Initial commit
This commit is contained in:
commit
22a1006598
1816 files changed
+410742
No files matched your search
@@ -0,0 +1,515 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services;
|
||||
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class AutoSubscribe
|
||||
{
|
||||
public function getRegistrationSettings()
|
||||
{
|
||||
$defaults = [
|
||||
'status' => 'no',
|
||||
'target_list' => '',
|
||||
'target_tags' => [],
|
||||
'double_optin' => 'no'
|
||||
];
|
||||
|
||||
$settings = fluentcrm_get_option('user_registration_subscribe_settings', []);
|
||||
|
||||
if (!$settings) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
return wp_parse_args($settings, $defaults);
|
||||
}
|
||||
|
||||
public function getRegistrationFields()
|
||||
{
|
||||
return [
|
||||
'title' => __('User Signup Optin Settings', 'fluent-crm'),
|
||||
'sub_title' => __('Automatically add your new user signups as subscriber in FluentCRM', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'status' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => '',
|
||||
'checkbox_label' => __('Enable Create new contacts in FluentCRM when users register in WordPress', 'fluent-crm'),
|
||||
'checkbox_description' => __('Automatically add your new user signups as subscriber in FluentCRM', 'fluent-crm'),
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no'
|
||||
],
|
||||
'target_list' => [
|
||||
'type' => 'option-selector',
|
||||
'label' => __('Assign List', 'fluent-crm'),
|
||||
'option_key' => 'lists',
|
||||
'is_multiple' => false,
|
||||
'creatable' => true,
|
||||
'placeholder' => __('Select Assign List', 'fluent-crm'),
|
||||
'inline_help' => __('Select the list that will be assigned for new user registration in your site', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'target_tags' => [
|
||||
'type' => 'option-selector',
|
||||
'label' => __('Assign Tags', 'fluent-crm'),
|
||||
'option_key' => 'tags',
|
||||
'is_multiple' => true,
|
||||
'creatable' => true,
|
||||
'placeholder' => __('Select Assign Tag', 'fluent-crm'),
|
||||
'inline_help' => __('Select the tags that will be assigned for new user registration in your site', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'double_optin' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => __('Double Opt-In', 'fluent-crm'),
|
||||
'checkbox_label' => __('Enable Double-Optin Email Confirmation', 'fluent-crm'),
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getCommentSettings()
|
||||
{
|
||||
$defaults = [
|
||||
'status' => 'no',
|
||||
'checkbox_label' => __('Subscribe to newsletter', 'fluent-crm'),
|
||||
'auto_checked' => 'no',
|
||||
'target_list' => '',
|
||||
'show_only_new' => 'yes',
|
||||
'target_tags' => [],
|
||||
'double_optin' => 'yes'
|
||||
];
|
||||
|
||||
$settings = fluentcrm_get_option('comment_form_subscribe_settings', []);
|
||||
|
||||
if (!$settings) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
return wp_parse_args($settings, $defaults);
|
||||
}
|
||||
|
||||
public function getCommentFields()
|
||||
{
|
||||
return [
|
||||
'title' => __('Comment Form Subscription Settings', 'fluent-crm'),
|
||||
'sub_title' => __('Automatically add your site commenter as a subscriber in FluentCRM', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'status' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => '',
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'checkbox_label' => __('Enable Create new contacts in FluentCRM when a visitor add a comment in your comment form', 'fluent-crm'),
|
||||
'checkbox_description' => __('Automatically add your site commenter as subscriber in FluentCRM', 'fluent-crm'),
|
||||
],
|
||||
'checkbox_label' => [
|
||||
'label' => __('Checkbox Label for Comment Form', 'fluent-crm'),
|
||||
'type' => 'input-text',
|
||||
'placeholder' => __('Checkbox Label for Comment Form', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'target_list' => [
|
||||
'type' => 'option-selector',
|
||||
'label' => __('Assign List', 'fluent-crm'),
|
||||
'option_key' => 'lists',
|
||||
'is_multiple' => false,
|
||||
'placeholder' => __('Select Assign List', 'fluent-crm'),
|
||||
'inline_help' => __('Select the list that will be assigned for comment will be made in comment forms', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'target_tags' => [
|
||||
'type' => 'option-selector',
|
||||
'label' => __('Assign Tags', 'fluent-crm'),
|
||||
'option_key' => 'tags',
|
||||
'is_multiple' => true,
|
||||
'placeholder' => __('Select Assign Tag', 'fluent-crm'),
|
||||
'inline_help' => __('Select the tags that will be assigned for new comment will be made in comment forms', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'auto_checked' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => '',
|
||||
'checkbox_label' => __('Enable auto checked status on Comment Form subscription', 'fluent-crm'),
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'show_only_new' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => '',
|
||||
'checkbox_label' => __('Do not show the checkbox if current user already subscribed state', 'fluent-crm'),
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'double_optin' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => __('Double Opt-In', 'fluent-crm'),
|
||||
'checkbox_label' => __('Enable Double-Optin Email Confirmation', 'fluent-crm'),
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getUserSyncSettings()
|
||||
{
|
||||
$defaults = [
|
||||
'status' => 'no',
|
||||
'delete_contact_on_user_delete' => 'no'
|
||||
];
|
||||
|
||||
$settings = fluentcrm_get_option('user_syncing_settings', []);
|
||||
|
||||
if (!$settings) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
return wp_parse_args($settings, $defaults);
|
||||
}
|
||||
|
||||
public function getUserSyncFields()
|
||||
{
|
||||
return [
|
||||
'title' => __('Auto Sync User Data and Contact Data', 'fluent-crm'),
|
||||
'sub_title' => __('Automatically Sync your WP User Data and FluentCRM Contact Data', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'status' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => '',
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'checkbox_label' => __('Enable Sync between WP User Data and FluentCRM Contact Data', 'fluent-crm'),
|
||||
'checkbox_description' => __('When enabled, changes to WordPress user profile fields (name, email) will be automatically synced to the corresponding FluentCRM contact', 'fluent-crm')
|
||||
],
|
||||
'delete_contact_on_user_delete' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => '',
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'checkbox_label' => __('Delete FluentCRM contact on WP User delete', 'fluent-crm'),
|
||||
'checkbox_description' => __('When enabled, deleting a WordPress user will also permanently delete the associated FluentCRM contact record', 'fluent-crm')
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getWooCheckoutSettings()
|
||||
{
|
||||
$defaults = [
|
||||
'auto_checkout_fill' => 'no',
|
||||
'status' => 'no',
|
||||
'checkbox_label' => __('Sign me up for the newsletter!', 'fluent-crm'),
|
||||
'auto_checked' => 'no',
|
||||
'target_list' => '',
|
||||
'show_only_new' => 'yes',
|
||||
'target_tags' => [],
|
||||
'double_optin' => 'yes'
|
||||
];
|
||||
|
||||
$settings = fluentcrm_get_option('woo_checkout_form_subscribe_settings', []);
|
||||
|
||||
if (!$settings) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
return wp_parse_args($settings, $defaults);
|
||||
}
|
||||
|
||||
public function getWooCheckoutFields()
|
||||
{
|
||||
return [
|
||||
'title' => __('Woocommerce Checkout Subscription Field', 'fluent-crm'),
|
||||
'sub_title' => __('Add a subscription box to WooCommerce Checkout Form', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'auto_checkout_fill' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => '',
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'checkbox_label' => __('Automatically fill WooCommerce Checkout field value with current contact data', 'fluent-crm')
|
||||
],
|
||||
'status' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => '',
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'checkbox_label' => __('Enable Subscription Checkbox to WooCommerce Checkout Page', 'fluent-crm')
|
||||
],
|
||||
'checkbox_label' => [
|
||||
'label' => __('Checkbox Label for Checkout checkbox', 'fluent-crm'),
|
||||
'type' => 'input-text',
|
||||
'placeholder' => __('Checkbox Label for Checkout checkbox', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'target_list' => [
|
||||
'type' => 'option-selector',
|
||||
'label' => __('Assign List', 'fluent-crm'),
|
||||
'option_key' => 'lists',
|
||||
'is_multiple' => false,
|
||||
'placeholder' => __('Select Assign List', 'fluent-crm'),
|
||||
'inline_help' => __('Select the list that will be assigned when checkbox checked', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'target_tags' => [
|
||||
'type' => 'option-selector',
|
||||
'label' => __('Assign Tags', 'fluent-crm'),
|
||||
'option_key' => 'tags',
|
||||
'is_multiple' => true,
|
||||
'placeholder' => __('Select Assign Tag', 'fluent-crm'),
|
||||
'inline_help' => __('Select the tags that will be assigned when checkbox checked', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'auto_checked' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => '',
|
||||
'checkbox_label' => __('Enable auto checked status on checkout page checkbox', 'fluent-crm'),
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'show_only_new' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => '',
|
||||
'checkbox_label' => __('Do not show the checkbox if current user already in subscribed state', 'fluent-crm'),
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'double_optin' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => __('Double Opt-In', 'fluent-crm'),
|
||||
'checkbox_label' => __('Enable Double-Optin Email Confirmation', 'fluent-crm'),
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the saved FluentCart checkout subscription settings merged with defaults.
|
||||
*
|
||||
* Unlike the WooCommerce equivalent, there is no 'auto_checkout_fill'
|
||||
* option — only the opt-in checkbox feature is supported for FluentCart.
|
||||
*/
|
||||
public function getFluentCartCheckoutSettings()
|
||||
{
|
||||
$defaults = [
|
||||
'status' => 'no',
|
||||
'checkbox_label' => __('Sign me up for the newsletter!', 'fluent-crm'),
|
||||
'auto_checked' => 'no',
|
||||
'target_list' => '',
|
||||
'show_only_new' => 'yes',
|
||||
'target_tags' => [],
|
||||
'double_optin' => 'yes'
|
||||
];
|
||||
|
||||
$settings = fluentcrm_get_option('fluent_cart_checkout_form_subscribe_settings', []);
|
||||
|
||||
if (!$settings) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
$settings = wp_parse_args($settings, $defaults);
|
||||
|
||||
// Ensure target_list is always a string so it matches the string option
|
||||
// values emitted by the _OptionSelector.vue component (String(option.id))
|
||||
if ($settings['target_list']) {
|
||||
$settings['target_list'] = (string) $settings['target_list'];
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and whitelist FluentCart checkout subscription settings before
|
||||
* persisting. Guards the stored option against malformed client payloads:
|
||||
* yes/no flags are forced to valid values, list/tag IDs are coerced to
|
||||
* strings (validated as integer IDs) and the label is plain text.
|
||||
*/
|
||||
public function sanitizeFluentCartCheckoutSettings($settings)
|
||||
{
|
||||
$settings = is_array($settings) ? $settings : [];
|
||||
|
||||
$yesNo = function ($value, $default = 'no') {
|
||||
if ($value === 'yes' || $value === 'no') {
|
||||
return $value;
|
||||
}
|
||||
return $default;
|
||||
};
|
||||
|
||||
$listId = Arr::get($settings, 'target_list');
|
||||
$tagIds = array_filter(array_map('intval', (array)Arr::get($settings, 'target_tags', [])));
|
||||
|
||||
return [
|
||||
'status' => $yesNo(Arr::get($settings, 'status')),
|
||||
'checkbox_label' => sanitize_text_field(Arr::get($settings, 'checkbox_label', '')),
|
||||
'auto_checked' => $yesNo(Arr::get($settings, 'auto_checked')),
|
||||
'target_list' => $listId ? (string) intval($listId) : '',
|
||||
'show_only_new' => $yesNo(Arr::get($settings, 'show_only_new'), 'yes'),
|
||||
'target_tags' => array_values($tagIds),
|
||||
'double_optin' => $yesNo(Arr::get($settings, 'double_optin'), 'yes')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Field definitions for the FluentCart checkout subscription settings panel,
|
||||
* rendered by the shared form-builder component in _GeneralSettings.vue.
|
||||
*/
|
||||
public function getFluentCartCheckoutFields()
|
||||
{
|
||||
return [
|
||||
'title' => __('FluentCart Checkout Subscription Field', 'fluent-crm'),
|
||||
'sub_title' => __('Add a subscription box to FluentCart Checkout Form', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'status' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => '',
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'checkbox_label' => __('Enable Subscription Checkbox to FluentCart Checkout Page', 'fluent-crm')
|
||||
],
|
||||
'checkbox_label' => [
|
||||
'label' => __('Checkbox Label for Checkout checkbox', 'fluent-crm'),
|
||||
'type' => 'input-text',
|
||||
'placeholder' => __('Checkbox Label for Checkout checkbox', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'target_list' => [
|
||||
'type' => 'option-selector',
|
||||
'label' => __('Assign List', 'fluent-crm'),
|
||||
'option_key' => 'lists',
|
||||
'is_multiple' => false,
|
||||
'placeholder' => __('Select Assign List', 'fluent-crm'),
|
||||
'inline_help' => __('Select the list that will be assigned when checkbox checked', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'target_tags' => [
|
||||
'type' => 'option-selector',
|
||||
'label' => __('Assign Tags', 'fluent-crm'),
|
||||
'option_key' => 'tags',
|
||||
'is_multiple' => true,
|
||||
'placeholder' => __('Select Assign Tag', 'fluent-crm'),
|
||||
'inline_help' => __('Select the tags that will be assigned when checkbox checked', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'auto_checked' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => '',
|
||||
'checkbox_label' => __('Enable auto checked status on checkout page checkbox', 'fluent-crm'),
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'show_only_new' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => '',
|
||||
'checkbox_label' => __('Do not show the checkbox if current user already in subscribed state', 'fluent-crm'),
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'double_optin' => [
|
||||
'type' => 'inline-checkbox',
|
||||
'label' => __('Double Opt-In', 'fluent-crm'),
|
||||
'checkbox_label' => __('Enable Double-Optin Email Confirmation', 'fluent-crm'),
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no',
|
||||
'dependency' => [
|
||||
'depends_on' => 'status',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services;
|
||||
|
||||
use FluentCrm\App\Services\BlockRender\CartProduct;
|
||||
use FluentCrm\App\Services\BlockRender\CartProducts;
|
||||
use FluentCrm\App\Services\BlockRender\WooProduct;
|
||||
use FluentCrm\App\Services\BlockRender\WooProducts;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class BlockParser
|
||||
{
|
||||
public function __construct($subscriber = null)
|
||||
{
|
||||
BlockParserHelper::setSubscriber($subscriber);
|
||||
|
||||
if (!fluentCrmRunTimeCache('fluentcrm_block_parser_initiated')) {
|
||||
fluentCrmRunTimeCache('fluentcrm_block_parser_initiated', 'yes');
|
||||
add_filter('render_block', array($this, 'alterBlockContent'), 999, 2);
|
||||
}
|
||||
}
|
||||
|
||||
public function parse($content)
|
||||
{
|
||||
try {
|
||||
$gutenParse = new \FluentCrm\App\Services\GutenbergEmailParser();
|
||||
$parsed = $gutenParse->parse($content);
|
||||
} catch (\Throwable $e) {
|
||||
$parsed = '';
|
||||
}
|
||||
|
||||
$useFallback = (bool)apply_filters('fluent_crm/block_parser_legacy_fallback_enabled', true, $content, $parsed);
|
||||
if ($useFallback && $this->shouldFallbackToLegacy($content, $parsed)) {
|
||||
return $this->parseWithLegacyParser($content);
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function shouldFallbackToLegacy($content, $parsed)
|
||||
{
|
||||
if (!is_string($content) || trim($content) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_string($parsed) && trim($parsed) !== '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strpos($content, '<!-- wp:') !== false;
|
||||
}
|
||||
|
||||
private static $syncedPatternCache = [];
|
||||
|
||||
private function renderSyncedPattern($data)
|
||||
{
|
||||
$ref = isset($data['attrs']['ref']) ? (int) $data['attrs']['ref'] : 0;
|
||||
if (!$ref) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!isset(self::$syncedPatternCache[$ref])) {
|
||||
$pattern = \FluentCrm\App\Models\Meta::where('object_type', 'email_pattern')
|
||||
->where('id', $ref)
|
||||
->first();
|
||||
|
||||
self::$syncedPatternCache[$ref] = ($pattern && !empty($pattern->value['content']))
|
||||
? $pattern->value['content']
|
||||
: '';
|
||||
}
|
||||
|
||||
$content = self::$syncedPatternCache[$ref];
|
||||
if (!$content) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->parseWithLegacyParser($content);
|
||||
}
|
||||
|
||||
private function parseWithLegacyParser($content)
|
||||
{
|
||||
if (!function_exists('parse_blocks') || !function_exists('render_block')) {
|
||||
return (string)$content;
|
||||
}
|
||||
|
||||
$blocks = parse_blocks((string)$content);
|
||||
if (empty($blocks)) {
|
||||
return (string)$content;
|
||||
}
|
||||
|
||||
$output = '';
|
||||
foreach ($blocks as $block) {
|
||||
$block = $this->sanitizeBlock($block);
|
||||
$output .= render_block($block);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
private function sanitizeBlock($block)
|
||||
{
|
||||
if (!empty($block['innerBlocks'])) {
|
||||
foreach ($block['innerBlocks'] as $index => $childBlock) {
|
||||
$block['innerBlocks'][$index] = $this->sanitizeBlock($childBlock);
|
||||
}
|
||||
}
|
||||
|
||||
$blockName = $block['blockName'];
|
||||
|
||||
if ($blockName == 'core/columns') {
|
||||
$blockCounts = count($block['innerBlocks']);
|
||||
$lastContentIndex = $blockCounts * 2;
|
||||
foreach ($block['innerBlocks'] as $blockIndex => $blockItem) {
|
||||
$block['innerBlocks'][$blockIndex]['fc_total_blocks'] = $blockCounts;
|
||||
}
|
||||
$block['innerContent'][0] = $this->getRowOpening($block);
|
||||
$block['innerContent'][$lastContentIndex] = $this->getRowClosing($block);
|
||||
} else if ($blockName == 'core/media-text') {
|
||||
$blockCounts = count($block['innerBlocks']);
|
||||
$lastContentIndex = $blockCounts * 2;
|
||||
$block['innerContent'][0] = $this->getMediaTextOpening($block);
|
||||
$block['innerContent'][$lastContentIndex] = $this->getMediaTextClosing($block);
|
||||
} else if ($blockName == 'core/buttons') {
|
||||
$blockCounts = count($block['innerBlocks']);
|
||||
$lastContentIndex = $blockCounts * 2;
|
||||
foreach ($block['innerBlocks'] as $blockIndex => $blockItem) {
|
||||
$block['innerBlocks'][$blockIndex]['fc_total_blocks'] = $blockCounts;
|
||||
$block['innerBlocks'][$blockIndex]['parent_attrs'] = $block['attrs'];
|
||||
}
|
||||
$block['innerContent'][0] = $this->getButtonsOpening($block);
|
||||
$block['innerContent'][$lastContentIndex] = $this->getButtonsClosing($block);
|
||||
} else if ($blockName == 'core/image') {
|
||||
$block['innerContent'][0] = $this->getImageBlockHtml($block);
|
||||
} else if ($blockName == 'core/latest-posts') {
|
||||
$block['blockName'] = 'fluent-crm/core-posts';
|
||||
$block['fc_total_blocks'] = 1;
|
||||
} else if ($blockName == 'fluentcrm/woo-product' || $blockName == 'fluent-crm/woo-product') {
|
||||
$block['innerContent'][0] = '';
|
||||
$block['innerContent'][2] = '';
|
||||
$block['fc_total_blocks'] = 1;
|
||||
} else if ($blockName == 'fluent-crm/latest-posts') {
|
||||
$block['innerContent'][0] = '';
|
||||
$block['innerContent'][2] = '';
|
||||
$block['fc_total_blocks'] = 1;
|
||||
} else if ($blockName == 'fluent-crm/woo-products') {
|
||||
$block['innerContent'][0] = '';
|
||||
$block['innerContent'][2] = '';
|
||||
$block['fc_total_blocks'] = 1;
|
||||
} else if ($blockName == 'fluent-crm/cart-products') {
|
||||
$block['innerContent'][0] = '';
|
||||
$block['innerContent'][2] = '';
|
||||
$block['fc_total_blocks'] = 1;
|
||||
} else if ($blockName == 'fluent-crm/cart-product') {
|
||||
$block['innerContent'][0] = '';
|
||||
$block['innerContent'][2] = '';
|
||||
$block['fc_total_blocks'] = 1;
|
||||
}
|
||||
|
||||
return $block;
|
||||
}
|
||||
|
||||
public function alterBlockContent($content, $data)
|
||||
{
|
||||
if (isset($data['blockName']) && $data['blockName'] === 'core/block') {
|
||||
return $this->renderSyncedPattern($data);
|
||||
}
|
||||
|
||||
if (isset($data['blockName']) && in_array($data['blockName'], ['fluent-crm/conditional-content', 'fluentcrm/conditional-group'])) {
|
||||
return $this->renderConditionalBlock($content, $data);
|
||||
}
|
||||
|
||||
if (empty($data['fc_total_blocks'])) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$blockName = $data['blockName'];
|
||||
|
||||
if ($blockName == 'core/column') {
|
||||
$content = $this->getColumnOpening($data) . $content . $this->getColumnClosing($data);
|
||||
} else if ($blockName == 'core/button') {
|
||||
$content = $this->getButtonWrapper($content, $data);
|
||||
} else if ($blockName == 'fluent-crm/core-posts') {
|
||||
$content = $this->renderLatestPosts($data);
|
||||
} else if ($blockName == 'fluentcrm/woo-product' || $blockName == 'fluent-crm/woo-product') {
|
||||
$content = WooProduct::renderProduct($content, $data);
|
||||
} else if ($blockName == 'fluent-crm/latest-posts') {
|
||||
|
||||
$content = '';
|
||||
if (class_exists('\FluentCampaign\App\Services\PostParser\LatestPost')) {
|
||||
$content = \FluentCampaign\App\Services\PostParser\LatestPost::renderPosts($content, $data);
|
||||
}
|
||||
} else if ($blockName == 'fluent-crm/woo-products') {
|
||||
$content = WooProducts::renderProducts($content, $data);
|
||||
} else if ($blockName == 'fluent-crm/cart-products') {
|
||||
$content = CartProducts::renderProducts($content, $data);
|
||||
} else if ($blockName == 'fluent-crm/cart-product') {
|
||||
$content = CartProduct::renderProduct('', $data);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function getMediaTextOpening($block)
|
||||
{
|
||||
$backgroundColorClass = Arr::get($block, 'attrs.backgroundColor');
|
||||
$prevContent = $block['innerContent'][0];
|
||||
preg_match('/<figure (.*?)<\/figure>/s', $prevContent, $match);
|
||||
$figure = $match[0];
|
||||
$mediaWidth = Arr::get($block, 'attrs.mediaWidth', 50);
|
||||
$contentWidth = 100 - $mediaWidth;
|
||||
$MediaAlign = Arr::get($block, 'attrs.mediaPosition', 'left');
|
||||
$textAlign = ($MediaAlign == 'right') ? 'left' : 'right';
|
||||
|
||||
$imageFill = Arr::get($block, 'attrs.imageFill') ? 'has_bg_image' : 'no_image_fill';
|
||||
|
||||
$background = Arr::get($block, 'attrs.style.color.background');
|
||||
$extraCss = '';
|
||||
if (!$backgroundColorClass && $background) {
|
||||
$extraCss = 'background: ' . $background . ';background-color:' . $background . ';';
|
||||
}
|
||||
if ($backgroundColorClass) {
|
||||
$backgroundColorClass = 'has-' . Helper::kebabCase($backgroundColorClass) . '-background-color';
|
||||
}
|
||||
$html = '<table class="fce_row fc_row_media_text ' . $backgroundColorClass . '" border="0" cellpadding="0" cellspacing="0" width="100%" style="' . $extraCss . 'table-layout: fixed; border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;-ms-text-size-adjust: 100%;-webkit-text-size-adjust: 100%;"><tbody><tr>';
|
||||
$html .= '<td><table class="fc_media_table" align="' . $MediaAlign . '" style="width: ' . $mediaWidth . '%;" border="0" cellpadding="0" cellspacing="0"><tbody><tr><td align="left" class="' . $imageFill . '" valign="middle">' . $figure . '</td></tr></tbody></table>';
|
||||
$html .= '<table class="fc_media_text" valign="middle" align="' . $textAlign . '" style="width: ' . $contentWidth . '%;" border="0" cellpadding="0" cellspacing="0"><tbody><tr><td style="padding: 30px 20px 10px;" align="left" valign="middle">';
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function getMediaTextClosing($block)
|
||||
{
|
||||
return '</td></tr></tbody></table></td></tr></tbody></table>';
|
||||
}
|
||||
|
||||
private function getRowOpening($block)
|
||||
{
|
||||
$isStackOnMobile = Arr::get($block, 'attrs.isStackedOnMobile', true);
|
||||
|
||||
$background = Arr::get($block, 'attrs.style.color.background');
|
||||
$defaultBackground = Arr::get($block, 'attrs.backgroundColor');
|
||||
|
||||
$style = 'margin-bottom: 10px;';
|
||||
if ($background) {
|
||||
$style .= 'background-color:' . $background . ';';
|
||||
} else if ($defaultBackground) {
|
||||
$defaultBackground = 'has-' . Helper::kebabCase($defaultBackground) . '-background-color';
|
||||
}
|
||||
|
||||
$class = 'fce_row';
|
||||
|
||||
if ($isStackOnMobile) {
|
||||
$class = 'fce_row fce_stacked';
|
||||
}
|
||||
|
||||
return '<table class="' . esc_attr($class) . ' ' . esc_attr($defaultBackground) . '" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed; border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;-ms-text-size-adjust: 100%;-webkit-text-size-adjust: 100%;' . $style . '"><tbody><tr>';
|
||||
}
|
||||
|
||||
private function getRowClosing($block)
|
||||
{
|
||||
return '</tr></tbody></table>';
|
||||
}
|
||||
|
||||
private function getColumnOpening($block)
|
||||
{
|
||||
|
||||
$width = Arr::get($block, 'attrs.width');
|
||||
if (!$width) {
|
||||
$total = !empty($block['fc_total_blocks']) ? $block['fc_total_blocks'] : 1;
|
||||
$width = 100 / $total;
|
||||
}
|
||||
$vAlign = Arr::get($block, 'attrs.verticalAlignment', 'middle');
|
||||
return '<td align="center" valign="' . $vAlign . '" width="' . $width . '%" class="fce_column"><table border="0" cellpadding="10" cellspacing="0" width="100%"><tr><td class="fc_column_content">';
|
||||
}
|
||||
|
||||
private function getColumnClosing($block)
|
||||
{
|
||||
return '</td></tr></table></td>';
|
||||
}
|
||||
|
||||
private function getButtonsOpening($block)
|
||||
{
|
||||
$alignment = Arr::get($block, 'innerBlocks.0.attrs.align', '');
|
||||
$align = Arr::get($block, 'attrs.layout.justifyContent', 'left');
|
||||
$tableCssClass = 'fce_row fce_buttons_row';
|
||||
|
||||
$tableCssClass .= ' tb_btn_' . $alignment;
|
||||
|
||||
if ($definedWidth = Arr::get($block, 'innerBlocks.0.attrs.width')) {
|
||||
$tableCssClass .= ' wp-block-button__width-' . $definedWidth;
|
||||
}
|
||||
|
||||
$btnCount = count(Arr::get($block, 'innerBlocks'));
|
||||
|
||||
if ($btnCount > 1) {
|
||||
$tableCssClass .= ' fc_btn_multiple fc_btn_count_' . $btnCount;
|
||||
}
|
||||
|
||||
$extraStyle = '';
|
||||
if ($spacings = Arr::get($block, 'attrs.style.spacing.margin', [])) {
|
||||
|
||||
if (!empty($spacings['top'])) {
|
||||
$extraStyle .= 'margin-top:' . $spacings['top'] . ';';
|
||||
}
|
||||
|
||||
if (!empty($spacings['bottom'])) {
|
||||
$extraStyle .= 'margin-bottom:' . $spacings['bottom'] . ';';
|
||||
}
|
||||
}
|
||||
|
||||
return '<table valign="middle" align="' . $align . '" class="' . $tableCssClass . '" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed; border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;-ms-text-size-adjust: 100%;-webkit-text-size-adjust: 100%; float:none;' . $extraStyle . '"><tbody><tr>';
|
||||
}
|
||||
|
||||
private function getButtonWrapper($content, $data)
|
||||
{
|
||||
$defaultClass = Arr::get($data, 'attrs.className', '');
|
||||
$backgroundColor = Arr::get($data, 'attrs.style.color.background');
|
||||
|
||||
$typoTextTransform = Arr::get($data, 'attrs.style.typography.textTransform');
|
||||
$typoFontWeight = Arr::get($data, 'attrs.style.typography.fontWeight');
|
||||
$typoFontStyle = Arr::get($data, 'attrs.style.typography.fontStyle');
|
||||
|
||||
$typography = '';
|
||||
if ($typoTextTransform) {
|
||||
$typography .= 'text-transform: ' . $typoTextTransform . ';';
|
||||
}
|
||||
if ($typoFontWeight) {
|
||||
$typography .= 'font-weight: ' . $typoFontWeight . ';';
|
||||
}
|
||||
if ($typoFontStyle) {
|
||||
$typography .= 'font-style: ' . $typoFontStyle . ';';
|
||||
}
|
||||
|
||||
if (!$backgroundColor) {
|
||||
$bgClass = Arr::get($data, 'attrs.backgroundColor');
|
||||
$backgroundColor = Helper::getColorSchemeValue($bgClass);
|
||||
}
|
||||
|
||||
$hasTextColor = Arr::get($data, 'attrs.style.color.text') || Arr::get($data, 'attrs.textColor');
|
||||
|
||||
$btn_wrapper_class = $defaultClass . ' ';
|
||||
if (!$backgroundColor && strpos($defaultClass, 'is-style-outline') === false) {
|
||||
$btn_wrapper_class .= 'fc_d_btn_bg ';
|
||||
$backgroundColor = '#32373c';
|
||||
}
|
||||
|
||||
if (!$hasTextColor) {
|
||||
$btn_wrapper_class .= 'fc_d_btn_color ';
|
||||
}
|
||||
|
||||
if (strpos($defaultClass, 'is-style-outline') !== false) {
|
||||
$backgroundColor = Arr::get($data, 'attrs.style.color.background');
|
||||
if (!$backgroundColor) {
|
||||
$backgroundColor = 'white';
|
||||
}
|
||||
}
|
||||
|
||||
$borderRadius = Arr::get($data, 'attrs.style.border.radius', '0px');
|
||||
|
||||
$content = trim(preg_replace("/<\/?div[^>]*\>/i", "", $content));
|
||||
|
||||
if (Arr::get($data, 'attrs.fontSize')) {
|
||||
$btn_wrapper_class .= ' has-' . Helper::kebabCase(Arr::get($data, 'attrs.fontSize')) . '-font-size ';
|
||||
}
|
||||
|
||||
$td = '<td class="fc_btn ' . trim($btn_wrapper_class) . '" align="center" style="' . $typography . ' border-radius: ' . $borderRadius . ';" bgcolor="' . $backgroundColor . '" border-radius="30px">';
|
||||
|
||||
$align = Arr::get($data, 'parent_attrs.parent_attrs.layout.justifyContent', 'center');
|
||||
|
||||
$alignment = $align == 'center' ? 'text-align: -webkit-center;' : ' ';
|
||||
|
||||
return '<td style="padding-right: 10px;' . $alignment . '" align="' . $align . '" valign="middle" class="fce_column"><table style="margin-bottom: 4px; margin-top: 4px;" border="0" cellspacing="0" cellpadding="0"><tr>' . $td . $content . '</td></tr></table></td>';
|
||||
}
|
||||
|
||||
private function getButtonsClosing($block)
|
||||
{
|
||||
return '</tr></tbody></table>';
|
||||
}
|
||||
|
||||
private function renderConditionalBlock($content, $data)
|
||||
{
|
||||
$checkType = $this->normalizeConditionalCheckType(Arr::get($data, 'attrs.condition_type', 'show_if_tag_exist'));
|
||||
|
||||
// Login-state conditions do not require subscriber/tag context.
|
||||
if ($checkType === 'show_if_user_logged_in') {
|
||||
return is_user_logged_in() ? $content : '';
|
||||
}
|
||||
|
||||
if ($checkType === 'show_if_user_not_logged_in') {
|
||||
return is_user_logged_in() ? '' : $content;
|
||||
}
|
||||
|
||||
$subscriber = $this->getConditionalBlockSubscriber();
|
||||
|
||||
if (!$subscriber) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$tagIds = Arr::get($data, 'attrs.tag_ids');
|
||||
if (!$tagIds) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$tagMatched = $subscriber->hasAnyTagId($tagIds);
|
||||
|
||||
if ($checkType == 'show_if_tag_exist') {
|
||||
if ($tagMatched) {
|
||||
return $content;
|
||||
};
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($checkType == 'show_if_tag_not_exist') {
|
||||
if ($tagMatched) {
|
||||
return '';
|
||||
};
|
||||
return $content;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function getConditionalBlockSubscriber()
|
||||
{
|
||||
$subscriber = BlockParserHelper::getSubscriber();
|
||||
|
||||
// Frontend conditional blocks should resolve the active FluentCRM contact lazily.
|
||||
if (!$subscriber && function_exists('fluentcrm_get_current_contact')) {
|
||||
$subscriber = fluentcrm_get_current_contact();
|
||||
}
|
||||
|
||||
if (!$subscriber) {
|
||||
/**
|
||||
* Filter the current subscriber while rendering the Conditional Block in FluentCRM.
|
||||
*
|
||||
* This filter allows you to modify the subscriber object used in the current block condition.
|
||||
*
|
||||
* @since 2.8.44
|
||||
*
|
||||
* @param object $subscriber The current subscriber object.
|
||||
* @return object The modified subscriber object.
|
||||
*/
|
||||
$subscriber = apply_filters('fluent_crm/get_current_block_condition_subscriber', $subscriber);
|
||||
}
|
||||
|
||||
return $subscriber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep backward compatibility with v2 conditional block values.
|
||||
*/
|
||||
private function normalizeConditionalCheckType($checkType)
|
||||
{
|
||||
$checkType = trim((string)$checkType);
|
||||
if ($checkType === '') {
|
||||
return 'show_if_tag_exist';
|
||||
}
|
||||
|
||||
$map = [
|
||||
// v2 legacy values
|
||||
'show_if_logged_in' => 'show_if_user_logged_in',
|
||||
'show_if_public_users' => 'show_if_user_not_logged_in',
|
||||
'show_if_tag_exists' => 'show_if_tag_exist',
|
||||
'show_if_tag_not_exists' => 'show_if_tag_not_exist',
|
||||
];
|
||||
|
||||
return Arr::get($map, $checkType, $checkType);
|
||||
}
|
||||
|
||||
private function getImageBlockHtml($block)
|
||||
{
|
||||
$classNames = implode(' ', array_filter([
|
||||
Arr::get($block, 'attrs.className'),
|
||||
'wp-block-image size-' . Arr::get($block, 'attrs.sizeSlug'),
|
||||
'align' . Arr::get($block, 'attrs.align', 'left')
|
||||
]));
|
||||
$radius = Arr::get($block, 'attrs.style.border.radius', '0px');
|
||||
$marginTop = $this->getSpacing('attrs.marginTop', $block);
|
||||
$marginBottom = $this->getSpacing('attrs.marginBottom', $block);
|
||||
$marginLeft = $this->getSpacing('attrs.marginLeft', $block);
|
||||
$marginRight = $this->getSpacing('attrs.marginRight', $block);
|
||||
|
||||
$paddingTop = $this->getSpacing('attrs.paddingTop', $block);
|
||||
$paddingBottom = $this->getSpacing('attrs.paddingBottom', $block);
|
||||
$paddingLeft = $this->getSpacing('attrs.paddingLeft', $block);
|
||||
$paddingRight = $this->getSpacing('attrs.paddingRight', $block);
|
||||
|
||||
$margin = '' . $marginTop . 'px ' . $marginRight . 'px ' . $marginBottom . 'px ' . $marginLeft . 'px';
|
||||
$padding = '' . $paddingTop . 'px ' . $paddingRight . 'px ' . $paddingBottom . 'px ' . $paddingLeft . 'px';
|
||||
|
||||
|
||||
$content = $block['innerContent'][0];
|
||||
$html = strip_tags($content, '<a><figcaption><img>');
|
||||
$html = str_replace(['<figcaption', 'figcaption/>'], ['<p', '/p>'], $html);
|
||||
$html = '<div class="' . $classNames . '" style="border-radius: ' . $radius . '; margin: ' . $margin . '; padding: ' . $padding . '">' . $html . '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function renderLatestPosts($attributes)
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
private function getSpacing($key, $block)
|
||||
{
|
||||
$data = Arr::get($block, $key, '0');
|
||||
if (empty($data)) {
|
||||
$data = '0';
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services;
|
||||
|
||||
class BlockParserHelper
|
||||
{
|
||||
private static $subscriber;
|
||||
|
||||
public static function setSubscriber($subscriber)
|
||||
{
|
||||
static::$subscriber = $subscriber;
|
||||
}
|
||||
|
||||
public static function getSubscriber()
|
||||
{
|
||||
return static::$subscriber;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace FluentCrm\App\Services\BlockRender;
|
||||
|
||||
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class BlockEditorHelper
|
||||
{
|
||||
public static function getStyleDefauls()
|
||||
{
|
||||
return [
|
||||
'spacing' => [
|
||||
[
|
||||
'name' => '2X-Small',
|
||||
'slug' => 'fc-xx-small',
|
||||
'size' => '5px',
|
||||
],
|
||||
[
|
||||
'name' => 'X-Small',
|
||||
'slug' => 'fc-x-small',
|
||||
'size' => '10px',
|
||||
],
|
||||
[
|
||||
'name' => 'Small',
|
||||
'slug' => 'fc-small',
|
||||
'size' => '14px',
|
||||
],
|
||||
[
|
||||
'name' => 'Medium',
|
||||
'slug' => 'fc-medium',
|
||||
'size' => '20px',
|
||||
],
|
||||
[
|
||||
'name' => 'Large',
|
||||
'slug' => 'fc-large',
|
||||
'size' => '30px',
|
||||
],
|
||||
[
|
||||
'name' => 'X-Large',
|
||||
'slug' => 'fc-x-large',
|
||||
'size' => '45px',
|
||||
],
|
||||
[
|
||||
'name' => '2X-Large',
|
||||
'slug' => 'fc-xx-large',
|
||||
'size' => '60px',
|
||||
],
|
||||
],
|
||||
'font-family' => [
|
||||
[
|
||||
'name' => 'System UI',
|
||||
'slug' => 'system-ui',
|
||||
'fontFamily' => "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'",
|
||||
],
|
||||
[
|
||||
'name' => 'Arial',
|
||||
'slug' => 'arial',
|
||||
'fontFamily' => "Arial, 'Helvetica Neue', Helvetica, sans-serif",
|
||||
],
|
||||
[
|
||||
'name' => 'Georgia',
|
||||
'slug' => 'georgia',
|
||||
'fontFamily' => "Georgia, Times, 'Times New Roman', serif",
|
||||
],
|
||||
[
|
||||
'name' => 'Helvetica',
|
||||
'slug' => 'helvetica',
|
||||
'fontFamily' => "Helvetica, Arial, Verdana, sans-serif",
|
||||
],
|
||||
[
|
||||
'name' => 'Courier New',
|
||||
'slug' => 'courier-new',
|
||||
'fontFamily' => "'Courier New', Courier, 'Lucida Sans Typewriter', monospace",
|
||||
],
|
||||
[
|
||||
'name' => 'Times New Roman',
|
||||
'slug' => 'times-new-roman',
|
||||
'fontFamily' => "'Times New Roman', Times, Baskerville, Georgia, serif",
|
||||
],
|
||||
[
|
||||
'name' => 'Trebuchet MS',
|
||||
'slug' => 'trebuchet-ms',
|
||||
'fontFamily' => "'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', Tahoma, sans-serif",
|
||||
],
|
||||
[
|
||||
'name' => 'Verdana',
|
||||
'slug' => 'verdana',
|
||||
'fontFamily' => "Verdana, Geneva, sans-serif",
|
||||
],
|
||||
],
|
||||
'font-size' => [
|
||||
[
|
||||
'name' => 'Small',
|
||||
'slug' => 'fc-small',
|
||||
'size' => '13px',
|
||||
],
|
||||
[
|
||||
'name' => 'Regular',
|
||||
'slug' => 'fc-regular',
|
||||
'size' => '16px',
|
||||
],
|
||||
[
|
||||
'name' => 'Medium',
|
||||
'slug' => 'fc-medium',
|
||||
'size' => '18px',
|
||||
],
|
||||
[
|
||||
'name' => 'Large',
|
||||
'slug' => 'fc-large',
|
||||
'size' => '26px',
|
||||
],
|
||||
[
|
||||
'name' => 'Extra Large',
|
||||
'slug' => 'fc-x-large',
|
||||
'size' => '32px',
|
||||
],
|
||||
],
|
||||
'color' => [
|
||||
[
|
||||
'name' => 'Black',
|
||||
'slug' => 'black',
|
||||
'color' => '#000000'
|
||||
],
|
||||
[
|
||||
'name' => 'Cyan bluish gray',
|
||||
'slug' => 'cyan-bluish-gray',
|
||||
'color' => '#abb8c3'
|
||||
],
|
||||
[
|
||||
'name' => 'White',
|
||||
'slug' => 'white',
|
||||
'color' => '#ffffff'
|
||||
],
|
||||
[
|
||||
'name' => 'Pale pink',
|
||||
'slug' => 'pale-pink',
|
||||
'color' => '#f78da7'
|
||||
],
|
||||
[
|
||||
'name' => 'Vivid red',
|
||||
'slug' => 'vivid-red',
|
||||
'color' => '#cf2e2e'
|
||||
],
|
||||
[
|
||||
'name' => 'Luminous vivid orange',
|
||||
'slug' => 'luminous-vivid-orange',
|
||||
'color' => '#ff6900'
|
||||
],
|
||||
[
|
||||
'name' => 'Luminous vivid amber',
|
||||
'slug' => 'luminous-vivid-amber',
|
||||
'color' => '#fcb900'
|
||||
],
|
||||
[
|
||||
'name' => 'Light green cyan',
|
||||
'slug' => 'light-green-cyan',
|
||||
'color' => '#7bdcb5'
|
||||
],
|
||||
[
|
||||
'name' => 'Vivid green cyan',
|
||||
'slug' => 'vivid-green-cyan',
|
||||
'color' => '#00d084'
|
||||
],
|
||||
[
|
||||
'name' => 'Pale cyan blue',
|
||||
'slug' => 'pale-cyan-blue',
|
||||
'color' => '#8ed1fc'
|
||||
],
|
||||
[
|
||||
'name' => 'Vivid cyan blue',
|
||||
'slug' => 'vivid-cyan-blue',
|
||||
'color' => '#0693e3'
|
||||
],
|
||||
[
|
||||
'name' => 'Vivid purple',
|
||||
'slug' => 'vivid-purple',
|
||||
'color' => '#9b51e0'
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public static function getDefaultPreset($key = '')
|
||||
{
|
||||
$defaults = self::getStyleDefauls();
|
||||
|
||||
if ($key && isset($defaults[$key])) {
|
||||
return $defaults[$key];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getStyleDefaultPresets()
|
||||
{
|
||||
$defaults = self::getStyleDefauls();
|
||||
|
||||
$cssProps = '';
|
||||
|
||||
foreach ($defaults['spacing'] as $item) {
|
||||
$cssProps .= '--wp--preset--spacing--' . $item['slug'] . ': ' . $item['size'] . ';';
|
||||
}
|
||||
|
||||
// Keep core color preset variables available inside the editor canvas.
|
||||
// Without these, Gutenberg's link color styles can resolve to empty values.
|
||||
foreach ($defaults['color'] as $item) {
|
||||
$cssProps .= '--wp--preset--color--' . $item['slug'] . ': ' . $item['color'] . ';';
|
||||
}
|
||||
|
||||
return $cssProps;
|
||||
}
|
||||
|
||||
public static function replaceStyleSlugsWithValues($css = '')
|
||||
{
|
||||
if (!$css) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$defaults = self::getStyleDefauls();
|
||||
|
||||
$replaces = [
|
||||
'var(--wp--preset--color--white)' => 'white',
|
||||
];
|
||||
|
||||
foreach ($defaults as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $item) {
|
||||
if ($key === 'spacing') {
|
||||
$replaces['var(--wp--preset--spacing--' . $item['slug'] . ')'] = $item['size'];
|
||||
} else if ($key === 'font-size') {
|
||||
$replaces['var(--fcom--font--size--' . $item['slug'] . ')'] = $item['size'];
|
||||
$replaces['var(--wp--preset--font-size--' . $item['slug'] . ')'] = $item['size'];
|
||||
} else if ($key === 'color') {
|
||||
$replaces['var(--fcom--color--' . $item['slug'] . ')'] = $item['color'];
|
||||
$replaces['var(--wp--preset--color--' . $item['slug'] . ')'] = $item['color'];
|
||||
} else if ($key === 'font-family') {
|
||||
$replaces['var(--wp--preset--font-family--' . $item['slug'] . ')'] = $item['fontFamily'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include active theme font size presets too (small, medium, large, etc).
|
||||
$themeFontSizes = \FluentCrm\App\Services\Helper::getThemeFontSizes();
|
||||
foreach ((array)$themeFontSizes as $themeFontSize) {
|
||||
if (!is_array($themeFontSize) || empty($themeFontSize['slug']) || !isset($themeFontSize['size'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$size = $themeFontSize['size'];
|
||||
if (is_numeric($size)) {
|
||||
$size = $size . 'px';
|
||||
}
|
||||
|
||||
$replaces['var(--wp--preset--font-size--' . $themeFontSize['slug'] . ')'] = $size;
|
||||
}
|
||||
|
||||
return str_replace(array_keys($replaces), array_values($replaces), $css);
|
||||
}
|
||||
|
||||
public static function renderAtts($atts = [], $echo = false)
|
||||
{
|
||||
$rendered = '';
|
||||
foreach ($atts as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$value = implode(' ', $value);
|
||||
}
|
||||
$value = esc_attr($value);
|
||||
$key = esc_attr($key);
|
||||
$rendered .= $key . '="' . $value . '" ';
|
||||
}
|
||||
|
||||
if ($echo) {
|
||||
echo $rendered;
|
||||
}
|
||||
|
||||
return $rendered;
|
||||
}
|
||||
|
||||
public static function renderStyles($styles = [], $echo = false)
|
||||
{
|
||||
$rendered = '';
|
||||
foreach ($styles as $key => $value) {
|
||||
if (!$value) {
|
||||
continue;
|
||||
}
|
||||
$rendered .= $key . ':' . $value . ';';
|
||||
}
|
||||
|
||||
if ($echo) {
|
||||
echo esc_attr($rendered);
|
||||
}
|
||||
|
||||
return esc_attr($rendered);
|
||||
}
|
||||
|
||||
public static function getBorderColor($attrs = [], $default = '')
|
||||
{
|
||||
if ($borderColor = Arr::get($attrs, 'borderColor', '')) {
|
||||
$color = 'var(--fcom--color--' . $borderColor . ')';
|
||||
return self::replaceStyleSlugsWithValues($color);
|
||||
}
|
||||
|
||||
$color = $default;
|
||||
if ($borderConfig = Arr::get($attrs, 'style.border', [])) {
|
||||
$color = Arr::get($borderConfig, 'color', '');
|
||||
}
|
||||
|
||||
return $color;
|
||||
}
|
||||
|
||||
public static function getDynamicCssForEditor()
|
||||
{
|
||||
$fonts = self::getDefaultPreset('font-family');
|
||||
|
||||
$css = '';
|
||||
|
||||
foreach ($fonts as $font) {
|
||||
$css .= '.editor-styles-wrapper .has-' . $font['slug'] . '-font-family { font-family: ' . $font['fontFamily'] . ' !important; }';
|
||||
}
|
||||
|
||||
return $css;
|
||||
|
||||
}
|
||||
|
||||
public static function getDefaultPrefConfig()
|
||||
{
|
||||
return [
|
||||
'content_width' => 700,
|
||||
'content_border_radius' => '0px',
|
||||
'content_padding_top' => '20px', // matched
|
||||
'content_padding_right' => '20px', // matched
|
||||
'content_padding_bottom' => '20px', // matched
|
||||
'content_padding_left' => '20px', // matched
|
||||
'content_margin_top' => '20px', // new
|
||||
'content_margin_bottom' => '20px', // new
|
||||
|
||||
'headings_font_family' => "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", // matched
|
||||
'content_font_family' => "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", // matched
|
||||
|
||||
'body_bg_color' => '#FAFAFA', // matched
|
||||
'content_bg_color' => '#FFFFFF', // matched
|
||||
|
||||
'headings_color' => '#202020', // matched
|
||||
'text_color' => '#202020', // matched
|
||||
'link_color' => '#0693e3',
|
||||
'link_color_hover' => '', // not used now
|
||||
|
||||
'paragraph_font_size' => '16px', // matched
|
||||
'paragraph_line_height' => '1.5', // matched
|
||||
|
||||
'footer_text_color' => '#202020', // matched,
|
||||
|
||||
'design_template' => 'simple'
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\BlockRender;
|
||||
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class CartProduct
|
||||
{
|
||||
private static $productsCache = [];
|
||||
|
||||
public static function renderProduct($buttonHtml, $data)
|
||||
{
|
||||
if (!defined('FLUENTCART_VERSION')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$defaultAtts = [
|
||||
'productId' => null,
|
||||
'showImage' => true,
|
||||
'showDescription' => true,
|
||||
'showPrice' => true,
|
||||
'showButton' => true,
|
||||
'buttonText' => __('Buy Now', 'fluent-crm'),
|
||||
'backgroundColor' => '#fffeeb',
|
||||
'contentColor' => '',
|
||||
'pricingColor' => '',
|
||||
'template' => 'left',
|
||||
];
|
||||
|
||||
$attrs = isset($data['attrs']) && is_array($data['attrs']) ? $data['attrs'] : [];
|
||||
$atts = wp_parse_args($attrs, $defaultAtts);
|
||||
$productId = absint($atts['productId']);
|
||||
if (!$productId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$product = self::getProductData($productId);
|
||||
if (!$product) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$template = self::normalizeTemplate(Arr::get($atts, 'template', 'left'));
|
||||
$showImage = RenderValueHelper::normalizeBool(Arr::get($atts, 'showImage'), true);
|
||||
$showDescription = RenderValueHelper::normalizeBool(Arr::get($atts, 'showDescription'), true);
|
||||
$showPrice = RenderValueHelper::normalizeBool(Arr::get($atts, 'showPrice'), true);
|
||||
$showButton = RenderValueHelper::normalizeBool(Arr::get($atts, 'showButton'), true);
|
||||
|
||||
$tableStyle = 'border-radius: 5px;';
|
||||
$contentColorStyle = '';
|
||||
|
||||
if ($bgColor = Arr::get($atts, 'backgroundColor')) {
|
||||
$tableStyle .= 'background-color:' . sanitize_text_field($bgColor) . ';';
|
||||
}
|
||||
|
||||
if ($contentColor = Arr::get($atts, 'contentColor')) {
|
||||
$safeContentColor = sanitize_text_field($contentColor);
|
||||
$tableStyle .= 'color:' . $safeContentColor . ';';
|
||||
$contentColorStyle = 'color:' . $safeContentColor . ';';
|
||||
}
|
||||
|
||||
$priceStyle = '';
|
||||
if ($pricingColor = Arr::get($atts, 'pricingColor')) {
|
||||
$priceStyle = 'color:' . sanitize_text_field($pricingColor) . ';';
|
||||
}
|
||||
|
||||
$contentHtml = sprintf(
|
||||
'<h2 style="%1$smargin:0;"><a href="%2$s" target="_blank" style="%1$stext-decoration:none;">%3$s</a></h2>',
|
||||
$contentColorStyle,
|
||||
esc_url($product['permalink']),
|
||||
wp_kses_post($product['name'])
|
||||
);
|
||||
|
||||
if ($showDescription && !empty($product['short_description'])) {
|
||||
$contentHtml .= sprintf(
|
||||
'<div style="%1$smargin-bottom:5px;">%2$s</div>',
|
||||
$contentColorStyle,
|
||||
wp_kses_post($product['short_description'])
|
||||
);
|
||||
}
|
||||
|
||||
if ($showPrice) {
|
||||
$contentHtml .= sprintf(
|
||||
'<div style="%1$smargin:5px 0;">%2$s</div>',
|
||||
$priceStyle,
|
||||
ProductListRenderer::preparePriceHtml($product['price_html'], __('Free', 'fluent-crm'))
|
||||
);
|
||||
}
|
||||
|
||||
$image = '';
|
||||
if ($showImage && $template !== 'none') {
|
||||
$customImage = trim((string)Arr::get($atts, 'customImage'));
|
||||
$image = $customImage ? esc_url_raw($customImage) : $product['image'];
|
||||
}
|
||||
|
||||
if (!$image) {
|
||||
$template = 'none';
|
||||
}
|
||||
|
||||
if ($showButton) {
|
||||
$fallbackButton = '<a href="' . esc_url($product['permalink']) . '" style="text-decoration:none;display:inline-block;padding:8px 18px;border-radius:4px;background:#2a363d;color:#ffffff;">' . esc_html(Arr::get($atts, 'buttonText', __('Buy Now', 'fluent-crm'))) . '</a>';
|
||||
$contentHtml .= '<div style="margin-top:10px;margin-bottom:10px;">' . ($buttonHtml ?: $fallbackButton) . '</div>';
|
||||
}
|
||||
|
||||
$contentStyle = '';
|
||||
if ($template === 'none' || $template === 'top') {
|
||||
$contentStyle = 'text-align:center;';
|
||||
}
|
||||
|
||||
$imageTd = '';
|
||||
if ($image) {
|
||||
if ($template === 'top') {
|
||||
$contentHtml = '<img src="' . esc_url($image) . '" width="auto" alt="' . esc_attr(wp_strip_all_tags($product['name'])) . '" style="border:0;height:auto;outline:none;text-decoration:none;max-width:100%;-ms-interpolation-mode:bicubic;display:block;margin:0 auto 15px;">' . $contentHtml;
|
||||
} elseif ($template === 'left') {
|
||||
$imageContent = '<img src="' . esc_url($image) . '" width="auto" alt="' . esc_attr(wp_strip_all_tags($product['name'])) . '" style="border:0;height:auto;outline:none;text-decoration:none;max-width:100%;-ms-interpolation-mode:bicubic;display:block;margin:0 auto;">';
|
||||
$imageTd = self::getContentTd($imageContent, $template);
|
||||
}
|
||||
}
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<table class="fce_row" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout:fixed;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;margin-bottom:20px;margin-top:20px;<?php echo $tableStyle; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"><tbody><tr>
|
||||
<?php
|
||||
if ($imageTd) {
|
||||
echo $imageTd; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
echo self::getContentTd($contentHtml, $template, $contentStyle); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
?>
|
||||
</tr></tbody></table>
|
||||
<?php
|
||||
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
private static function getProductData($productId)
|
||||
{
|
||||
if (isset(self::$productsCache[$productId])) {
|
||||
return self::$productsCache[$productId];
|
||||
}
|
||||
self::$productsCache[$productId] = CartProductData::getProductById($productId, 'large');
|
||||
|
||||
return self::$productsCache[$productId];
|
||||
}
|
||||
|
||||
private static function getContentTd($contentHtml, $template, $extraStyle = '')
|
||||
{
|
||||
$width = ($template === 'left') ? '50' : '100';
|
||||
|
||||
return '<td align="center" valign="middle" width="' . $width . '%" class="fce_column"><table border="0" cellpadding="10" cellspacing="0" width="100%"><tr><td class="fc_column_content" style="padding:10px;' . $extraStyle . '">' . $contentHtml . '</td></tr></table></td>';
|
||||
}
|
||||
|
||||
private static function normalizeTemplate($template)
|
||||
{
|
||||
$template = is_string($template) ? trim($template) : 'left';
|
||||
return in_array($template, ['left', 'top', 'none'], true) ? $template : 'left';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\BlockRender;
|
||||
|
||||
class CartProductData
|
||||
{
|
||||
/**
|
||||
* Query FluentCart products and return normalized product payloads.
|
||||
*
|
||||
* @param array $options
|
||||
* @param string $imageSize
|
||||
* @return array
|
||||
*/
|
||||
public static function getProducts($options = [], $imageSize = 'large')
|
||||
{
|
||||
if (!defined('FLUENTCART_VERSION')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$queryArgs = self::buildQueryArgs($options);
|
||||
$query = new \WP_Query($queryArgs);
|
||||
$posts = $query->posts;
|
||||
wp_reset_postdata();
|
||||
|
||||
if (!$posts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$postIds = wp_list_pluck($posts, 'ID');
|
||||
$detailMap = self::buildDetailMap($postIds);
|
||||
|
||||
$products = [];
|
||||
foreach ($posts as $post) {
|
||||
$products[] = self::mapPostToProduct($post, $detailMap, $imageSize);
|
||||
}
|
||||
|
||||
return $products;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single FluentCart product by ID.
|
||||
*
|
||||
* @param int $productId
|
||||
* @param string $imageSize
|
||||
* @return array|null
|
||||
*/
|
||||
public static function getProductById($productId, $imageSize = 'large')
|
||||
{
|
||||
$productId = absint($productId);
|
||||
if (!$productId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$products = self::getProducts([
|
||||
'productId' => $productId,
|
||||
'perPage' => 1,
|
||||
], $imageSize);
|
||||
|
||||
return isset($products[0]) ? $products[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query args for FluentCart products.
|
||||
*
|
||||
* @param array $options
|
||||
* @return array
|
||||
*/
|
||||
public static function buildQueryArgs($options = [])
|
||||
{
|
||||
$perPage = max(1, min(20, absint(isset($options['perPage']) ? $options['perPage'] : 3)));
|
||||
$order = strtolower((string)(isset($options['order']) ? $options['order'] : 'desc')) === 'asc' ? 'ASC' : 'DESC';
|
||||
$orderByRaw = sanitize_key((string)(isset($options['orderBy']) ? $options['orderBy'] : 'date'));
|
||||
$productId = absint(isset($options['productId']) ? $options['productId'] : 0);
|
||||
$search = sanitize_text_field((string)(isset($options['search']) ? $options['search'] : ''));
|
||||
$taxType = sanitize_text_field((string)(isset($options['taxType']) ? $options['taxType'] : 'all'));
|
||||
|
||||
$orderByMap = [
|
||||
'date' => 'date',
|
||||
'modified' => 'modified',
|
||||
'title' => 'title',
|
||||
'menu_order' => 'menu_order',
|
||||
'rand' => 'rand',
|
||||
];
|
||||
$orderBy = isset($orderByMap[$orderByRaw]) ? $orderByMap[$orderByRaw] : 'date';
|
||||
|
||||
$queryArgs = [
|
||||
'post_type' => 'fluent-products',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => $perPage,
|
||||
'orderby' => $orderBy,
|
||||
'order' => $order,
|
||||
'no_found_rows' => true,
|
||||
];
|
||||
|
||||
if ($productId) {
|
||||
$queryArgs['post__in'] = [$productId];
|
||||
$queryArgs['posts_per_page'] = 1;
|
||||
$queryArgs['orderby'] = 'post__in';
|
||||
} elseif ($search !== '') {
|
||||
$queryArgs['s'] = $search;
|
||||
}
|
||||
|
||||
if ($taxType && $taxType !== 'all' && is_numeric($taxType)) {
|
||||
$queryArgs['tax_query'] = [[
|
||||
'taxonomy' => 'product-categories',
|
||||
'field' => 'term_id',
|
||||
'terms' => [(int)$taxType],
|
||||
]];
|
||||
}
|
||||
|
||||
return $queryArgs;
|
||||
}
|
||||
|
||||
private static function buildDetailMap($postIds)
|
||||
{
|
||||
$detailMap = [];
|
||||
if (!$postIds || !class_exists('\FluentCart\App\Models\ProductDetail')) {
|
||||
return $detailMap;
|
||||
}
|
||||
|
||||
try {
|
||||
$details = \FluentCart\App\Models\ProductDetail::query()
|
||||
->whereIn('post_id', $postIds)
|
||||
->get();
|
||||
|
||||
foreach ($details as $detail) {
|
||||
$detailMap[(int)$detail->post_id] = $detail;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$detailMap = [];
|
||||
}
|
||||
|
||||
return $detailMap;
|
||||
}
|
||||
|
||||
private static function mapPostToProduct($post, $detailMap, $imageSize)
|
||||
{
|
||||
$postId = (int)$post->ID;
|
||||
$detail = isset($detailMap[$postId]) ? $detailMap[$postId] : null;
|
||||
|
||||
$priceHtml = ($detail && isset($detail->formatted_min_price) && $detail->formatted_min_price)
|
||||
? (string)$detail->formatted_min_price
|
||||
: __('Free', 'fluent-crm');
|
||||
$priceHtml = ProductListRenderer::preparePriceHtml($priceHtml, __('Free', 'fluent-crm'));
|
||||
|
||||
$priceText = html_entity_decode(
|
||||
wp_strip_all_tags((string)$priceHtml),
|
||||
ENT_QUOTES,
|
||||
get_bloginfo('charset') ?: 'UTF-8'
|
||||
);
|
||||
$priceText = preg_replace('/\s+/', ' ', trim($priceText));
|
||||
if ($priceText === '') {
|
||||
$priceText = __('Free', 'fluent-crm');
|
||||
}
|
||||
|
||||
$categories = wp_get_post_terms($postId, 'product-categories', ['fields' => 'names']);
|
||||
if (is_wp_error($categories) || !is_array($categories)) {
|
||||
$categories = [];
|
||||
}
|
||||
|
||||
$title = get_the_title($postId);
|
||||
$label = '#' . $postId . ' - ' . $title;
|
||||
if ($priceText) {
|
||||
$label .= ' | ' . $priceText;
|
||||
}
|
||||
if ($categories) {
|
||||
$label .= ' | ' . implode(', ', $categories);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $postId,
|
||||
'name' => $title,
|
||||
'label' => $label,
|
||||
'short_description' => wp_kses_post(get_the_excerpt($postId)),
|
||||
'price_html' => $priceHtml,
|
||||
'price_text' => $priceText,
|
||||
'categories' => $categories,
|
||||
'image' => get_the_post_thumbnail_url($postId, $imageSize) ?: '',
|
||||
'permalink' => get_permalink($postId) ?: '#',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\BlockRender;
|
||||
|
||||
class CartProducts
|
||||
{
|
||||
/**
|
||||
* Render FluentCart products list block for email body.
|
||||
*
|
||||
* @param string $content
|
||||
* @param array $data
|
||||
* @return string
|
||||
*/
|
||||
public static function renderProducts($content, $data)
|
||||
{
|
||||
$customRenderer = apply_filters('fluent_crm/cart_products_renderer', null, $content, $data);
|
||||
if (is_callable($customRenderer)) {
|
||||
try {
|
||||
$rendered = call_user_func($customRenderer, $content, $data);
|
||||
return is_string($rendered) ? ProductListRenderer::postProcessRendered($rendered, $data) : '';
|
||||
} catch (\Throwable $e) {
|
||||
// Fall through to built-in renderer.
|
||||
}
|
||||
}
|
||||
|
||||
if (!defined('FLUENTCART_VERSION')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$attrs = isset($data['attrs']) && is_array($data['attrs']) ? $data['attrs'] : [];
|
||||
$atts = ProductListRenderer::mergeDefaultAttributes($attrs);
|
||||
$products = self::getFluentCartProducts($atts);
|
||||
|
||||
if (!$products) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = ProductListRenderer::renderProducts($products, $atts, 'fc_woo_products fc_cart_products');
|
||||
|
||||
return ProductListRenderer::postProcessRendered($html, $data);
|
||||
}
|
||||
|
||||
private static function getFluentCartProducts($atts)
|
||||
{
|
||||
return CartProductData::getProducts([
|
||||
'perPage' => isset($atts['selectedPostsPerPage']) ? $atts['selectedPostsPerPage'] : 3,
|
||||
'order' => isset($atts['order']) ? $atts['order'] : 'desc',
|
||||
'orderBy' => isset($atts['orderBy']) ? $atts['orderBy'] : 'date',
|
||||
'taxType' => isset($atts['taxType']) ? $atts['taxType'] : 'all',
|
||||
], 'large');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\BlockRender;
|
||||
|
||||
class ProductListRenderer
|
||||
{
|
||||
/**
|
||||
* Default block attributes for list-style product blocks.
|
||||
*
|
||||
* @param array $attrs
|
||||
* @return array
|
||||
*/
|
||||
public static function mergeDefaultAttributes($attrs = [])
|
||||
{
|
||||
if (!is_array($attrs)) {
|
||||
$attrs = [];
|
||||
}
|
||||
|
||||
return wp_parse_args($attrs, [
|
||||
'selectedLayout' => 'default',
|
||||
'selectedPostsPerPage' => '3',
|
||||
'showDescription' => true,
|
||||
'showImage' => true,
|
||||
'showPrice' => true,
|
||||
'showButton' => true,
|
||||
'buttonText' => 'Buy Now',
|
||||
'titleColor' => '#2a363d',
|
||||
'descriptionColor' => '',
|
||||
'priceColor' => '#37454e',
|
||||
'buttonColor' => '',
|
||||
'buttonBG' => '#2a363d',
|
||||
'taxType' => 'all',
|
||||
'order' => 'desc',
|
||||
'orderBy' => 'date',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert normalized product arrays into email-safe HTML.
|
||||
*
|
||||
* @param array $products
|
||||
* @param array $atts
|
||||
* @param string $tableClass
|
||||
* @return string
|
||||
*/
|
||||
public static function renderProducts($products, $atts, $tableClass = 'fc_woo_products')
|
||||
{
|
||||
if (!is_array($products) || !$products) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$selectedLayout = self::normalizeLayout(isset($atts['selectedLayout']) ? $atts['selectedLayout'] : 'default');
|
||||
$productsHtml = '';
|
||||
|
||||
foreach ($products as $product) {
|
||||
$productsHtml .= self::renderProduct($product, $atts, $selectedLayout);
|
||||
}
|
||||
|
||||
if (!$productsHtml) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($selectedLayout === 'layout-2') {
|
||||
return $productsHtml;
|
||||
}
|
||||
|
||||
return '<table class="' . esc_attr($tableClass) . '" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td class="template-' . esc_attr($selectedLayout) . '">' . $productsHtml . '</td></tr></tbody></table>';
|
||||
}
|
||||
|
||||
public static function postProcessRendered($html, $data)
|
||||
{
|
||||
if (!is_string($html) || $html === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$attrs = isset($data['attrs']) && is_array($data['attrs']) ? $data['attrs'] : [];
|
||||
if (!self::isShowImageEnabled($attrs)) {
|
||||
$html = self::removeProductImagePlaceholders($html);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private static function renderProduct($product, $atts, $layout)
|
||||
{
|
||||
$showImage = RenderValueHelper::normalizeBool(isset($atts['showImage']) ? $atts['showImage'] : true, true);
|
||||
$showDescription = RenderValueHelper::normalizeBool(isset($atts['showDescription']) ? $atts['showDescription'] : true, true);
|
||||
$showPrice = RenderValueHelper::normalizeBool(isset($atts['showPrice']) ? $atts['showPrice'] : true, true);
|
||||
$showButton = RenderValueHelper::normalizeBool(isset($atts['showButton']) ? $atts['showButton'] : true, true);
|
||||
|
||||
$titleColor = trim((string)(isset($atts['titleColor']) ? $atts['titleColor'] : ''));
|
||||
$descriptionColor = trim((string)(isset($atts['descriptionColor']) ? $atts['descriptionColor'] : ''));
|
||||
$priceColor = trim((string)(isset($atts['priceColor']) ? $atts['priceColor'] : ''));
|
||||
$buttonColor = trim((string)(isset($atts['buttonColor']) ? $atts['buttonColor'] : ''));
|
||||
$buttonBg = trim((string)(isset($atts['buttonBG']) ? $atts['buttonBG'] : ''));
|
||||
|
||||
$title = wp_kses_post(isset($product['name']) ? $product['name'] : '');
|
||||
$description = wp_kses_post(isset($product['short_description']) ? $product['short_description'] : '');
|
||||
$priceHtml = self::preparePriceHtml(
|
||||
isset($product['price_html']) ? $product['price_html'] : '',
|
||||
__('Free', 'fluent-crm')
|
||||
);
|
||||
$image = esc_url(isset($product['image']) ? $product['image'] : '');
|
||||
$permalink = esc_url(isset($product['permalink']) ? $product['permalink'] : '#');
|
||||
$buttonText = esc_html(isset($atts['buttonText']) ? $atts['buttonText'] : __('Buy Now', 'fluent-crm'));
|
||||
|
||||
$titleStyle = $titleColor ? 'color:' . esc_attr($titleColor) . ';' : '';
|
||||
$descriptionStyle = $descriptionColor ? 'color:' . esc_attr($descriptionColor) . ';' : '';
|
||||
$priceStyle = $priceColor ? 'color:' . esc_attr($priceColor) . ';' : '';
|
||||
$buttonStyle = $buttonColor ? 'color:' . esc_attr($buttonColor) . ';' : '';
|
||||
|
||||
if ($layout === 'layout-2' && $buttonBg) {
|
||||
$buttonStyle .= 'background:' . esc_attr($buttonBg) . ';';
|
||||
}
|
||||
|
||||
if ($layout === 'layout-2') {
|
||||
return self::renderLayoutTwoProduct(
|
||||
$title,
|
||||
$description,
|
||||
$priceHtml,
|
||||
$image,
|
||||
$permalink,
|
||||
$buttonText,
|
||||
$titleStyle,
|
||||
$descriptionStyle,
|
||||
$priceStyle,
|
||||
$buttonStyle,
|
||||
$showImage,
|
||||
$showDescription,
|
||||
$showPrice,
|
||||
$showButton
|
||||
);
|
||||
}
|
||||
|
||||
$contentHtml = '<div class="fc_woo_product_info"><div>';
|
||||
$contentHtml .= '<h2 class="title"><a style="' . esc_attr($titleStyle) . '" target="_blank" href="' . $permalink . '">' . $title . '</a></h2>';
|
||||
|
||||
if ($showPrice) {
|
||||
$contentHtml .= '<span class="price" style="' . esc_attr($priceStyle) . '">' . $priceHtml . '</span>';
|
||||
}
|
||||
|
||||
$contentHtml .= '</div>';
|
||||
|
||||
if ($layout !== 'layout-3' && $showButton) {
|
||||
$contentHtml .= '<a href="' . $permalink . '" target="_blank" style="' . esc_attr($buttonStyle) . '" class="add-to-cart-btn">' . $buttonText . '</a>';
|
||||
}
|
||||
|
||||
$contentHtml .= '</div>';
|
||||
|
||||
$imageHtml = '';
|
||||
if ($showImage && $image) {
|
||||
$imageHtml = '<div class="fc_woo_product_img"><img src="' . $image . '" alt="' . esc_attr(wp_strip_all_tags($title)) . '"></div>';
|
||||
}
|
||||
|
||||
return '<table class="fce_row fc_woo_product ' . esc_attr($layout) . '" border="0" cellpadding="0" cellspacing="0" width="40%" style="margin-bottom:35px;"><tbody><tr><td width="100%">' . $imageHtml . $contentHtml . '</td></tr></tbody></table>';
|
||||
}
|
||||
|
||||
private static function renderLayoutTwoProduct($title, $description, $priceHtml, $image, $permalink, $buttonText, $titleStyle, $descriptionStyle, $priceStyle, $buttonStyle, $showImage, $showDescription, $showPrice, $showButton)
|
||||
{
|
||||
$hasImage = $showImage && $image;
|
||||
$imageCell = '';
|
||||
if ($hasImage) {
|
||||
$imageCell = '<td width="45%" class="fc_cart_product_img" style="background:url(\'' . $image . '\') center no-repeat;background-size:cover;"></td>';
|
||||
}
|
||||
|
||||
$contentWidth = $hasImage ? '55%' : '100%';
|
||||
$contentPadding = $hasImage ? 'padding:10px 10px 10px 20px;' : 'padding:10px;';
|
||||
|
||||
$contentHtml = '<div class="fc_woo_product_info"><div>';
|
||||
$contentHtml .= '<h2 class="title"><a style="' . esc_attr($titleStyle) . '" target="_blank" href="' . $permalink . '">' . $title . '</a></h2>';
|
||||
|
||||
if ($showDescription) {
|
||||
$contentHtml .= '<div class="description" style="' . esc_attr($descriptionStyle) . '">' . $description . '</div>';
|
||||
}
|
||||
|
||||
if ($showPrice) {
|
||||
$contentHtml .= '<span class="price" style="' . esc_attr($priceStyle) . '">' . $priceHtml . '</span>';
|
||||
}
|
||||
|
||||
$contentHtml .= '</div>';
|
||||
|
||||
if ($showButton) {
|
||||
$contentHtml .= '<a href="' . $permalink . '" target="_blank" style="' . esc_attr($buttonStyle) . '" class="add-to-cart-btn">' . $buttonText . '</a>';
|
||||
}
|
||||
|
||||
$contentHtml .= '</div>';
|
||||
|
||||
return '<table class="fce_row fc_woo_product layout-2" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom:35px;"><tbody><tr>' . $imageCell . '<td width="' . esc_attr($contentWidth) . '" style="' . esc_attr($contentPadding) . '">' . $contentHtml . '</td></tr></tbody></table>';
|
||||
}
|
||||
|
||||
private static function normalizeLayout($layout)
|
||||
{
|
||||
$layout = is_string($layout) ? trim($layout) : 'default';
|
||||
|
||||
return in_array($layout, ['default', 'layout-2', 'layout-3'], true) ? $layout : 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare product price markup for email/block-editor visual output.
|
||||
*
|
||||
* WooCommerce sale price HTML includes screen-reader helper spans. Remove
|
||||
* those snippet-visible labels while keeping visible sale prices readable.
|
||||
*
|
||||
* @param string $priceHtml Raw product price HTML.
|
||||
* @param string $fallback Fallback text when no price is available.
|
||||
* @return string
|
||||
*/
|
||||
public static function preparePriceHtml($priceHtml, $fallback = '')
|
||||
{
|
||||
$priceHtml = wp_kses_post((string)$priceHtml);
|
||||
|
||||
if ($priceHtml === '') {
|
||||
return esc_html($fallback);
|
||||
}
|
||||
|
||||
$priceHtml = self::removeScreenReaderText($priceHtml);
|
||||
$priceHtml = self::removeAttributeFromTag($priceHtml, 'del', 'aria-hidden');
|
||||
$priceHtml = self::removeAttributeFromTag($priceHtml, 'ins', 'aria-hidden');
|
||||
$priceHtml = self::addInlineStyleToTag(
|
||||
$priceHtml,
|
||||
'del',
|
||||
'color:#9aa3aa;text-decoration:line-through;margin-right:8px;'
|
||||
);
|
||||
$priceHtml = self::addInlineStyleToTag(
|
||||
$priceHtml,
|
||||
'ins',
|
||||
'text-decoration:none;font-weight:600;'
|
||||
);
|
||||
|
||||
return trim(wp_kses_post($priceHtml));
|
||||
}
|
||||
|
||||
private static function removeScreenReaderText($html)
|
||||
{
|
||||
return preg_replace('/<span\b(?=[^>]*\bclass=(["\'])[^"\']*\bscreen-reader-text\b[^"\']*\1)[^>]*>.*?<\/span>/is', '', $html);
|
||||
}
|
||||
|
||||
private static function addInlineStyleToTag($html, $tag, $style)
|
||||
{
|
||||
return preg_replace_callback('/<' . preg_quote($tag, '/') . '\b([^>]*)>/i', function ($matches) use ($tag, $style) {
|
||||
$attributes = $matches[1];
|
||||
|
||||
if (preg_match('/\sstyle=(["\'])(.*?)\1/i', $attributes)) {
|
||||
$attributes = preg_replace_callback('/\sstyle=(["\'])(.*?)\1/i', function ($styleMatches) use ($style) {
|
||||
if (strpos($styleMatches[2], rtrim($style, ';')) !== false) {
|
||||
return $styleMatches[0];
|
||||
}
|
||||
|
||||
return ' style=' . $styleMatches[1] . rtrim($styleMatches[2], ';') . ';' . $style . $styleMatches[1];
|
||||
}, $attributes, 1);
|
||||
|
||||
return '<' . $tag . $attributes . '>';
|
||||
}
|
||||
|
||||
return '<' . $tag . ' style="' . $style . '"' . $attributes . '>';
|
||||
}, $html);
|
||||
}
|
||||
|
||||
private static function removeAttributeFromTag($html, $tag, $attribute)
|
||||
{
|
||||
return preg_replace_callback('/<' . preg_quote($tag, '/') . '\b([^>]*)>/i', function ($matches) use ($tag, $attribute) {
|
||||
$attributes = preg_replace('/\s' . preg_quote($attribute, '/') . '\s*=\s*(["\']).*?\1/i', '', $matches[1]);
|
||||
$attributes = preg_replace('/\s' . preg_quote($attribute, '/') . '\s*=\s*[^\s>]*/i', '', $attributes);
|
||||
|
||||
return '<' . $tag . $attributes . '>';
|
||||
}, $html);
|
||||
}
|
||||
|
||||
private static function removeProductImagePlaceholders($html)
|
||||
{
|
||||
// Fast path first; also helps as fallback when DOM extension is unavailable.
|
||||
$html = preg_replace('/<img\b[^>]*>/i', '', $html);
|
||||
$html = preg_replace('/<figure\b[^>]*>\s*<\/figure>/i', '', $html);
|
||||
$html = self::stripImageColumnsWithRegex($html);
|
||||
|
||||
if (!class_exists('\DOMDocument') || trim($html) === '') {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$internalErrors = libxml_use_internal_errors(true);
|
||||
$dom = new \DOMDocument('1.0', 'UTF-8');
|
||||
$loaded = $dom->loadHTML(
|
||||
'<?xml encoding="utf-8" ?><div id="fc_products_root">' . $html . '</div>',
|
||||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
|
||||
);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($internalErrors);
|
||||
|
||||
if (!$loaded) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$xpath = new \DOMXPath($dom);
|
||||
|
||||
$imageClassQueries = [
|
||||
"//*[contains(concat(' ', normalize-space(@class), ' '), ' fc_woo_product_img ')]",
|
||||
"//*[contains(concat(' ', normalize-space(@class), ' '), ' fc_cart_product_img ')]"
|
||||
];
|
||||
|
||||
foreach ($imageClassQueries as $query) {
|
||||
$nodes = $xpath->query($query);
|
||||
if (!$nodes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for ($i = $nodes->length - 1; $i >= 0; $i--) {
|
||||
self::removeNode($nodes->item($i));
|
||||
}
|
||||
}
|
||||
|
||||
$imageCells = $xpath->query("//td[contains(translate(@style, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'url(')]");
|
||||
if ($imageCells) {
|
||||
for ($i = $imageCells->length - 1; $i >= 0; $i--) {
|
||||
$cell = $imageCells->item($i);
|
||||
if (self::isImageOnlyCell($cell)) {
|
||||
self::removeNode($cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $xpath->query('//tr[count(td)=1]');
|
||||
if ($rows) {
|
||||
for ($i = 0; $i < $rows->length; $i++) {
|
||||
$row = $rows->item($i);
|
||||
$cells = $row->getElementsByTagName('td');
|
||||
if (!$cells || !$cells->item(0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cell = $cells->item(0);
|
||||
$cell->setAttribute('width', '100%');
|
||||
|
||||
$style = self::normalizeCellStyle($cell->getAttribute('style'));
|
||||
if ($style) {
|
||||
$cell->setAttribute('style', $style);
|
||||
} else {
|
||||
$cell->removeAttribute('style');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$root = $dom->getElementById('fc_products_root');
|
||||
if (!$root) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$output = '';
|
||||
foreach ($root->childNodes as $child) {
|
||||
$output .= $dom->saveHTML($child);
|
||||
}
|
||||
|
||||
return self::stripImageColumnsWithRegex($output ?: $html);
|
||||
}
|
||||
|
||||
private static function stripImageColumnsWithRegex($html)
|
||||
{
|
||||
$html = preg_replace(
|
||||
'/<div\b[^>]*class=(["\'])[^"\']*(?:fc_woo_product_img|fc_cart_product_img)[^"\']*\1[^>]*>.*?<\/div>/is',
|
||||
'',
|
||||
$html
|
||||
);
|
||||
|
||||
$html = preg_replace(
|
||||
'/<td\b[^>]*style=(["\'])[^"\']*url\([^)]+\)[^"\']*\1[^>]*>\s*<\/td>/is',
|
||||
'',
|
||||
$html
|
||||
);
|
||||
|
||||
$html = preg_replace('/\bwidth=(["\'])55%\1/i', 'width="100%"', $html);
|
||||
$html = preg_replace('/\bpadding-left\s*:\s*[^;]+;?/i', '', $html);
|
||||
$html = preg_replace('/\s{2,}/', ' ', $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private static function normalizeCellStyle($style)
|
||||
{
|
||||
if (!$style) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$style = preg_replace('/\bbackground(?:-image)?\s*:\s*[^;]+;?/i', '', $style);
|
||||
$style = preg_replace('/\bbackground-size\s*:\s*[^;]+;?/i', '', $style);
|
||||
$style = preg_replace('/\bbackground-position\s*:\s*[^;]+;?/i', '', $style);
|
||||
$style = preg_replace('/\bbackground-repeat\s*:\s*[^;]+;?/i', '', $style);
|
||||
$style = preg_replace('/\bpadding-left\s*:\s*[^;]+;?/i', '', $style);
|
||||
$style = preg_replace('/\bwidth\s*:\s*(?:40%|45%|50%);?/i', '', $style);
|
||||
|
||||
$style = preg_replace_callback(
|
||||
'/\bpadding\s*:\s*([0-9.]+[a-z%]+)\s+([0-9.]+[a-z%]+)\s+([0-9.]+[a-z%]+)\s+([0-9.]+[a-z%]+)\s*;?/i',
|
||||
function ($matches) {
|
||||
return 'padding:' . $matches[1] . ' ' . $matches[2] . ' ' . $matches[3] . ';';
|
||||
},
|
||||
$style
|
||||
);
|
||||
|
||||
$style = preg_replace('/;\s*;/', ';', $style);
|
||||
|
||||
return trim($style, " \t\n\r\0\x0B;");
|
||||
}
|
||||
|
||||
private static function removeNode($node)
|
||||
{
|
||||
if ($node && $node->parentNode) {
|
||||
$node->parentNode->removeChild($node);
|
||||
}
|
||||
}
|
||||
|
||||
private static function isImageOnlyCell($cell)
|
||||
{
|
||||
if (!$cell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$text = trim(str_replace("\xc2\xa0", '', (string)$cell->textContent));
|
||||
if ($text !== '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($cell->childNodes as $child) {
|
||||
if ($child->nodeType !== XML_ELEMENT_NODE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = strtolower($child->nodeName);
|
||||
if (!in_array($tag, ['img', 'figure', 'picture', 'source', 'br'], true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function isShowImageEnabled($attrs)
|
||||
{
|
||||
if (!array_key_exists('showImage', $attrs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return RenderValueHelper::normalizeBool($attrs['showImage'], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\BlockRender;
|
||||
|
||||
class RenderValueHelper
|
||||
{
|
||||
/**
|
||||
* Normalize mixed values coming from block attrs into bool.
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param bool $default
|
||||
* @return bool
|
||||
*/
|
||||
public static function normalizeBool($value, $default = true)
|
||||
{
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return ((int)$value) !== 0;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$normalized = strtolower(trim($value));
|
||||
return !in_array($normalized, ['0', 'false', 'no', 'off'], true);
|
||||
}
|
||||
|
||||
return (bool)$value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\BlockRender;
|
||||
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class WooProduct
|
||||
{
|
||||
private static $productsCache = [];
|
||||
|
||||
public static function renderProduct($buttonHtml, $data)
|
||||
{
|
||||
if(!defined('WC_PLUGIN_FILE')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$defaultAtts = [
|
||||
'productId' => null,
|
||||
'showDescription' => true,
|
||||
'showPrice' => true,
|
||||
'customImage' => '',
|
||||
'backgroundColor' => '#fffeeb',
|
||||
'contentColor' => '',
|
||||
'pricingColor' => '',
|
||||
'template' => 'left'
|
||||
];
|
||||
$atts = wp_parse_args($data['attrs'], $defaultAtts);
|
||||
$productId = (int) $atts['productId'];
|
||||
|
||||
if (!$productId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if(isset(self::$productsCache[$productId])) {
|
||||
$product = self::$productsCache[$productId];
|
||||
} else {
|
||||
self::$productsCache[$productId] = wc_get_product($productId);
|
||||
$product = self::$productsCache[$productId];
|
||||
}
|
||||
|
||||
if (!$product) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$tableStyle = 'border-radius: 5px;';
|
||||
|
||||
if ($bgColor = Arr::get($atts, 'backgroundColor')) {
|
||||
$tableStyle .= 'background-color: ' . $bgColor . ';';
|
||||
}
|
||||
|
||||
$contentColorStyle = '';
|
||||
if ($color = Arr::get($atts, 'contentColor')) {
|
||||
$tableStyle .= 'color: ' . $color . ';';
|
||||
$contentColorStyle = 'color: ' . $color . ';';
|
||||
}
|
||||
|
||||
$pricingStyle = 'display:block;margin:8px 0 10px;font-size:18px;line-height:1.25;';
|
||||
if ($color = Arr::get($atts, 'pricingColor')) {
|
||||
$pricingStyle .= 'color: ' . $color . ';';
|
||||
}
|
||||
|
||||
$contentHtml = sprintf(
|
||||
'<h2 style="%1$s">%2$s</h2>',
|
||||
$contentColorStyle.'margin: 0;',
|
||||
wp_kses_post($product->get_title())
|
||||
);
|
||||
|
||||
if (Arr::get($atts, 'showDescription')) {
|
||||
$contentHtml .= sprintf(
|
||||
'<div style="%1$s">%2$s</div>',
|
||||
$contentColorStyle.'margin-bottom: 5px;',
|
||||
wc_format_content(wp_kses_post($product->get_short_description() ? $product->get_short_description() : wc_trim_string($product->get_description(), 400)))
|
||||
);
|
||||
}
|
||||
|
||||
if (Arr::get($atts, 'showPrice')) {
|
||||
$contentHtml .= sprintf(
|
||||
'<div class="fcw_p_price" style="%1$s">%2$s</div>',
|
||||
$pricingStyle,
|
||||
ProductListRenderer::preparePriceHtml($product->get_price_html(), __('Free', 'fluent-crm'))
|
||||
);
|
||||
}
|
||||
|
||||
$image = '';
|
||||
|
||||
$template = Arr::get($atts, 'template', 'left');
|
||||
|
||||
if($template != 'none') {
|
||||
$image = self::getImage($product);
|
||||
}
|
||||
if (!$image) {
|
||||
$template = 'none';
|
||||
}
|
||||
|
||||
$atts['template'] = $template;
|
||||
|
||||
$contentStyle = '';
|
||||
if ($template == 'none' || $template == 'top') {
|
||||
$contentStyle = 'text-align: center;';
|
||||
}
|
||||
|
||||
$imageTd = '';
|
||||
if($image) {
|
||||
if($template == 'top') {
|
||||
$contentHtml = '<img src="'.$image.'" width="auto" alt="'.$product->get_title().'" style="border: 0; height: auto; outline: none; text-decoration: none; max-width: 100%; -ms-interpolation-mode: bicubic; display: block;margin: 0 auto 15px;">'.$contentHtml;
|
||||
} else if($template == 'left') {
|
||||
$imageContent = '<img src="'.$image.'" width="auto" alt="'.$product->get_title().'" style="border: 0; height: auto; outline: none; text-decoration: none; max-width: 100%; -ms-interpolation-mode: bicubic; display: block;margin: 0 auto;">';
|
||||
$imageTd = self::getContentTd($imageContent, $template);
|
||||
}
|
||||
}
|
||||
|
||||
$buttonHtml = '<div style="margin-top: 10px; margin-bottom: 10px;">'.$buttonHtml.'</div>';
|
||||
|
||||
$contentHtml .= $buttonHtml;
|
||||
$tableClass = 'fce_row';
|
||||
if ($template === 'left' || $template === 'right') {
|
||||
$tableClass .= ' fc_woo_product_stack_mobile';
|
||||
}
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<table class="<?php echo esc_attr($tableClass); ?>" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed; border-collapse: collapse;mso-table-lspace: 0pt;mso-table-rspace: 0pt;-ms-text-size-adjust: 100%;-webkit-text-size-adjust: 100%;margin-bottom: 20px; margin-top: 20px;<?php echo $tableStyle; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"><tbody><tr>
|
||||
<?php
|
||||
if($imageTd) {
|
||||
echo $imageTd; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
echo self::getContentTd($contentHtml, $template, $contentStyle); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
?>
|
||||
</tr></tbody></table>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
private static function getContentTd($contentHtml, $template, $extraStyle = '')
|
||||
{
|
||||
if(!defined('WC_PLUGIN_FILE')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$width = '100';
|
||||
if($template == 'left') {
|
||||
$width = '50';
|
||||
}
|
||||
return '<td align="center" valign="middle" width="' . $width . '%" class="fce_column"><table border="0" cellpadding="10" cellspacing="0" width="100%"><tr><td class="fc_column_content" style="padding: 10px;'. $extraStyle.'">'.$contentHtml.'</td></tr></table></td>'; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the main product image URL.
|
||||
*
|
||||
* @param \WC_Product $product Product object.
|
||||
* @param string $size Image size, defaults to 'full'.
|
||||
* @return string
|
||||
*/
|
||||
public static function getImage($product, $size = 'full')
|
||||
{
|
||||
if(!defined('WC_PLUGIN_FILE')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
static $imageCache = [];
|
||||
|
||||
if(isset($imageCache[$product->get_id()])) {
|
||||
return $imageCache[$product->get_id()];
|
||||
}
|
||||
|
||||
$image = '';
|
||||
if ($product->get_image_id()) {
|
||||
$image = wp_get_attachment_image_url($product->get_image_id(), $size);
|
||||
} elseif ($product->get_parent_id()) {
|
||||
$parent_product = wc_get_product($product->get_parent_id());
|
||||
if ($parent_product) {
|
||||
$image = wp_get_attachment_image_url($parent_product->get_image_id(), $size);
|
||||
}
|
||||
}
|
||||
|
||||
$imageCache[$product->get_id()] = $image;
|
||||
|
||||
return $imageCache[$product->get_id()] ;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\BlockRender;
|
||||
|
||||
class WooProducts
|
||||
{
|
||||
/**
|
||||
* Render WooCommerce products list block for email body.
|
||||
*
|
||||
* @param string $content
|
||||
* @param array $data
|
||||
* @return string
|
||||
*/
|
||||
public static function renderProducts($content, $data)
|
||||
{
|
||||
$customRenderer = apply_filters('fluent_crm/products_renderer', null, $content, $data);
|
||||
if (is_callable($customRenderer)) {
|
||||
try {
|
||||
$rendered = call_user_func($customRenderer, $content, $data);
|
||||
return is_string($rendered) ? ProductListRenderer::postProcessRendered($rendered, $data) : '';
|
||||
} catch (\Throwable $e) {
|
||||
// Fall through to built-in renderer.
|
||||
}
|
||||
}
|
||||
|
||||
if (!defined('WC_PLUGIN_FILE') || !function_exists('wc_get_products')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$attrs = isset($data['attrs']) && is_array($data['attrs']) ? $data['attrs'] : [];
|
||||
$atts = ProductListRenderer::mergeDefaultAttributes($attrs);
|
||||
$products = self::getWooProducts($atts);
|
||||
|
||||
if (!$products) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = ProductListRenderer::renderProducts($products, $atts, 'fc_woo_products');
|
||||
|
||||
return ProductListRenderer::postProcessRendered($html, $data);
|
||||
}
|
||||
|
||||
private static function getWooProducts($atts)
|
||||
{
|
||||
if (!defined('WC_PLUGIN_FILE') || !function_exists('wc_get_products')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$perPage = max(1, min(20, absint(isset($atts['selectedPostsPerPage']) ? $atts['selectedPostsPerPage'] : 3)));
|
||||
$order = strtolower((string)(isset($atts['order']) ? $atts['order'] : 'desc')) === 'asc' ? 'ASC' : 'DESC';
|
||||
$orderByRaw = sanitize_key((string)(isset($atts['orderBy']) ? $atts['orderBy'] : 'date'));
|
||||
|
||||
$orderByMap = [
|
||||
'date' => 'date',
|
||||
'modified' => 'modified',
|
||||
'title' => 'title',
|
||||
'menu_order' => 'menu_order',
|
||||
'rand' => 'rand',
|
||||
];
|
||||
$orderBy = isset($orderByMap[$orderByRaw]) ? $orderByMap[$orderByRaw] : 'date';
|
||||
|
||||
$queryArgs = [
|
||||
'status' => 'publish',
|
||||
'limit' => $perPage,
|
||||
'orderby' => $orderBy,
|
||||
'order' => $order,
|
||||
'return' => 'objects',
|
||||
];
|
||||
|
||||
$taxType = sanitize_text_field((string)(isset($atts['taxType']) ? $atts['taxType'] : 'all'));
|
||||
if ($taxType && $taxType !== 'all' && is_numeric($taxType)) {
|
||||
$term = get_term((int)$taxType, 'product_cat');
|
||||
if ($term && !is_wp_error($term) && !empty($term->slug)) {
|
||||
$queryArgs['category'] = [$term->slug];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$wooProducts = wc_get_products($queryArgs);
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!$wooProducts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$products = [];
|
||||
foreach ($wooProducts as $product) {
|
||||
if (!$product || !method_exists($product, 'get_id')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$description = $product->get_short_description();
|
||||
if (!$description && method_exists($product, 'get_description')) {
|
||||
$description = wc_trim_string($product->get_description(), 400);
|
||||
}
|
||||
|
||||
$products[] = [
|
||||
'id' => $product->get_id(),
|
||||
'name' => $product->get_name(),
|
||||
'short_description' => wp_kses_post($description),
|
||||
'price_html' => ProductListRenderer::preparePriceHtml($product->get_price_html(), __('Free', 'fluent-crm')),
|
||||
'image' => WooProduct::getImage($product),
|
||||
'permalink' => $product->get_permalink() ?: '#',
|
||||
];
|
||||
}
|
||||
|
||||
return $products;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services;
|
||||
|
||||
use FluentCrm\App\Models\Campaign;
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
|
||||
class CampaignProcessor
|
||||
{
|
||||
protected $campaignId = false;
|
||||
|
||||
protected $initialStatus = 'scheduling';
|
||||
|
||||
public function __construct($campaignId)
|
||||
{
|
||||
$this->campaignId = $campaignId;
|
||||
|
||||
// Always create rows as 'scheduling' — they become 'scheduled' (sendable)
|
||||
// only after ALL rows are created. This ensures clean phase separation:
|
||||
// processing completes fully, then sending begins with accurate totals.
|
||||
}
|
||||
|
||||
/*
|
||||
* By Default, this function will process emails in chunks of 30 (customizable) and run for max 30 seconds per processing cycle
|
||||
* @param int $perChunk
|
||||
* @param int $runTime
|
||||
* @return Campaign|false
|
||||
*/
|
||||
public function processEmails($perChunk = 0, $runTime = 30)
|
||||
{
|
||||
if ($runTime > 30) {
|
||||
$runTime = fluentCrmMaxRunTime() - 5;
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
$campaign = Campaign::withoutGlobalScope('type')->find($this->campaignId);
|
||||
|
||||
if (!$campaign) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($campaign->status != 'processing') {
|
||||
return $campaign;
|
||||
}
|
||||
|
||||
if (fluentCrmIsMemoryExceeded()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$perChunk || $perChunk <= 0) {
|
||||
/**
|
||||
* Filter the number of subscribers processed per request while processing Campaign Emails.
|
||||
*
|
||||
* This filter allows you to modify the number of subscribers that are processed
|
||||
* in each request when processing campaigns.
|
||||
*
|
||||
* @since 2.7.0
|
||||
*
|
||||
* @param int The number of subscribers to process per request. Default is 30.
|
||||
*/
|
||||
$perChunk = (int)apply_filters('fluent_crm/process_subscribers_per_request', 30);
|
||||
}
|
||||
|
||||
if (!$this->acquireProcessingLock()) {
|
||||
// Return false (not $campaign) so the caller doesn't fire
|
||||
// another AJAX chain while the lock is held by another process.
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscribersModel = $this->getSubscribersChunk($campaign, $perChunk);
|
||||
if (!$subscribersModel) {
|
||||
$this->releaseProcessingLock();
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $this->subscribe($campaign, $subscribersModel);
|
||||
|
||||
$willRun = !!$result;
|
||||
|
||||
while ($willRun && ((microtime(true) - $startTime) < $runTime) && !fluentCrmIsMemoryExceeded()) {
|
||||
usleep(10000); // 10 milliseconds sleep
|
||||
$campaign = Campaign::withoutGlobalScope('type')->find($campaign->id);
|
||||
$willRun = !!$result;
|
||||
|
||||
if ($willRun) {
|
||||
$this->refreshProcessingLock();
|
||||
$subscribersModel = $this->getSubscribersChunk($campaign, $perChunk);
|
||||
if (!$subscribersModel) {
|
||||
$this->releaseProcessingLock();
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $this->subscribe($campaign, $subscribersModel);
|
||||
}
|
||||
}
|
||||
|
||||
$this->releaseProcessingLock();
|
||||
|
||||
if (!$result) { // All Done. Let's make it scheduled
|
||||
$campaign = Campaign::withoutGlobalScope('type')->find($this->campaignId);
|
||||
|
||||
if ($campaign->status == 'processing') {
|
||||
$campaign->status = 'scheduled';
|
||||
$campaign->save();
|
||||
fluentcrm_update_campaign_meta($campaign->id, '_last_recipient_id', 0);
|
||||
}
|
||||
|
||||
CampaignEmail::where('campaign_id', $campaign->id)
|
||||
->where('status', 'scheduling')
|
||||
->update([
|
||||
'status' => 'scheduled'
|
||||
]);
|
||||
|
||||
$campaign->maybeDeleteDuplicates();
|
||||
}
|
||||
|
||||
return $campaign;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next stable subscriber chunk for campaign email materialization.
|
||||
*/
|
||||
private function getSubscribersChunk($campaign, $perChunk)
|
||||
{
|
||||
$subscribersModel = $campaign->getSubscribersModel($campaign->settings);
|
||||
if (!$subscribersModel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lastRecipientId = absint(fluentcrm_get_campaign_meta($campaign->id, '_last_recipient_id', true));
|
||||
|
||||
return $subscribersModel
|
||||
->where('fc_subscribers.id', '>', $lastRecipientId)
|
||||
->reorder('fc_subscribers.id', 'ASC')
|
||||
->limit($perChunk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize a subscriber chunk and advance the cursor after rows are created.
|
||||
*/
|
||||
private function subscribe($campaign, $subscribersModel)
|
||||
{
|
||||
$subscribers = $subscribersModel->get();
|
||||
if ($subscribers->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = $campaign->subscribe($subscribers, [
|
||||
'status' => $this->initialStatus,
|
||||
'scheduled_at' => $campaign->getEmailScheduleAt(),
|
||||
], true);
|
||||
|
||||
fluentcrm_update_campaign_meta($campaign->id, '_last_recipient_id', $subscribers->max('id'));
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically acquire the processing lock for this campaign.
|
||||
*
|
||||
* Uses fc_meta table with the '_processing_emails' key. The lock row
|
||||
* is created on first acquire and reused. A single UPDATE with a WHERE
|
||||
* clause prevents TOCTOU race conditions.
|
||||
*
|
||||
* @return bool True if lock acquired, false if another process holds it.
|
||||
*/
|
||||
private function acquireProcessingLock()
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'fc_meta';
|
||||
$now = (string)time();
|
||||
$lockTimeout = 90;
|
||||
$key = '_processing_emails';
|
||||
$objectType = 'FluentCrm\App\Models\Campaign';
|
||||
|
||||
// Try atomic UPDATE on existing row: claim if free (0/empty) or expired
|
||||
$affected = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET `value` = %s WHERE `object_id` = %d AND `object_type` = %s AND `key` = %s AND (`value` = '' OR `value` = '0' OR `value` < %d)",
|
||||
$now, $this->campaignId, $objectType, $key, time() - $lockTimeout
|
||||
));
|
||||
|
||||
if ($affected > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Row might not exist yet — create it then retry the atomic UPDATE
|
||||
$exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE `object_id` = %d AND `object_type` = %s AND `key` = %s",
|
||||
$this->campaignId, $objectType, $key
|
||||
));
|
||||
|
||||
if (!$exists) {
|
||||
// Create the row with value '0' (unlocked), then retry atomic claim
|
||||
fluentcrm_update_campaign_meta($this->campaignId, $key, '0');
|
||||
|
||||
$affected = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET `value` = %s WHERE `object_id` = %d AND `object_type` = %s AND `key` = %s AND (`value` = '' OR `value` = '0' OR `value` < %d)",
|
||||
$now, $this->campaignId, $objectType, $key, time() - $lockTimeout
|
||||
));
|
||||
|
||||
return $affected > 0;
|
||||
}
|
||||
|
||||
// Row exists but lock is held by another process
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the lock timestamp to prevent stale-lock detection.
|
||||
*/
|
||||
private function refreshProcessingLock()
|
||||
{
|
||||
fluentcrm_update_campaign_meta($this->campaignId, '_processing_emails', (string)time());
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the processing lock.
|
||||
*/
|
||||
private function releaseProcessingLock()
|
||||
{
|
||||
fluentcrm_update_campaign_meta($this->campaignId, '_processing_emails', 0);
|
||||
}
|
||||
|
||||
public function getSchedulingMethod()
|
||||
{
|
||||
return $this->initialStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services;
|
||||
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class ContactsQuery
|
||||
{
|
||||
private $args = [];
|
||||
|
||||
private $model = null;
|
||||
|
||||
public function __construct($args = [])
|
||||
{
|
||||
$this->args = wp_parse_args($args, [
|
||||
'with' => ['tags', 'lists'],
|
||||
'filter_type' => 'simple',
|
||||
'filters_groups' => [],
|
||||
'filters_groups_raw' => [],
|
||||
'search' => '',
|
||||
'sort_by' => 'id',
|
||||
'sort_type' => 'DESC',
|
||||
'tags' => [],
|
||||
'lists' => [],
|
||||
'statuses' => [],
|
||||
'sms_statuses' => [],
|
||||
'has_commerce' => false,
|
||||
'custom_fields' => false,
|
||||
'limit' => false,
|
||||
'offset' => false,
|
||||
'contact_status' => '',
|
||||
'company_ids' => [],
|
||||
'contact_ids' => []
|
||||
]);
|
||||
|
||||
$this->setupQuery();
|
||||
}
|
||||
|
||||
private function setupQuery()
|
||||
{
|
||||
if ($this->args['filters_groups_raw']) {
|
||||
$this->formatAdvancedFilters();
|
||||
}
|
||||
|
||||
$subscribersQuery = Subscriber::with($this->args['with']);
|
||||
if ($search = $this->args['search']) {
|
||||
$subscribersQuery->searchBy($search, $this->args['custom_fields']);
|
||||
}
|
||||
|
||||
if ($shortBy = $this->args['sort_by']) {
|
||||
// Allowlist of every real fc_subscribers column. The framework
|
||||
// rewrite (May 2025) made orderBy() throw LogicException for
|
||||
// names that don't match ^[a-zA-Z0-9_\.]+$, and unknown columns
|
||||
// would produce SQL errors — so the check has to happen before
|
||||
// we hand the value to the builder. getFillable() used to be
|
||||
// the gate but it omits id, contact_owner and a few others,
|
||||
// so legitimate sorts (e.g., contact_owner) silently dropped.
|
||||
$allowedSortBy = [
|
||||
'id', 'user_id', 'hash', 'contact_owner', 'company_id', 'prefix',
|
||||
'first_name', 'last_name', 'email', 'timezone', 'address_line_1',
|
||||
'address_line_2', 'postal_code', 'city', 'state', 'country', 'ip',
|
||||
'latitude', 'longitude', 'total_points', 'life_time_value', 'phone',
|
||||
'status', 'contact_type', 'source', 'avatar', 'date_of_birth',
|
||||
'created_at', 'last_activity', 'updated_at',
|
||||
];
|
||||
if (in_array($shortBy, $allowedSortBy, true)) {
|
||||
$subscribersQuery->orderBy($shortBy, $this->args['sort_type']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->args['filter_type'] == 'advanced') {
|
||||
$filtersGroups = $this->args['filters_groups'];
|
||||
|
||||
$subscribersQuery->where(function ($subscribersQueryGroup) use ($filtersGroups) {
|
||||
foreach ($filtersGroups as $groupIndex => $group) {
|
||||
$method = 'orWhere';
|
||||
if ($groupIndex == 0) {
|
||||
$method = 'where';
|
||||
}
|
||||
|
||||
$subscribersQueryGroup->{$method}(function ($q) use ($group) {
|
||||
foreach ($group as $providerName => $items) {
|
||||
do_action_ref_array('fluentcrm_contacts_filter_' . $providerName, [&$q, $items]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if ($tags = $this->args['tags']) {
|
||||
$subscribersQuery->filterByTags($tags);
|
||||
}
|
||||
|
||||
if ($lists = $this->args['lists']) {
|
||||
$subscribersQuery->filterByLists($lists);
|
||||
}
|
||||
|
||||
if ($company_ids = $this->args['company_ids']) {
|
||||
$subscribersQuery->filterByCompanies($company_ids);
|
||||
}
|
||||
|
||||
if ($statuses = $this->args['statuses']) {
|
||||
$statuses = (array) $statuses;
|
||||
$statuses = array_intersect($statuses, fluentcrm_subscriber_statuses());
|
||||
|
||||
$subscribersQuery->filterByStatues($statuses);
|
||||
}
|
||||
|
||||
if ($sms_statuses = $this->args['sms_statuses']) {
|
||||
$sms_statuses = (array) $sms_statuses;
|
||||
$subscribersQuery->where(function ($query) use ($sms_statuses) {
|
||||
foreach ($sms_statuses as $sms_status) {
|
||||
$query->orWhere('sms_status', $sms_status);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->args['has_commerce']) {
|
||||
/**
|
||||
* Filter the commerce provider for quering contacts in FluentCRM.
|
||||
*
|
||||
* This filter allows you to modify the commerce provider used in the Contact Query.
|
||||
*
|
||||
* @since 2.5.1
|
||||
*
|
||||
* @param string The commerce provider.
|
||||
*/
|
||||
$commerceProvider = apply_filters('fluentcrm_commerce_provider', '');
|
||||
if ($commerceProvider) {
|
||||
$subscribersQuery->with(['commerce_by_provider' => function ($query) use ($commerceProvider) {
|
||||
$query->where('provider', $commerceProvider);
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->args['contact_status']) {
|
||||
$subscribersQuery->where('status', $this->args['contact_status']);
|
||||
}
|
||||
|
||||
if ($this->args['company_ids']) {
|
||||
$subscribersQuery->filterByCompanies($this->args['company_ids']);
|
||||
}
|
||||
|
||||
if ($this->args['contact_ids'] && is_array($this->args['contact_ids']) && !empty($this->args['contact_ids'])) {
|
||||
$subscribersQuery->whereIn('id', $this->args['contact_ids']);
|
||||
}
|
||||
|
||||
$this->model = $subscribersQuery;
|
||||
}
|
||||
|
||||
public function get()
|
||||
{
|
||||
$subscriberModel = $this->model;
|
||||
|
||||
if ($limit = $this->args['limit']) {
|
||||
$subscriberModel = $subscriberModel->limit($limit);
|
||||
}
|
||||
|
||||
if ($offset = $this->args['offset']) {
|
||||
$subscriberModel = $subscriberModel->offset($offset);
|
||||
}
|
||||
|
||||
return $this->returnSubscribers($subscriberModel->get());
|
||||
}
|
||||
|
||||
public function paginate()
|
||||
{
|
||||
return $this->returnSubscribers($this->model->paginate());
|
||||
}
|
||||
|
||||
public function getModel()
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
private function returnSubscribers($subscribers)
|
||||
{
|
||||
if ($this->args['custom_fields']) {
|
||||
// we have to include custom fields
|
||||
foreach ($subscribers as $subscriber) {
|
||||
$subscriber->custom_fields = $subscriber->custom_fields();
|
||||
}
|
||||
}
|
||||
|
||||
return $subscribers;
|
||||
}
|
||||
|
||||
private function formatAdvancedFilters()
|
||||
{
|
||||
$filters = $this->args['filters_groups_raw'];
|
||||
|
||||
$groups = [];
|
||||
|
||||
foreach ($filters as $filterGroup) {
|
||||
$group = [];
|
||||
foreach ($filterGroup as $filterItem) {
|
||||
if (count($filterItem['source']) != 2 || empty($filterItem['source'][0]) || empty($filterItem['source'][1]) || empty($filterItem['operator'])) {
|
||||
continue;
|
||||
}
|
||||
$provider = $filterItem['source'][0];
|
||||
|
||||
if (!isset($group[$provider])) {
|
||||
$group[$provider] = [];
|
||||
}
|
||||
|
||||
$property = $filterItem['source'][1];
|
||||
|
||||
if ($property == 'purchased_groups') {
|
||||
$property = 'purchased_items';
|
||||
}
|
||||
|
||||
$group[$provider][] = [
|
||||
'property' => $property,
|
||||
'operator' => $filterItem['operator'],
|
||||
'value' => $filterItem['value'],
|
||||
'extra_value' => Arr::get($filterItem, 'extra_value')
|
||||
];
|
||||
}
|
||||
|
||||
if ($group) {
|
||||
$groups[] = $group;
|
||||
}
|
||||
}
|
||||
|
||||
$this->args['filters_groups'] = $groups;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\CrmMigrator;
|
||||
|
||||
use FluentCrm\App\Services\CrmMigrator\Api\ActiveCampaign;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class ActiveCampaignMigrator extends BaseMigrator
|
||||
{
|
||||
public function getInfo()
|
||||
{
|
||||
$infoSvg = $this->getInfoIcon();
|
||||
|
||||
$logo = $this->getSvgLogo();
|
||||
|
||||
return [
|
||||
'title' => 'ActiveCampaign',
|
||||
'description' => __('Transfer your ActiveCampaign tags and contacts to FluentCRM', 'fluent-crm'),
|
||||
'logo' => fluentCrmMix('images/migrators/active_campaign.png'),
|
||||
'logo_svg' => $logo,
|
||||
'supports' => [
|
||||
'tags' => false,
|
||||
'lists' => false,
|
||||
'empty_tags' => true,
|
||||
'active_imports_only' => true,
|
||||
'has_list_mapper' => true
|
||||
],
|
||||
'credentials' => [
|
||||
'api_key' => '',
|
||||
'api_url' => ''
|
||||
],
|
||||
'field_map_info' => __('Email and main contact fields will be mapped automatically', 'fluent-crm'),
|
||||
'credential_fields' => [
|
||||
'api_url' => [
|
||||
'label' => __('API Access URL', 'fluent-crm'),
|
||||
'placeholder' => __('API Access URL', 'fluent-crm'),
|
||||
'data_type' => 'url',
|
||||
'type' => 'input-text',
|
||||
'inline_help' => $infoSvg . ' ' . __('You can find Account ID Settings -> Developer -> API Access', 'fluent-crm')
|
||||
],
|
||||
'api_key' => [
|
||||
'label' => __('API Access Key', 'fluent-crm'),
|
||||
'placeholder' => __('ActiveCampaign API Token', 'fluent-crm'),
|
||||
'data_type' => 'password',
|
||||
'type' => 'input-text',
|
||||
'inline_help' => $infoSvg . ' ' . __('You can find your API key at ActiveCampaign Settings -> Developer -> API Access', 'fluent-crm')
|
||||
]
|
||||
],
|
||||
'refresh_on_list_change' => false,
|
||||
'doc_url' => 'https://fluentcrm.com/docs/migrating-into-fluentcrm-from-activecampaign/'
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyCredentials($credential)
|
||||
{
|
||||
$api = $this->getApi($credential);
|
||||
|
||||
try {
|
||||
$result = $api->auth_test();
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
return new \WP_Error('api_error', $exception->getMessage());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getListTagMappings($postedData)
|
||||
{
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
|
||||
$data = [];
|
||||
|
||||
$lists = $api->get_lists();
|
||||
|
||||
if (is_wp_error($lists)) {
|
||||
return $lists;
|
||||
}
|
||||
|
||||
$formattedLists = [];
|
||||
foreach ($lists as $list) {
|
||||
if (!is_array($list)) {
|
||||
continue;
|
||||
}
|
||||
$formattedLists[] = [
|
||||
'remote_id' => (string)$list['id'],
|
||||
'local_id' => '',
|
||||
'will_create' => 'no',
|
||||
'remote_name' => (string)$list['name']
|
||||
];
|
||||
}
|
||||
|
||||
$data['mapped_lists'] = $formattedLists;
|
||||
|
||||
$tags = $api->getTags();
|
||||
|
||||
if (is_wp_error($tags)) {
|
||||
return $tags;
|
||||
}
|
||||
|
||||
$formattedTags = [];
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
$formattedTags[] = [
|
||||
'remote_name' => $tag['name'],
|
||||
'remote_id' => $tag['id'],
|
||||
'will_create' => 'no',
|
||||
'fluentcrm_id' => ''
|
||||
];
|
||||
}
|
||||
$data['tags'] = $formattedTags;
|
||||
|
||||
$mergeFields = $api->get_custom_fields();
|
||||
|
||||
if (is_wp_error($mergeFields)) {
|
||||
$mergeFields = [];
|
||||
}
|
||||
|
||||
$formattedContactFields = [];
|
||||
|
||||
foreach ($mergeFields as $field) {
|
||||
if (!is_array($field)) {
|
||||
continue;
|
||||
}
|
||||
$item = [
|
||||
'type' => 'any',
|
||||
'remote_label' => $field['title'],
|
||||
'remote_tag' => $field['id'],
|
||||
'fluentcrm_field' => '',
|
||||
'remote_type' => $field['type'],
|
||||
'will_skip' => 'no'
|
||||
];
|
||||
|
||||
$formattedContactFields[] = $item;
|
||||
}
|
||||
|
||||
$data['contact_fields'] = $formattedContactFields;
|
||||
$data['contact_fillables'] = $this->getFillables();
|
||||
|
||||
unset($data['contact_fillables']['first_name']);
|
||||
unset($data['contact_fillables']['last_name']);
|
||||
unset($data['contact_fillables']['full_name']);
|
||||
unset($data['contact_fillables']['phone']);
|
||||
|
||||
$data['all_ready'] = true;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getSummary($postedData)
|
||||
{
|
||||
$mappedLists = Arr::get($postedData, 'mapped_lists', []);
|
||||
|
||||
$listCounts = count($mappedLists);
|
||||
|
||||
$message = __('Based on your selections ', 'fluent-crm') . $listCounts . __(' lists and associate contacts will be imported', 'fluent-crm');
|
||||
|
||||
return [
|
||||
'subscribed_count' => 1,
|
||||
'unsubscribed_count' => 0,
|
||||
'all_count' => 1,
|
||||
'message' => $message
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
public function runImport($postedData)
|
||||
{
|
||||
if (!defined('FLUENTCRM_DISABLE_TAG_LIST_EVENTS')) {
|
||||
define('FLUENTCRM_DISABLE_TAG_LIST_EVENTS', true);
|
||||
}
|
||||
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
|
||||
// Initialize or retrieve the current offset
|
||||
$offset = Arr::get($postedData, 'completed', 0);
|
||||
|
||||
$tagMappings = Arr::get($postedData, 'tags', []);
|
||||
|
||||
$taggingArray = $this->mapTags($tagMappings);
|
||||
|
||||
$mapSettings = Arr::get($postedData, 'map_settings', []);
|
||||
|
||||
$listingArray = $this->getListingArray(Arr::get($postedData, 'mapped_lists', []));
|
||||
|
||||
$subscribedOnly = false;
|
||||
if ($mapSettings['import_active_only'] == 'yes') {
|
||||
$subscribedOnly = true;
|
||||
}
|
||||
|
||||
// Prepare the API parameters
|
||||
$params = [
|
||||
'offset' => $offset,
|
||||
'status' => $subscribedOnly ? '1' : '-1',
|
||||
];
|
||||
$subscribers = $api->getContacts($params);
|
||||
|
||||
if (is_wp_error($subscribers)) {
|
||||
if ($offset > 0) {
|
||||
return [
|
||||
'completed' => $offset,
|
||||
'total' => $offset,
|
||||
'has_more' => false,
|
||||
'hide_progress' => true
|
||||
];
|
||||
}
|
||||
|
||||
return $subscribers;
|
||||
}
|
||||
|
||||
$fieldMaps = Arr::get($postedData, 'contact_fields', []);
|
||||
$stepCompletedCount = 0;
|
||||
foreach ($subscribers as $subscriber) {
|
||||
|
||||
if (!is_array($subscriber)) {
|
||||
continue;
|
||||
}
|
||||
$stepCompletedCount++;
|
||||
|
||||
if ($subscribedOnly && $subscriber['status'] != 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = 'subscribed';
|
||||
if ($subscriber['status'] != 1) {
|
||||
$status = 'pending';
|
||||
}
|
||||
|
||||
$data = [
|
||||
'email' => $subscriber['email'],
|
||||
'first_name' => $subscriber['first_name'],
|
||||
'last_name' => $subscriber['last_name'],
|
||||
'phone' => $subscriber['phone'],
|
||||
'created_at' => gmdate('Y-m-d H:i:s', strtotime($subscriber['cdate'])),
|
||||
'source' => 'ActiveCampaign',
|
||||
'ip' => $subscriber['ip'],
|
||||
'status' => $status,
|
||||
'lists' => []
|
||||
];
|
||||
|
||||
|
||||
if (!empty($subscriber['fields'])) {
|
||||
$customFields = [];
|
||||
foreach ($subscriber['fields'] as $field) {
|
||||
$val = $field['val'];
|
||||
/**
|
||||
* ActiveCampaign api return checkbox & listbox field value as ||VALUE|| format
|
||||
* For example: ||a||, ||yes||
|
||||
* Here we remove the || from the value
|
||||
*/
|
||||
if (in_array($field['type'], ['checkbox', 'listbox'])) {
|
||||
$val = array_values( array_filter( explode( '||', $val ) ) );
|
||||
}
|
||||
$customFields[$field['perstag']] = $val;
|
||||
}
|
||||
if ($customFields) {
|
||||
$mergeData = $this->getMergedData($customFields, $fieldMaps);
|
||||
|
||||
// Create values for multiselect and checkbox fields.
|
||||
$this->maybeCreateFieldOptions( $mergeData );
|
||||
|
||||
if ($mergeData) {
|
||||
$data = array_merge($data, $mergeData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map Lists here
|
||||
if ($listingArray && $subscriber['lists']) {
|
||||
foreach ($subscriber['lists'] as $subList) {
|
||||
$listId = $subList['listid'];
|
||||
if (isset($listingArray[$listId])) {
|
||||
$data['lists'][] = $listingArray[$listId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($mapSettings['local_list_id'])) {
|
||||
$data['lists'][] = $mapSettings['local_list_id'];
|
||||
}
|
||||
|
||||
if (!empty($subscriber['tags']) && $taggingArray) {
|
||||
$tagIds = [];
|
||||
foreach ($subscriber['tags'] as $contactTag) {
|
||||
if (!empty($taggingArray[$contactTag])) {
|
||||
$tagIds[] = $taggingArray[$contactTag];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($tagIds) && !empty($mapSettings['local_tag_id'])) {
|
||||
$tagIds = [$mapSettings['local_tag_id']];
|
||||
}
|
||||
|
||||
$data['tags'] = $tagIds;
|
||||
|
||||
} else if (!empty($mapSettings['local_tag_id'])) {
|
||||
$data['tags'] = [$mapSettings['local_tag_id']];
|
||||
}
|
||||
|
||||
$contact = FluentCrmApi('contacts')->createOrUpdate($data);
|
||||
|
||||
if ($subscribedOnly && $contact && $contact->status != 'subscribed') {
|
||||
$contact->updateStatus('subscribed');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'completed' => $offset + 100,
|
||||
'total' => $offset + 100,
|
||||
'has_more' => !empty($subscribers),
|
||||
'hide_progress' => true,
|
||||
'message' => ($offset + 100) . __(' contacts have been imported so far.', 'fluent-crm')
|
||||
];
|
||||
}
|
||||
|
||||
private function getApi($credentials)
|
||||
{
|
||||
return new ActiveCampaign($credentials['api_url'], $credentials['api_key']);
|
||||
}
|
||||
|
||||
private function getListingArray($listMappings)
|
||||
{
|
||||
$formattedMaps = [];
|
||||
|
||||
foreach ($listMappings as $listMapping) {
|
||||
$remoteId = $listMapping['remote_id'];
|
||||
|
||||
if ($listMapping['will_create'] == 'yes') {
|
||||
$remoteName = sanitize_text_field($listMapping['remote_name']);
|
||||
if (!$remoteName) {
|
||||
continue;
|
||||
}
|
||||
$listMapping['fluentcrm_id'] = $this->getListId($remoteName);
|
||||
}
|
||||
|
||||
if (empty($listMapping['fluentcrm_id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$formattedMaps[$remoteId] = (int)$listMapping['fluentcrm_id'];
|
||||
}
|
||||
|
||||
return $formattedMaps;
|
||||
}
|
||||
|
||||
public function getSvgLogo()
|
||||
{
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M16 30C23.732 30 30 23.732 30 16C30 8.26801 23.732 2 16 2C8.26801 2 2 8.26801 2 16C2 23.732 8.26801 30 16 30Z" fill="#004CFF"/>
|
||||
<path d="M20.7497 15.9683L12.5577 21.4086C12.1782 21.6616 11.9883 22.0727 11.9883 22.4839V23.8439L21.92 17.3283C22.3628 17.0121 22.6475 16.5059 22.6475 15.9683C22.6475 15.4306 22.3945 14.9244 21.92 14.6081L11.9883 8.15576V9.42089C11.9883 9.8638 12.2097 10.2749 12.5577 10.4963L20.7497 15.9683Z" fill="white"/>
|
||||
<path d="M15.8155 16.4112C16.2583 16.6958 16.8277 16.6958 17.2705 16.4112L17.9663 15.9367L12.779 12.4259C12.4628 12.2044 11.9883 12.4259 11.9883 12.837V13.8807L14.6768 15.6837L15.8155 16.4112Z" fill="white"/>
|
||||
</svg>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\CrmMigrator\Api;
|
||||
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
class ActiveCampaign
|
||||
{
|
||||
protected $apiUrl = null;
|
||||
|
||||
protected $apiKey = null;
|
||||
|
||||
public function __construct($apiUrl, $apiKey = null)
|
||||
{
|
||||
$this->apiUrl = $apiUrl;
|
||||
$this->apiKey = $apiKey;
|
||||
}
|
||||
|
||||
public function default_options()
|
||||
{
|
||||
return array(
|
||||
'api_key' => $this->apiKey,
|
||||
'api_output' => 'json'
|
||||
);
|
||||
}
|
||||
|
||||
public function make_request($action, $options = array(), $method = 'GET')
|
||||
{
|
||||
/* Build request options string. */
|
||||
$request_options = $this->default_options();
|
||||
$request_options['api_action'] = $action;
|
||||
|
||||
if ($request_options['api_action'] == 'contact_edit')
|
||||
$request_options['overwrite'] = '0';
|
||||
|
||||
$request_options = http_build_query($request_options);
|
||||
$request_options .= ($method == 'GET') ? '&' . http_build_query($options) : null;
|
||||
|
||||
/* Build request URL. */
|
||||
$request_url = untrailingslashit($this->apiUrl) . '/admin/api.php?' . $request_options;
|
||||
$response = null;
|
||||
/* Execute request based on method. */
|
||||
switch ($method) {
|
||||
|
||||
case 'POST':
|
||||
$args = array(
|
||||
'body' => $options,
|
||||
'timeout' => 30
|
||||
);
|
||||
$response = wp_remote_post($request_url, $args);
|
||||
break;
|
||||
|
||||
case 'GET':
|
||||
$response = wp_remote_get($request_url, [
|
||||
'timeout' => 30
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
$error = $this->maybeError($response);
|
||||
if ($error) {
|
||||
return $error;
|
||||
}
|
||||
return json_decode($response['body'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the provided API credentials.
|
||||
*
|
||||
* @access public
|
||||
* @return bool
|
||||
*/
|
||||
public function auth_test()
|
||||
{
|
||||
/* Build options string. */
|
||||
$request_options = $this->default_options();
|
||||
$request_options['api_action'] = 'list_paginator';
|
||||
$request_options = http_build_query($request_options);
|
||||
|
||||
/* Setup request URL. */
|
||||
$request_url = untrailingslashit($this->apiUrl) . '/admin/api.php?' . $request_options;
|
||||
|
||||
/* Execute request. */
|
||||
$response = wp_remote_get($request_url);
|
||||
|
||||
$error = $this->maybeError($response);
|
||||
if ($error) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all custom list fields.
|
||||
*
|
||||
* @access public
|
||||
* @return array
|
||||
*/
|
||||
public function get_custom_fields()
|
||||
{
|
||||
return $this->make_request('list_field_view', array('ids' => 'all'));
|
||||
}
|
||||
|
||||
public function getContacts($args = ['offset' => 0, 'status' => -1])
|
||||
{
|
||||
|
||||
$args = wp_parse_args(
|
||||
$args,
|
||||
array(
|
||||
'offset' => 0,
|
||||
'status' => $args['status'],
|
||||
'include' => 'contactTags,contactLists,fieldValues',
|
||||
'limit' => 100,
|
||||
'api_key' => $this->apiKey,
|
||||
)
|
||||
);
|
||||
|
||||
$subscribers = array();
|
||||
|
||||
$request = add_query_arg( $args, untrailingslashit( $this->apiUrl ) . '/api/3/contacts' );
|
||||
$response = wp_safe_remote_get( $request );
|
||||
|
||||
$error = $this->maybeError( $response );
|
||||
if ( $error ) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$response = json_decode( wp_remote_retrieve_body( $response ) );
|
||||
|
||||
if ( ! empty( $response->contacts ) ) {
|
||||
|
||||
// Base subscriber data.
|
||||
|
||||
foreach ( $response->contacts as $contact ) {
|
||||
|
||||
$subscribers[ $contact->id ] = array(
|
||||
'first_name' => $contact->{'firstName'},
|
||||
'last_name' => $contact->{'lastName'},
|
||||
'email' => $contact->{'email'},
|
||||
'phone' => $contact->{'phone'},
|
||||
'cdate' => $contact->{'cdate'},
|
||||
'ip' => $contact->{'ip'},
|
||||
'status' => 1, // @todo this should be checked based on the list membership.
|
||||
'tags' => array(),
|
||||
'lists' => array(),
|
||||
'fields' => array(),
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
// Fields.
|
||||
|
||||
if ( ! empty( $response->{'fieldValues'} ) ) {
|
||||
|
||||
foreach ( $response->{'fieldValues'} as $field ) {
|
||||
|
||||
if ( false !== strpos( $field->value, '||' ) ) {
|
||||
$type = 'checkbox';
|
||||
} else {
|
||||
$type = 'text';
|
||||
}
|
||||
|
||||
$subscribers[ $field->contact ]['fields'][] = array(
|
||||
'val' => $field->value,
|
||||
'perstag' => $field->field,
|
||||
'type' => $type,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Tags.
|
||||
|
||||
if ( ! empty( $response->{'contactTags'} ) ) {
|
||||
|
||||
foreach ( $response->{'contactTags'} as $tag ) {
|
||||
$subscribers[ $tag->contact ]['tags'][] = $tag->tag;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Lists.
|
||||
|
||||
if ( ! empty( $response->{'contactLists'} ) ) {
|
||||
|
||||
foreach ( $response->{'contactLists'} as $list ) {
|
||||
$subscribers[ $list->contact ]['lists'][] = array( 'listid' => $list->list );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $subscribers;
|
||||
|
||||
}
|
||||
|
||||
public function contactPaginator($args = ['limit' => 20, 'public' => 0])
|
||||
{
|
||||
return $this->make_request('contact_paginator', $args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all lists in the system.
|
||||
*
|
||||
* @access public
|
||||
* @return array
|
||||
*/
|
||||
public function get_lists()
|
||||
{
|
||||
return $this->make_request('list_list', array('ids' => 'all'));
|
||||
}
|
||||
|
||||
public function getTags()
|
||||
{
|
||||
return $this->make_request('tags_list', array('ids' => 'all'));
|
||||
}
|
||||
|
||||
public function maybeError($response)
|
||||
{
|
||||
/* If invalid content type, API URL is invalid. */
|
||||
if (is_wp_error($response))
|
||||
return $response;
|
||||
$contentType = isset($response['headers']['content-type']) ? (string) $response['headers']['content-type'] : '';
|
||||
if (strpos($contentType, 'application/json') === false) {
|
||||
return new \WP_Error('error', 'Invalid API URL');
|
||||
}
|
||||
|
||||
if ($response['response']['code'] > 300) {
|
||||
return new \WP_Error('API_Error', $response['response']['message'], $response);
|
||||
}
|
||||
|
||||
$body = json_decode($response['body'], true);
|
||||
if (isset($body['result_code']) && $body['result_code'] == 0) {
|
||||
$message = 'Invalid API';
|
||||
return new \WP_Error('API_Error', $message, $response);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\CrmMigrator\Api;
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
class ConvertKit
|
||||
{
|
||||
protected $apiUrl = 'https://api.convertkit.com/v3/';
|
||||
|
||||
protected $apiKey = null;
|
||||
|
||||
protected $apiSecret = null;
|
||||
|
||||
public function __construct( $apiKey = null, $apiSecret = null )
|
||||
{
|
||||
$this->apiKey = $apiKey;
|
||||
$this->apiSecret = $apiSecret;
|
||||
}
|
||||
|
||||
public function default_options()
|
||||
{
|
||||
return array(
|
||||
'api_key' => $this->apiKey
|
||||
);
|
||||
}
|
||||
|
||||
public function make_request( $action, $options = array(), $method = 'GET' )
|
||||
{
|
||||
/* Build request options string. */
|
||||
$request_options = $this->default_options();
|
||||
|
||||
$request_options = wp_parse_args($options, $request_options);
|
||||
$options_string = http_build_query( $request_options );
|
||||
|
||||
/* Execute request based on method. */
|
||||
switch ( $method ) {
|
||||
case 'POST':
|
||||
$args = array(
|
||||
'body' => json_encode($options),
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json'
|
||||
]
|
||||
);
|
||||
$response = wp_remote_post( $this->apiUrl.$action.'?api_key='.$this->apiKey, $args );
|
||||
break;
|
||||
|
||||
case 'GET':
|
||||
/* Build request URL. */
|
||||
$request_url = $this->apiUrl . $action.'?' . $options_string;
|
||||
$response = wp_remote_get( $request_url );
|
||||
break;
|
||||
}
|
||||
|
||||
/* If WP_Error, die. Otherwise, return decoded JSON. */
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return [
|
||||
'error' => __('API_Error', 'fluent-crm'),
|
||||
'message' => $response->get_error_message()
|
||||
];
|
||||
} else {
|
||||
return json_decode( $response['body'], true );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the provided API credentials.
|
||||
*
|
||||
* @access public
|
||||
* @return bool
|
||||
*/
|
||||
public function auth_test()
|
||||
{
|
||||
return $this->make_request('forms', [], 'GET');
|
||||
}
|
||||
|
||||
|
||||
public function subscribe($formId, $data)
|
||||
{
|
||||
$response = $this->make_request('forms/'.$formId.'/subscribe', $data, 'POST');
|
||||
if(!empty($response['error'])) {
|
||||
return new \WP_Error('api_error', $response['message']);
|
||||
}
|
||||
|
||||
return $response['subscription'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Forms in the system.
|
||||
*
|
||||
* @access public
|
||||
* @return array
|
||||
*/
|
||||
public function getLists()
|
||||
{
|
||||
$response = $this->make_request( 'forms', array(), 'GET' );
|
||||
if(empty($response['error'])) {
|
||||
return $response['forms'];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Tags in the system.
|
||||
*
|
||||
* @access public
|
||||
* @return array
|
||||
*/
|
||||
public function getTags()
|
||||
{
|
||||
$response = $this->make_request( 'tags', array(), 'GET' );
|
||||
if(empty($response['error'])) {
|
||||
return $response['tags'];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getCustomFields()
|
||||
{
|
||||
$response = $this->make_request( 'custom_fields', array(), 'GET' );
|
||||
if(empty($response['error'])) {
|
||||
return $response['custom_fields'];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getSubscribers($args = [])
|
||||
{
|
||||
$args['api_secret'] = $this->apiSecret;
|
||||
$response = $this->make_request( 'subscribers', $args, 'GET' );
|
||||
|
||||
if(empty($response['error'])) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
new \WP_Error('api_error', $response['message']);
|
||||
}
|
||||
|
||||
public function getTagSubscribers($tagId, $args = [])
|
||||
{
|
||||
$args['api_secret'] = $this->apiSecret;
|
||||
$response = $this->make_request( 'tags/'.$tagId.'/subscriptions', $args, 'GET' );
|
||||
|
||||
if(empty($response['error'])) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
new \WP_Error('api_error', $response['message']);
|
||||
}
|
||||
|
||||
public function getSubscriberTags($contactId)
|
||||
{
|
||||
$args['api_secret'] = $this->apiSecret;
|
||||
$response = $this->make_request( 'subscribers/'.$contactId.'/tags', $args, 'GET' );
|
||||
|
||||
if(empty($response['error'])) {
|
||||
return $response['tags'];
|
||||
}
|
||||
|
||||
new \WP_Error('api_error', $response['message']);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\CrmMigrator\Api;
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
class Drip
|
||||
{
|
||||
protected $apiKey = null;
|
||||
protected $accountId = null;
|
||||
|
||||
private $apiUrl = "https://api.getdrip.com/v2/";
|
||||
|
||||
public function __construct($apiKey = null, $accountId = null)
|
||||
{
|
||||
$this->apiKey = $apiKey;
|
||||
$this->accountId = $accountId;
|
||||
}
|
||||
|
||||
public function make_request($endpoint = '', $data = array(), $method = 'POST')
|
||||
{
|
||||
$data['api_key'] = $this->apiKey;
|
||||
|
||||
$args = array(
|
||||
'method' => $method,
|
||||
'headers' => array(
|
||||
'content-type' => 'application/vnd.api+json',
|
||||
'Authorization' => 'Basic ' . base64_encode($this->apiKey)
|
||||
),
|
||||
'body' => ($method == 'POST') ? wp_json_encode($data) : $data
|
||||
);
|
||||
|
||||
if ($method == 'POST') {
|
||||
$response = wp_remote_post($this->apiUrl . $endpoint, $args);
|
||||
} else {
|
||||
$response = wp_remote_get($this->apiUrl . $endpoint, $args);
|
||||
}
|
||||
/* If WP_Error, die. Otherwise, return decoded JSON. */
|
||||
if (is_wp_error($response)) {
|
||||
return $response;
|
||||
}
|
||||
return json_decode($response['body'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the provided API credentials.
|
||||
*
|
||||
* @access public
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
public function auth_test()
|
||||
{
|
||||
return $this->make_request('accounts', [], 'GET');
|
||||
}
|
||||
|
||||
public function sendAccountItems($endpoint, $args = [])
|
||||
{
|
||||
return $this->make_request($this->accountId . '/'.$endpoint, $args, 'GET');
|
||||
}
|
||||
|
||||
public function addContact($contact)
|
||||
{
|
||||
$accountId = $this->accountId;
|
||||
$contactObj = [
|
||||
'subscribers' => [$contact]
|
||||
];
|
||||
$response = $this->make_request($accountId . '/subscribers', $contactObj, 'POST');
|
||||
|
||||
if (!empty($response['subscribers'])) {
|
||||
return $response;
|
||||
}
|
||||
$message = 'API Eroror';
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$message = $response->get_error_message();
|
||||
}
|
||||
|
||||
return new \WP_Error('error', $message);
|
||||
}
|
||||
|
||||
public function add_note($contact_id, $email, $note)
|
||||
{
|
||||
return $this->make_request([
|
||||
'action' => 'contact_add_note',
|
||||
'value' => (object)[
|
||||
'contact_id' => $contact_id,
|
||||
'email' => $email,
|
||||
'note' => $note
|
||||
],
|
||||
], 'POST');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\CrmMigrator\Api;
|
||||
|
||||
/**
|
||||
* Super-simple, minimum abstraction MailChimp API v3 wrapper
|
||||
* MailChimp API v3: http://developer.mailchimp.com
|
||||
* This wrapper: https://github.com/drewm/mailchimp-api
|
||||
*
|
||||
* @author Drew McLellan <drew.mclellan@gmail.com>
|
||||
* @version 2.4
|
||||
*/
|
||||
class MailChimp
|
||||
{
|
||||
private $api_key;
|
||||
private $api_endpoint = 'https://<dc>.api.mailchimp.com/3.0';
|
||||
|
||||
const TIMEOUT = 10;
|
||||
|
||||
/* SSL Verification
|
||||
Read before disabling:
|
||||
http://snippets.webaware.com.au/howto/stop-turning-off-curlopt_ssl_verifypeer-and-fix-your-php-config/
|
||||
*/
|
||||
public $verify_ssl = true;
|
||||
|
||||
private $request_successful = false;
|
||||
private $last_error = '';
|
||||
private $last_response = array();
|
||||
private $last_request = array();
|
||||
|
||||
/**
|
||||
* Create a new instance
|
||||
* @param string $api_key Your MailChimp API key
|
||||
* @param string $api_endpoint Optional custom API endpoint
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct($api_key, $api_endpoint = null)
|
||||
{
|
||||
$this->api_key = $api_key;
|
||||
|
||||
if ($api_endpoint === null) {
|
||||
if (strpos($this->api_key, '-') === false) {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
throw new \Exception("Invalid MailChimp API key `{$api_key}` supplied.");
|
||||
}
|
||||
list(, $data_center) = explode('-', $this->api_key);
|
||||
$this->api_endpoint = str_replace('<dc>', $data_center, $this->api_endpoint);
|
||||
} else {
|
||||
$this->api_endpoint = $api_endpoint;
|
||||
}
|
||||
|
||||
$this->last_response = array('headers' => null, 'body' => null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The url to the API endpoint
|
||||
*/
|
||||
public function getApiEndpoint()
|
||||
{
|
||||
return $this->api_endpoint;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Convert an email address into a 'subscriber hash' for identifying the subscriber in a method URL
|
||||
* @param string $email The subscriber's email address
|
||||
* @return string Hashed version of the input
|
||||
*/
|
||||
public function subscriberHash($email)
|
||||
{
|
||||
return md5(strtolower($email));
|
||||
}
|
||||
|
||||
/**
|
||||
* Was the last request successful?
|
||||
* @return bool True for success, false for failure
|
||||
*/
|
||||
public function success()
|
||||
{
|
||||
return $this->request_successful;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last error returned by either the network transport, or by the API.
|
||||
* If something didn't work, this should contain the string describing the problem.
|
||||
* @return string|false describing the error
|
||||
*/
|
||||
public function getLastError()
|
||||
{
|
||||
return $this->last_error ?: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array containing the HTTP headers and the body of the API response.
|
||||
* @return array Assoc array with keys 'headers' and 'body'
|
||||
*/
|
||||
public function getLastResponse()
|
||||
{
|
||||
return $this->last_response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array containing the HTTP headers and the body of the API request.
|
||||
* @return array Assoc array
|
||||
*/
|
||||
public function getLastRequest()
|
||||
{
|
||||
return $this->last_request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP DELETE request - for deleting data
|
||||
* @param string $method URL of the API request method
|
||||
* @param array $args Assoc array of arguments (if any)
|
||||
* @param int $timeout Timeout limit for request in seconds
|
||||
* @return array|false Assoc array of API response, decoded from JSON
|
||||
*/
|
||||
public function delete($method, $args = array(), $timeout = self::TIMEOUT)
|
||||
{
|
||||
return $this->makeRequest('delete', $method, $args, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP GET request - for retrieving data
|
||||
* @param string $method URL of the API request method
|
||||
* @param array $args Assoc array of arguments (usually your data)
|
||||
* @param int $timeout Timeout limit for request in seconds
|
||||
* @return array|false Assoc array of API response, decoded from JSON
|
||||
*/
|
||||
public function get($method, $args = array(), $timeout = self::TIMEOUT)
|
||||
{
|
||||
return $this->makeRequest('get', $method, $args, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP PATCH request - for performing partial updates
|
||||
* @param string $method URL of the API request method
|
||||
* @param array $args Assoc array of arguments (usually your data)
|
||||
* @param int $timeout Timeout limit for request in seconds
|
||||
* @return array|false Assoc array of API response, decoded from JSON
|
||||
*/
|
||||
public function patch($method, $args = array(), $timeout = self::TIMEOUT)
|
||||
{
|
||||
return $this->makeRequest('patch', $method, $args, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP POST request - for creating and updating items
|
||||
* @param string $method URL of the API request method
|
||||
* @param array $args Assoc array of arguments (usually your data)
|
||||
* @param int $timeout Timeout limit for request in seconds
|
||||
* @return array|false Assoc array of API response, decoded from JSON
|
||||
*/
|
||||
public function post($method, $args = array(), $timeout = self::TIMEOUT)
|
||||
{
|
||||
return $this->makeRequest('post', $method, $args, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP PUT request - for creating new items
|
||||
* @param string $method URL of the API request method
|
||||
* @param array $args Assoc array of arguments (usually your data)
|
||||
* @param int $timeout Timeout limit for request in seconds
|
||||
* @return array|false Assoc array of API response, decoded from JSON
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function put($method, $args = array(), $timeout = self::TIMEOUT)
|
||||
{
|
||||
return $this->makeRequest('put', $method, $args, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the underlying HTTP request. Not very exciting.
|
||||
* @param string $http_verb The HTTP verb to use: get, post, put, patch, delete
|
||||
* @param string $method The API method to be called
|
||||
* @param array $args Assoc array of parameters to be passed
|
||||
* @param int $timeout
|
||||
* @return array|false Assoc array of decoded result
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function makeRequest($http_verb, $method, $args = array(), $timeout = self::TIMEOUT)
|
||||
{
|
||||
if (!function_exists('curl_init') || !function_exists('curl_setopt')) {
|
||||
throw new \Exception("cURL support is required, but can't be found.");
|
||||
}
|
||||
|
||||
$url = $this->api_endpoint . '/' . $method;
|
||||
|
||||
$response = $this->prepareStateForRequest($http_verb, $method, $url, $timeout);
|
||||
|
||||
$httpHeader = array(
|
||||
'Accept: application/vnd.api+json',
|
||||
'Content-Type: application/vnd.api+json',
|
||||
'Authorization: apikey ' . $this->api_key
|
||||
);
|
||||
|
||||
if (isset($args["language"])) {
|
||||
$httpHeader[] = "Accept-Language: " . $args["language"];
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_exec
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error
|
||||
// PluginCheck:ignoreFile
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $httpHeader);
|
||||
curl_setopt($ch, CURLOPT_USERAGENT, 'DrewM/MailChimp-API/3.0 (github.com/drewm/mailchimp-api)');
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_VERBOSE, true);
|
||||
curl_setopt($ch, CURLOPT_HEADER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $this->verify_ssl);
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
|
||||
curl_setopt($ch, CURLOPT_ENCODING, '');
|
||||
curl_setopt($ch, CURLINFO_HEADER_OUT, true);
|
||||
|
||||
switch ($http_verb) {
|
||||
case 'post':
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
$this->attachRequestPayload($ch, $args);
|
||||
break;
|
||||
|
||||
case 'get':
|
||||
$query = http_build_query($args, '', '&');
|
||||
curl_setopt($ch, CURLOPT_URL, $url . '?' . $query);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
|
||||
break;
|
||||
|
||||
case 'patch':
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
|
||||
$this->attachRequestPayload($ch, $args);
|
||||
break;
|
||||
|
||||
case 'put':
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
|
||||
$this->attachRequestPayload($ch, $args);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
$responseContent = curl_exec($ch);
|
||||
$response['headers'] = curl_getinfo($ch);
|
||||
$response = $this->setResponseState($response, $responseContent, $ch);
|
||||
$formattedResponse = $this->formatResponse($response);
|
||||
|
||||
curl_close($ch);
|
||||
// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_init
|
||||
// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_exec
|
||||
// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_close
|
||||
// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
|
||||
// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_error
|
||||
|
||||
$this->determineSuccess($response, $formattedResponse, $timeout);
|
||||
|
||||
return $formattedResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $http_verb
|
||||
* @param string $method
|
||||
* @param string $url
|
||||
* @param integer $timeout
|
||||
*/
|
||||
private function prepareStateForRequest($http_verb, $method, $url, $timeout)
|
||||
{
|
||||
$this->last_error = '';
|
||||
|
||||
$this->request_successful = false;
|
||||
|
||||
$this->last_response = array(
|
||||
'headers' => null, // array of details from curl_getinfo()
|
||||
'httpHeaders' => null, // array of HTTP headers
|
||||
'body' => null // content of the response
|
||||
);
|
||||
|
||||
$this->last_request = array(
|
||||
'method' => $http_verb,
|
||||
'path' => $method,
|
||||
'url' => $url,
|
||||
'body' => '',
|
||||
'timeout' => $timeout,
|
||||
);
|
||||
|
||||
return $this->last_response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTTP headers as an array of header-name => header-value pairs.
|
||||
*
|
||||
* The "Link" header is parsed into an associative array based on the
|
||||
* rel names it contains. The original value is available under
|
||||
* the "_raw" key.
|
||||
*
|
||||
* @param string $headersAsString
|
||||
* @return array
|
||||
*/
|
||||
private function getHeadersAsArray($headersAsString)
|
||||
{
|
||||
$headers = array();
|
||||
|
||||
foreach (explode("\r\n", $headersAsString) as $i => $line) {
|
||||
if ($i === 0) { // HTTP code
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = trim($line);
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
list($key, $value) = explode(': ', $line);
|
||||
|
||||
if ($key == 'Link') {
|
||||
$value = array_merge(
|
||||
array('_raw' => $value),
|
||||
$this->getLinkHeaderAsArray($value)
|
||||
);
|
||||
}
|
||||
|
||||
$headers[$key] = $value;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all rel => URL pairs from the provided Link header value
|
||||
*
|
||||
* Mailchimp only implements the URI reference and relation type from
|
||||
* RFC 5988, so the value of the header is something like this:
|
||||
*
|
||||
* 'https://us13.api.mailchimp.com/schema/3.0/Lists/Instance.json; rel="describedBy", <https://us13.admin.mailchimp.com/lists/members/?id=XXXX>; rel="dashboard"'
|
||||
*
|
||||
* @param string $linkHeaderAsString
|
||||
* @return array
|
||||
*/
|
||||
private function getLinkHeaderAsArray($linkHeaderAsString)
|
||||
{
|
||||
$urls = array();
|
||||
|
||||
if (preg_match_all('/<(.*?)>\s*;\s*rel="(.*?)"\s*/', $linkHeaderAsString, $matches)) {
|
||||
foreach ($matches[2] as $i => $relName) {
|
||||
$urls[$relName] = $matches[1][$i];
|
||||
}
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the data and attach it to the request
|
||||
* @param resource $ch cURL session handle, used by reference
|
||||
* @param array $data Assoc array of data to attach
|
||||
*/
|
||||
private function attachRequestPayload(&$ch, $data)
|
||||
{
|
||||
$encoded = json_encode($data);
|
||||
$this->last_request['body'] = $encoded;
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the response and format any error messages for debugging
|
||||
* @param array $response The response from the curl request
|
||||
* @return array|false The JSON decoded into an array
|
||||
*/
|
||||
private function formatResponse($response)
|
||||
{
|
||||
$this->last_response = $response;
|
||||
|
||||
if (!empty($response['body'])) {
|
||||
return json_decode($response['body'], true);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do post-request formatting and setting state from the response
|
||||
* @param array $response The response from the curl request
|
||||
* @param string $responseContent The body of the response from the curl request
|
||||
* * @return array The modified response
|
||||
*/
|
||||
private function setResponseState($response, $responseContent, $ch)
|
||||
{
|
||||
if ($responseContent === false) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error
|
||||
$this->last_error = curl_error($ch);
|
||||
} else {
|
||||
|
||||
$headerSize = $response['headers']['header_size'];
|
||||
|
||||
$response['httpHeaders'] = $this->getHeadersAsArray(substr($responseContent, 0, $headerSize));
|
||||
$response['body'] = substr($responseContent, $headerSize);
|
||||
|
||||
if (isset($response['headers']['request_header'])) {
|
||||
$this->last_request['headers'] = $response['headers']['request_header'];
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the response was successful or a failure. If it failed, store the error.
|
||||
* @param array $response The response from the curl request
|
||||
* @param array|false $formattedResponse The response body payload from the curl request
|
||||
* @param int $timeout The timeout supplied to the curl request.
|
||||
* @return bool If the request was successful
|
||||
*/
|
||||
private function determineSuccess($response, $formattedResponse, $timeout)
|
||||
{
|
||||
$status = $this->findHTTPStatus($response, $formattedResponse);
|
||||
|
||||
if ($status >= 200 && $status <= 299) {
|
||||
$this->request_successful = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isset($formattedResponse['detail'])) {
|
||||
$this->last_error = sprintf('%d: %s', $formattedResponse['status'], $formattedResponse['detail']);
|
||||
return false;
|
||||
}
|
||||
|
||||
if( $timeout > 0 && $response['headers'] && $response['headers']['total_time'] >= $timeout ) {
|
||||
$this->last_error = sprintf('Request timed out after %f seconds.', $response['headers']['total_time'] );
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->last_error = 'Unknown error, call getLastResponse() to find out what happened.';
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the HTTP status code from the headers or API response body
|
||||
* @param array $response The response from the curl request
|
||||
* @param array|false $formattedResponse The response body payload from the curl request
|
||||
* @return int HTTP status code
|
||||
*/
|
||||
private function findHTTPStatus($response, $formattedResponse)
|
||||
{
|
||||
if (!empty($response['headers']) && isset($response['headers']['http_code'])) {
|
||||
return (int) $response['headers']['http_code'];
|
||||
}
|
||||
|
||||
if (!empty($response['body']) && isset($formattedResponse['status'])) {
|
||||
return (int) $formattedResponse['status'];
|
||||
}
|
||||
|
||||
return 418;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\CrmMigrator\Api;
|
||||
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
class MailerLite
|
||||
{
|
||||
protected $apiUrl = 'https://api.mailerlite.com/api/v2/';
|
||||
|
||||
protected $apiKey = null;
|
||||
|
||||
protected $apiSecret = null;
|
||||
|
||||
public function __construct($apiKey = null)
|
||||
{
|
||||
$this->apiKey = $apiKey;
|
||||
}
|
||||
|
||||
public function default_options()
|
||||
{
|
||||
return [
|
||||
'User-Agent' => 'MailerLite PHP SDK/2.0',
|
||||
'X-MailerLite-ApiKey' => $this->apiKey,
|
||||
'Content-Type' => 'application/json'
|
||||
];
|
||||
}
|
||||
|
||||
public function make_request($action, $options = array(), $method = 'GET')
|
||||
{
|
||||
|
||||
$headers = $this->default_options();
|
||||
$endpointUrl = $this->apiUrl . $action;
|
||||
$args = [
|
||||
'headers' => $headers
|
||||
];
|
||||
|
||||
if ($options && $method == 'POST') {
|
||||
$args['body'] = \json_encode($options);
|
||||
} else if($method == 'GET' && $options) {
|
||||
$endpointUrl = add_query_arg($options, $endpointUrl);
|
||||
}
|
||||
|
||||
/* Execute request based on method. */
|
||||
switch ($method) {
|
||||
case 'POST':
|
||||
$response = wp_remote_post($endpointUrl, $args);
|
||||
break;
|
||||
|
||||
case 'GET':
|
||||
$response = wp_remote_get($endpointUrl, $args);
|
||||
break;
|
||||
}
|
||||
|
||||
/* If WP_Error, die. Otherwise, return decoded JSON. */
|
||||
if (is_wp_error($response)) {
|
||||
return new \WP_Error('API_Error', $response->get_error_message());
|
||||
} else if ($response && $response['response']['code'] >= 300) {
|
||||
return new \WP_Error('API_Error', $response['response']['message']);
|
||||
}
|
||||
return json_decode($response['body'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the provided API credentials.
|
||||
*
|
||||
* @access public
|
||||
* @return bool
|
||||
*/
|
||||
public function auth_test()
|
||||
{
|
||||
return $this->make_request('groups', [], 'GET');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Forms in the system.
|
||||
*
|
||||
* @access public
|
||||
* @return array
|
||||
*/
|
||||
public function getGroups()
|
||||
{
|
||||
return $this->make_request('groups', array(), 'GET');
|
||||
}
|
||||
|
||||
public function getGroupSubscribers($groupId, $args = [])
|
||||
{
|
||||
return $this->make_request('groups/' . $groupId . '/subscribers', $args, 'GET');
|
||||
}
|
||||
|
||||
public function getContactCountByGroup($groupId)
|
||||
{
|
||||
$result = $this->make_request('groups/' . $groupId . '/subscribers/count', array(), 'GET');
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return $result['count'];
|
||||
}
|
||||
|
||||
public function getCustomFields()
|
||||
{
|
||||
return $this->make_request('fields', array(), 'GET');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\CrmMigrator;
|
||||
|
||||
use FluentCrm\App\Models\Lists;
|
||||
use FluentCrm\App\Models\Tag;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
abstract class BaseMigrator
|
||||
{
|
||||
abstract public function getInfo();
|
||||
|
||||
abstract public function verifyCredentials($credential);
|
||||
|
||||
abstract public function getSummary($settings);
|
||||
|
||||
abstract public function runImport($settings);
|
||||
|
||||
abstract public function getListTagMappings($settings);
|
||||
|
||||
public function getFillables($with = ['main_fields', 'address', 'custom_fields'])
|
||||
{
|
||||
$mainFields = [
|
||||
'prefix' => __('Name Prefix', 'fluent-crm'),
|
||||
'first_name' => __('First Name', 'fluent-crm'),
|
||||
'last_name' => __('Last Name', 'fluent-crm'),
|
||||
'full_name' => __('Full Name', 'fluent-crm'),
|
||||
'phone' => __('Phone', 'fluent-crm')
|
||||
];
|
||||
|
||||
if (!$with) {
|
||||
return $mainFields;
|
||||
}
|
||||
|
||||
$addressFields = [
|
||||
'address_line_1' => __('Address Line 1', 'fluent-crm'),
|
||||
'address_line_2' => __('Address Line 2', 'fluent-crm'),
|
||||
'postal_code' => __('Postal Code', 'fluent-crm'),
|
||||
'city' => __('City', 'fluent-crm'),
|
||||
'state' => __('State', 'fluent-crm'),
|
||||
'country' => __('Country', 'fluent-crm'),
|
||||
];
|
||||
|
||||
$customFields = fluentcrm_get_custom_contact_fields();
|
||||
|
||||
$customs = [];
|
||||
foreach ($customFields as $customField) {
|
||||
$customs[$customField['slug']] = $customField['label'] . ' (Custom Field)';
|
||||
}
|
||||
|
||||
if (in_array('address', $with)) {
|
||||
$mainFields = array_merge($mainFields, $addressFields);
|
||||
}
|
||||
|
||||
if ($customs && in_array('custom_fields', $with)) {
|
||||
$mainFields = array_merge($mainFields, $customs);
|
||||
}
|
||||
|
||||
if (in_array('custom_fields_only', $with)) {
|
||||
return $customs;
|
||||
}
|
||||
|
||||
return $mainFields;
|
||||
|
||||
}
|
||||
|
||||
public function getMergedData($remoteData, $fieldMaps)
|
||||
{
|
||||
$formattedData = [];
|
||||
foreach ($fieldMaps as $fieldMap) {
|
||||
if ($fieldMap['will_skip'] == 'yes' || empty($fieldMap['fluentcrm_field']) || empty($fieldMap['remote_tag'])) {
|
||||
continue;
|
||||
}
|
||||
$tagName = $fieldMap['remote_tag'];
|
||||
if (empty($remoteData[$tagName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fluentCrmTag = $fieldMap['fluentcrm_field'];
|
||||
|
||||
if (!empty($fieldMap['date_format'])) {
|
||||
$givenValue = $remoteData[$tagName];
|
||||
$myDateTime = \DateTime::createFromFormat($fieldMap['date_format'], $givenValue);
|
||||
$formattedData[$fluentCrmTag] = $myDateTime->format('Y-m-d');
|
||||
} else {
|
||||
$formattedData[$fluentCrmTag] = $remoteData[$tagName];
|
||||
}
|
||||
}
|
||||
|
||||
$formattedData = array_filter($formattedData);
|
||||
|
||||
$customFields = fluentcrm_get_custom_contact_fields();
|
||||
|
||||
if (!$customFields) {
|
||||
return $formattedData;
|
||||
}
|
||||
|
||||
$keys = array_map(function ($item) {
|
||||
return $item['slug'];
|
||||
}, $customFields);
|
||||
|
||||
$customs = array_filter(Arr::only($formattedData, $keys));
|
||||
|
||||
foreach ($keys as $key) {
|
||||
unset($formattedData[$key]);
|
||||
}
|
||||
|
||||
$formattedData['custom_values'] = $customs;
|
||||
|
||||
return $formattedData;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe create field options.
|
||||
*
|
||||
* When importing multi-checkbox or multi-select data, we can save time by
|
||||
* auto-populating the options into the FluentCRM custom field config.
|
||||
*
|
||||
* @param array $mergeData The loaded custom field data.
|
||||
* @since 2.8.34
|
||||
*
|
||||
*/
|
||||
public function maybeCreateFieldOptions($mergeData)
|
||||
{
|
||||
|
||||
$needsUpdate = false;
|
||||
$customFields = fluentcrm_get_custom_contact_fields();
|
||||
|
||||
foreach ($mergeData['custom_values'] as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
foreach ($customFields as $i => $field) {
|
||||
if ($key === $field['slug'] && in_array($field['type'], ['checkbox', 'select-multi']) && !empty(array_diff($value, $field['options']))) {
|
||||
$value = array_map('sanitize_text_field', $value);
|
||||
$customFields[$i]['options'] = array_merge($field['options'], array_diff($value, $field['options']));
|
||||
$needsUpdate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($needsUpdate) {
|
||||
fluentcrm_update_option('contact_custom_fields', $customFields);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function mapTags($tagMappings)
|
||||
{
|
||||
$formattedMaps = [];
|
||||
|
||||
foreach ($tagMappings as $tagMapping) {
|
||||
$remoteId = $tagMapping['remote_id'];
|
||||
if ($tagMapping['will_create'] == 'yes') {
|
||||
$remoteName = sanitize_text_field($tagMapping['remote_name']);
|
||||
if (!$remoteName) {
|
||||
continue;
|
||||
}
|
||||
$tagMapping['fluentcrm_id'] = $this->getTagId($remoteName);
|
||||
}
|
||||
|
||||
if (empty($tagMapping['fluentcrm_id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$formattedMaps[$remoteId] = (int)$tagMapping['fluentcrm_id'];
|
||||
}
|
||||
|
||||
return $formattedMaps;
|
||||
|
||||
}
|
||||
|
||||
public function getTagId($tagName)
|
||||
{
|
||||
$slug = sanitize_title($tagName);
|
||||
|
||||
$tag = Tag::updateOrCreate(
|
||||
['slug' => sanitize_title($slug, 'display')],
|
||||
['title' => $tagName]
|
||||
);
|
||||
|
||||
do_action('fluentcrm_tag_created', $tag->id);
|
||||
|
||||
do_action('fluent_crm/tag_created', $tag);
|
||||
|
||||
return $tag->id;
|
||||
}
|
||||
|
||||
public function getListId($listName)
|
||||
{
|
||||
$slug = sanitize_title($listName);
|
||||
|
||||
$list = Lists::updateOrCreate(
|
||||
['slug' => sanitize_title($slug, 'display')],
|
||||
['title' => $listName]
|
||||
);
|
||||
|
||||
do_action('fluentcrm_list_created', $list->id);
|
||||
do_action('fluent_crm/list_created', $list);
|
||||
|
||||
return $list->id;
|
||||
}
|
||||
|
||||
public function getInfoIcon()
|
||||
{
|
||||
$infoSvg = '<span class="fc-inline-help-icon" aria-hidden="true" style="display:inline-flex;vertical-align:middle">'
|
||||
. '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">'
|
||||
. '<path d="M8 1.333a6.667 6.667 0 1 0 0 13.334A6.667 6.667 0 0 0 8 1.333zm0 2a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm1.333 9.334H6.667v-1.333h1.333V7.333H6.667V6h2.666v5.333h1.333v1.334z"/>'
|
||||
. '</svg></span>';
|
||||
|
||||
return $infoSvg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\CrmMigrator;
|
||||
|
||||
use FluentCrm\App\Services\CrmMigrator\Api\ConvertKit;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class ConvertKitMigrator extends BaseMigrator
|
||||
{
|
||||
public function getInfo()
|
||||
{
|
||||
$infoSvg = $this->getInfoIcon();
|
||||
|
||||
$logo = $this->getSvgLogo();
|
||||
|
||||
return [
|
||||
'title' => 'Kit',
|
||||
'formerly' => 'ConvertKit',
|
||||
'description' => __('Migrate your ConvertKit contacts and associate to FluentCRM', 'fluent-crm'),
|
||||
'logo' => fluentCrmMix('images/migrators/convertkit.png'),
|
||||
'logo_svg' => $logo,
|
||||
'supports' => [
|
||||
'tags' => true,
|
||||
'lists' => false,
|
||||
'empty_tags' => false,
|
||||
'active_imports_only' => false
|
||||
],
|
||||
'field_map_info' => __('Email Address and First name will be mapped automatically', 'fluent-crm'),
|
||||
'tags_map_info' => __('Only Selected tags will be imported from ConvertKit', 'fluent-crm'),
|
||||
'credentials' => [
|
||||
'api_key' => '',
|
||||
'api_secret' => ''
|
||||
],
|
||||
'credential_fields' => [
|
||||
'api_key' => [
|
||||
'label' => __('API Key', 'fluent-crm'),
|
||||
'placeholder' => __('ConvertKit API Key', 'fluent-crm'),
|
||||
'data_type' => 'text',
|
||||
'type' => 'input-text',
|
||||
'inline_help' => $infoSvg . ' ' . __('You can find your API key at ConvertKit ', 'fluent-crm') . '<a href="https://app.convertkit.com/account_settings/advanced_settings" rel="noopener" target="_blank">'. __("Account -> Settings -> Advanced", "fluent-crm") .'</a>'
|
||||
],
|
||||
'api_secret' => [
|
||||
'label' => __('API Secret', 'fluent-crm'),
|
||||
'placeholder' => __('ConvertKit API Secret', 'fluent-crm'),
|
||||
'data_type' => 'password',
|
||||
'type' => 'input-text',
|
||||
'inline_help' => $infoSvg . ' ' . __('You can find your API Secret key at ConvertKit Account -> Settings -> Advanced', 'fluent-crm')
|
||||
]
|
||||
],
|
||||
'refresh_on_list_change' => false,
|
||||
'doc_url' => 'https://fluentcrm.com/docs/migrating-into-fluentcrm-from-convertkit/'
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyCredentials($credential)
|
||||
{
|
||||
$api = $this->getApi($credential);
|
||||
|
||||
try {
|
||||
$result = $api->auth_test();
|
||||
if (!empty($result['error'])) {
|
||||
throw new \Exception($result['message']);
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
return new \WP_Error('api_error', $exception->getMessage());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getListTagMappings($postedData)
|
||||
{
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
$tags = $api->getTags();
|
||||
|
||||
$formattedTags = [];
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
$formattedTags[] = [
|
||||
'remote_name' => $tag['name'],
|
||||
'remote_id' => (string)$tag['id'],
|
||||
'will_create' => 'no',
|
||||
'fluentcrm_id' => ''
|
||||
];
|
||||
}
|
||||
|
||||
$data['tags'] = $formattedTags;
|
||||
|
||||
$contactFields = $api->getCustomFields();
|
||||
$formattedContactFields = [];
|
||||
|
||||
foreach ($contactFields as $field) {
|
||||
$item = [
|
||||
'type' => 'any',
|
||||
'remote_label' => $field['label'],
|
||||
'remote_tag' => $field['key'],
|
||||
'fluentcrm_field' => '',
|
||||
'will_skip' => 'no'
|
||||
];
|
||||
|
||||
$fieldKey = $field['key'];
|
||||
|
||||
if ($fieldKey == 'last_name') {
|
||||
$item['fluentcrm_field'] = 'last_name';
|
||||
}
|
||||
|
||||
$formattedContactFields[] = $item;
|
||||
}
|
||||
|
||||
$data['contact_fields'] = $formattedContactFields;
|
||||
$data['contact_fillables'] = $this->getFillables();
|
||||
|
||||
unset($data['contact_fillables']['first_name']);
|
||||
unset($data['contact_fillables']['full_name']);
|
||||
|
||||
$data['all_ready'] = true;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getSummary($postedData)
|
||||
{
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
|
||||
$subscribers = $api->getSubscribers([
|
||||
'page' => 1
|
||||
]);
|
||||
|
||||
if (is_wp_error($subscribers)) {
|
||||
return $subscribers;
|
||||
}
|
||||
|
||||
$totalSubscribers = Arr::get($subscribers, 'total_subscribers', 0);
|
||||
|
||||
|
||||
$tagMappings = Arr::get($postedData, 'tags', []);
|
||||
|
||||
$tagCounts = 0;
|
||||
|
||||
foreach ($tagMappings as $tagMapping) {
|
||||
if ($tagMapping['will_create'] == 'yes') {
|
||||
$tagCounts++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($tagMapping['will_create'] == 'no' || empty($tagMapping['fluentcrm_id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tagCounts++;
|
||||
|
||||
}
|
||||
|
||||
$message = __('Based on your selections, ', 'fluent-crm') . $tagCounts . __(' tags and associate contacts will be imported from ConvertKit', 'fluent-crm');
|
||||
|
||||
|
||||
return [
|
||||
'subscribed_count' => $totalSubscribers,
|
||||
'unsubscribed_count' => 0,
|
||||
'all_count' => $totalSubscribers,
|
||||
'message' => $message
|
||||
];
|
||||
}
|
||||
|
||||
public function runImport($postedData)
|
||||
{
|
||||
if (!defined('FLUENTCRM_DISABLE_TAG_LIST_EVENTS')) {
|
||||
define('FLUENTCRM_DISABLE_TAG_LIST_EVENTS', true);
|
||||
}
|
||||
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
|
||||
$tagMappings = Arr::get($postedData, 'tags', []);
|
||||
|
||||
$taggingArray = $this->mapTags($tagMappings);
|
||||
|
||||
$taggingKeys = array_keys($taggingArray);
|
||||
|
||||
if (!$taggingKeys) {
|
||||
return new \WP_Error('not_found', 'No Tag found based on your selection');
|
||||
}
|
||||
|
||||
$import_tracker = Arr::get($postedData, 'import_tracker', []);
|
||||
|
||||
if (empty($import_tracker)) {
|
||||
$import_tracker = [
|
||||
'current_index' => 0,
|
||||
'completed_page' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$currentTagId = $taggingKeys[$import_tracker['current_index']];
|
||||
$completedPage = $import_tracker['completed_page'];
|
||||
|
||||
$members = $api->getTagSubscribers($currentTagId, [
|
||||
'page' => $completedPage + 1
|
||||
]);
|
||||
|
||||
$subscribers = $members['subscriptions'];
|
||||
|
||||
$fieldMaps = Arr::get($postedData, 'contact_fields');
|
||||
|
||||
$mapSettings = Arr::get($postedData, 'map_settings', []);
|
||||
|
||||
foreach ($subscribers as $subscriberItem) {
|
||||
|
||||
$subscriber = $subscriberItem['subscriber'];
|
||||
if ($subscriber['state'] != 'active') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'email' => $subscriber['email_address'],
|
||||
'first_name' => $subscriber['first_name'],
|
||||
'created_at' => gmdate('Y-m-d H:i:s', strtotime($subscriber['created_at'])),
|
||||
'source' => 'ConvertKit',
|
||||
'status' => 'subscribed'
|
||||
];
|
||||
|
||||
$mergeData = $this->getMergedData($subscriber['fields'], $fieldMaps);
|
||||
|
||||
if ($mergeData) {
|
||||
$data = array_merge($data, $mergeData);
|
||||
}
|
||||
|
||||
if (!empty($mapSettings['local_list_id'])) {
|
||||
$data['lists'] = [$mapSettings['local_list_id']];
|
||||
}
|
||||
|
||||
$data['tags'] = [$taggingArray[$currentTagId]];
|
||||
|
||||
$contact = FluentCrmApi('contacts')->createOrUpdate($data);
|
||||
|
||||
if ($contact && $contact->status != 'subscribed') {
|
||||
$contact->updateStatus('subscribed');
|
||||
}
|
||||
}
|
||||
|
||||
$stepCompleted = ($completedPage + 1) >= $members['total_pages'];
|
||||
|
||||
if (!$stepCompleted) {
|
||||
$import_tracker = [
|
||||
'current_index' => $import_tracker['current_index'],
|
||||
'completed_page' => $completedPage + 1
|
||||
];
|
||||
} else {
|
||||
$nextIndex = $import_tracker['current_index'] + 1;
|
||||
$import_tracker = [
|
||||
'current_index' => $nextIndex,
|
||||
'completed_page' => 0
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'completed' => 0,
|
||||
'total' => 0,
|
||||
'import_tracker' => $import_tracker,
|
||||
'has_more' => isset($taggingKeys[$import_tracker['current_index']]),
|
||||
'message' => __('Importer is running now. ', 'fluent-crm').( $import_tracker['current_index']+1 ) .__(' tags have been imported so far', 'fluent-crm')
|
||||
];
|
||||
}
|
||||
|
||||
private function getApi($credential)
|
||||
{
|
||||
return new ConvertKit($credential['api_key'], $credential['api_secret']);
|
||||
}
|
||||
|
||||
public function getSvgLogo()
|
||||
{
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" width="71" height="32" viewBox="0 0 71 32" fill="none">
|
||||
<g clip-path="url(#clip0_6972_60083)">
|
||||
<mask id="mask0_6972_60083" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="71" height="32">
|
||||
<path d="M70.9328 0H0V32H70.9328V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_6972_60083)">
|
||||
<mask id="mask1_6972_60083" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="71" height="32">
|
||||
<path d="M70.961 0H0V32H70.961V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_6972_60083)">
|
||||
<path d="M19.436 13.3155C28.1679 15.0036 30.8781 23.0778 30.9496 31.1988C30.9512 31.3815 30.8035 31.5305 30.6206 31.5305H19.6288C19.4475 31.5305 19.2998 31.3843 19.2988 31.2028C19.2658 24.9024 18.2438 19.3425 11.9939 19.1024C11.8074 19.0954 11.652 19.2445 11.652 19.431V31.202C11.652 31.3834 11.5047 31.5305 11.323 31.5305H0.329015C0.147368 31.5305 0 31.3837 0 31.202V1.20928C0 1.02784 0.147368 0.88064 0.329015 0.88064H11.323C11.5047 0.88064 11.652 1.02784 11.652 1.20928V12.4445C11.652 12.6112 11.7872 12.7463 11.9541 12.7463C12.0864 12.7463 12.204 12.6601 12.2425 12.5335C15.0744 3.26849 20.3634 0.93888 28.9517 0.8816C29.1341 0.88032 29.2833 1.02816 29.2833 1.21024V12.4176C29.2833 12.599 29.136 12.7463 28.9543 12.7463H19.491C19.3322 12.7463 19.2033 12.8749 19.2033 13.0336C19.2033 13.1712 19.301 13.2896 19.436 13.3155ZM49.5337 20.3389V13.0749C49.5337 12.8934 49.681 12.7463 49.8627 12.7463H57.9529C58.1121 12.7463 58.2412 12.6173 58.2412 12.4583C58.2412 12.3203 58.1429 12.2022 58.0074 12.1757C51.6775 10.9216 48.7555 7.28192 48.6546 1.2096C48.6518 1.02912 48.7968 0.88064 48.9772 0.88064H60.8567C61.0384 0.88064 61.1857 1.02784 61.1857 1.20928V6.32481C61.1857 6.50624 61.333 6.65344 61.5148 6.65344H68.3263C68.508 6.65344 68.6553 6.80064 68.6553 6.98208V12.4176C68.6553 12.599 68.508 12.7463 68.3263 12.7463H61.5148C61.333 12.7463 61.1857 12.8934 61.1857 13.0749V18.9341C61.1857 21.0041 62.4563 21.6867 64.146 21.6867C66.7937 21.6867 69.4056 20.4951 70.4532 19.9549C70.6724 19.842 70.9329 20.001 70.9329 20.247V29.3683C70.9329 29.6119 70.7983 29.8359 70.5823 29.9494C69.5483 30.4928 66.3509 31.9994 62.6933 31.9994C55.1711 32 49.5337 28.937 49.5337 20.3389ZM34.0697 31.202V13.0743C34.0697 12.8928 34.217 12.7456 34.3987 12.7456H45.3927C45.5743 12.7456 45.7217 12.8928 45.7217 13.0743V31.202C45.7217 31.3834 45.5743 31.5305 45.3927 31.5305H34.3987C34.217 31.5305 34.0697 31.3837 34.0697 31.202ZM33.4471 5.58688C33.4471 8.67232 35.6286 11.1738 39.8196 11.1738C44.0105 11.1738 46.192 8.67232 46.192 5.58688C46.192 2.50144 44.0103 0 39.8196 0C35.6286 0 33.4471 2.50144 33.4471 5.58688Z" fill="#1E1E1E"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6972_60083">
|
||||
<rect width="71" height="32" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\CrmMigrator;
|
||||
|
||||
use FluentCrm\App\Services\CrmMigrator\Api\Drip;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class DripMigrator extends BaseMigrator
|
||||
{
|
||||
public function getInfo()
|
||||
{
|
||||
$infoSvg = $this->getInfoIcon();
|
||||
|
||||
$logo = $this->getSvgLogo();
|
||||
|
||||
return [
|
||||
'title' => 'Drip',
|
||||
'description' => __('Transfer your Drip tags and contacts to FluentCRM', 'fluent-crm'),
|
||||
'logo' => fluentCrmMix('images/migrators/drip.png'),
|
||||
'logo_svg' => $logo,
|
||||
'supports' => [
|
||||
'tags' => true,
|
||||
'lists' => false,
|
||||
'empty_tags' => true,
|
||||
'active_imports_only' => true
|
||||
],
|
||||
'credentials' => [
|
||||
'api_key' => '',
|
||||
'account_id' => ''
|
||||
],
|
||||
'field_map_info' => __('Email and main contact fields will be mapped automatically', 'fluent-crm'),
|
||||
'credential_fields' => [
|
||||
'api_key' => [
|
||||
'label' => __('API Token', 'fluent-crm'),
|
||||
'placeholder' => __('Drip API Token', 'fluent-crm'),
|
||||
'data_type' => 'password',
|
||||
'type' => 'input-text',
|
||||
'inline_help' => $infoSvg . ' ' . __('You can find your API key at Drip Profile -> User Info -> API Token', 'fluent-crm')
|
||||
],
|
||||
'account_id' => [
|
||||
'label' => __('Account ID', 'fluent-crm'),
|
||||
'placeholder' => __('Drip Account ID', 'fluent-crm'),
|
||||
'data_type' => 'text',
|
||||
'type' => 'input-text',
|
||||
'inline_help' => $infoSvg . ' ' . __('You can find Account ID Settings -> General Info -> Account ID', 'fluent-crm')
|
||||
]
|
||||
],
|
||||
'refresh_on_list_change' => false,
|
||||
'doc_url' => 'https://fluentcrm.com/docs/migrating-into-fluentcrm-from-drip/'
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyCredentials($credential)
|
||||
{
|
||||
$api = $this->getApi($credential);
|
||||
|
||||
try {
|
||||
$result = $api->make_request('accounts', [], 'GET');
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
return new \WP_Error('api_error', $exception->getMessage());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getListTagMappings($postedData)
|
||||
{
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
|
||||
$tags = $api->sendAccountItems('tags');
|
||||
|
||||
$data = [];
|
||||
|
||||
if (is_wp_error($tags)) {
|
||||
return $tags;
|
||||
}
|
||||
|
||||
$formattedTags = [];
|
||||
|
||||
foreach ($tags['tags'] as $tag) {
|
||||
$formattedTags[] = [
|
||||
'remote_name' => (string)$tag,
|
||||
'remote_id' => (string)$tag,
|
||||
'will_create' => 'no',
|
||||
'fluentcrm_id' => ''
|
||||
];
|
||||
}
|
||||
$data['tags'] = $formattedTags;
|
||||
|
||||
$mergeFields = $api->sendAccountItems('custom_field_identifiers');
|
||||
|
||||
$contactFields = $mergeFields['custom_field_identifiers'];
|
||||
$formattedContactFields = [];
|
||||
|
||||
$defaultFields = [
|
||||
'First_Name',
|
||||
'Last_Name',
|
||||
'address1',
|
||||
'address2',
|
||||
'city',
|
||||
'phone',
|
||||
'state',
|
||||
'zip',
|
||||
'country'
|
||||
];
|
||||
|
||||
$contactFields = array_values(array_diff($contactFields, $defaultFields));
|
||||
|
||||
foreach ($contactFields as $field) {
|
||||
$item = [
|
||||
'type' => 'any',
|
||||
'remote_label' => $field,
|
||||
'remote_tag' => $field,
|
||||
'fluentcrm_field' => '',
|
||||
'remote_type' => '',
|
||||
'will_skip' => 'no'
|
||||
];
|
||||
|
||||
$formattedContactFields[] = $item;
|
||||
}
|
||||
|
||||
$data['contact_fields'] = $formattedContactFields;
|
||||
$data['contact_fillables'] = $this->getFillables();
|
||||
|
||||
$data['all_ready'] = true;
|
||||
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getSummary($postedData)
|
||||
{
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
|
||||
$status = 'all';
|
||||
|
||||
$settings = Arr::get($postedData, 'map_settings', []);
|
||||
|
||||
if (Arr::get($settings, 'import_active_only') == 'yes') {
|
||||
$status = 'active';
|
||||
}
|
||||
|
||||
$members = $api->sendAccountItems('subscribers', [
|
||||
'status' => $status,
|
||||
'page' => 1,
|
||||
'per_page' => 10
|
||||
]);
|
||||
|
||||
if (is_wp_error($members)) {
|
||||
return $members;
|
||||
}
|
||||
|
||||
$meta = $members['meta'];
|
||||
|
||||
$message = __('Based on your selections ', 'fluent-crm') . $meta['total_count'] . __(' contacts will be imported', 'fluent-crm');
|
||||
|
||||
return [
|
||||
'subscribed_count' => $meta['total_count'],
|
||||
'unsubscribed_count' => 0,
|
||||
'all_count' => $meta['total_count'],
|
||||
'message' => $message
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
public function runImport($postedData)
|
||||
{
|
||||
if (!defined('FLUENTCRM_DISABLE_TAG_LIST_EVENTS')) {
|
||||
define('FLUENTCRM_DISABLE_TAG_LIST_EVENTS', true);
|
||||
}
|
||||
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
|
||||
$processPerPage = 10;
|
||||
|
||||
$page = Arr::get($postedData, 'completed', 0);
|
||||
|
||||
if (!$page) {
|
||||
$page = 1;
|
||||
}
|
||||
|
||||
$params = [
|
||||
'page' => $page,
|
||||
'per_page' => $processPerPage,
|
||||
'status' => 'all'
|
||||
];
|
||||
|
||||
$tagMappings = Arr::get($postedData, 'tags', []);
|
||||
|
||||
$taggingArray = $this->mapTags($tagMappings);
|
||||
|
||||
$mapSettings = Arr::get($postedData, 'map_settings', []);
|
||||
|
||||
if ($mapSettings['import_active_only'] == 'yes') {
|
||||
$params['status'] = 'subscribed';
|
||||
}
|
||||
|
||||
$members = $api->sendAccountItems('subscribers', $params);
|
||||
|
||||
if (is_wp_error($members)) {
|
||||
return $members;
|
||||
}
|
||||
|
||||
$memberMeta = $members['meta'];
|
||||
|
||||
$subscribers = $members['subscribers'];
|
||||
|
||||
$fieldMaps = Arr::get($postedData, 'contact_fields', []);
|
||||
|
||||
foreach ($subscribers as $subscriber) {
|
||||
|
||||
$statusMaps = [
|
||||
'active' => 'subscribed',
|
||||
'unsubscribed' => 'unsubscribed'
|
||||
];
|
||||
$status = (isset($statusMaps[$subscriber['status']])) ? $statusMaps[$subscriber['status']] : 'pending';
|
||||
if ($mapSettings['import_active_only'] == 'yes') {
|
||||
$status = 'subscribed';
|
||||
}
|
||||
|
||||
$data = [
|
||||
'email' => $subscriber['email'],
|
||||
'first_name' => $subscriber['first_name'],
|
||||
'last_name' => $subscriber['last_name'],
|
||||
'address_line_1' => $subscriber['address1'],
|
||||
'address_line_2' => $subscriber['address2'],
|
||||
'city' => $subscriber['city'],
|
||||
'state' => $subscriber['state'],
|
||||
'postal_code' => $subscriber['zip'],
|
||||
'phone' => $subscriber['phone'],
|
||||
'created_at' => gmdate('Y-m-d H:i:s', strtotime($subscriber['created_at'])),
|
||||
'source' => 'Drip',
|
||||
'ip' => $subscriber['ip_address'],
|
||||
'country' => Arr::get($subscriber, 'country'),
|
||||
'status' => $status
|
||||
];
|
||||
|
||||
$mergeData = $this->getMergedData($subscriber['custom_fields'], $fieldMaps);
|
||||
|
||||
if ($mergeData) {
|
||||
$data = array_merge($data, $mergeData);
|
||||
}
|
||||
|
||||
if (!empty($mapSettings['local_list_id'])) {
|
||||
$data['lists'] = [$mapSettings['local_list_id']];
|
||||
}
|
||||
|
||||
if (!empty($subscriber['tags'])) {
|
||||
$tagIds = [];
|
||||
foreach ($subscriber['tags'] as $contactTag) {
|
||||
if (!empty($taggingArray[$contactTag])) {
|
||||
$tagIds[] = $taggingArray[$contactTag];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($tagIds) && !empty($mapSettings['local_tag_id'])) {
|
||||
$tagIds = [$mapSettings['local_tag_id']];
|
||||
}
|
||||
|
||||
$data['tags'] = $tagIds;
|
||||
|
||||
} else if (!empty($mapSettings['local_tag_id'])) {
|
||||
$data['tags'] = [$mapSettings['local_tag_id']];
|
||||
}
|
||||
|
||||
$contact = FluentCrmApi('contacts')->createOrUpdate($data);
|
||||
|
||||
if ($status == 'subscribed' && $contact && $contact->status != 'subscribed') {
|
||||
$contact->updateStatus('subscribed');
|
||||
}
|
||||
}
|
||||
|
||||
$completed = $memberMeta['page'] + 1;
|
||||
|
||||
return [
|
||||
'completed' => $completed,
|
||||
'total' => $memberMeta['total_pages'],
|
||||
'has_more' => $memberMeta['page'] < $memberMeta['total_pages']
|
||||
];
|
||||
}
|
||||
|
||||
private function getApi($credentials)
|
||||
{
|
||||
return new Drip($credentials['api_key'], $credentials['account_id']);
|
||||
}
|
||||
|
||||
public function getSvgLogo()
|
||||
{
|
||||
return '<svg class="logo nav__logo" height="30" viewBox="0 0 84 30" width="84" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Drip</title>
|
||||
<g class="logo__logomark nav__logo-logomark" id="logomark">
|
||||
<path d="M18 14.5L15.3 14.5C15.3 14.6 15.3 14.7 15.3 14.8 15.3 18.8 12 21.2 9 21.2 6 21.2 2.7 18.8 2.7 14.8 2.7 14.7 2.7 14.6 2.7 14.5L0 14.5C0 14.6 0 14.7 0 14.8 0 20.3 4.5 23.9 9 23.9 13.5 23.9 18 20.3 18 14.8 18 14.7 18 14.6 18 14.5Z" id="logo-mouth"></path>
|
||||
<path d="M9 3.4L10.9 6 14.3 6C12.7 3.9 11 1.8 9 0 7 1.8 5.3 3.9 3.7 6L7.1 6 9 3.4Z" id="logo-hat"></path>
|
||||
<g id="logo-eyes">
|
||||
<ellipse cx="3.6" cy="10.3" id="logo-eye-left" rx="1.8" ry="1.8"></ellipse>
|
||||
<ellipse cx="9" cy="10.3" id="logo-eye-center" rx="1.8" ry="1.8"></ellipse>
|
||||
<ellipse cx="14.4" cy="10.3" id="logo-eye-right" rx="1.8" ry="1.8"></ellipse>
|
||||
</g>
|
||||
</g>
|
||||
<g class="logo__wordmark nav__logo-wordmark" id="wordmark">
|
||||
<path d="M40.8 23.5L37.6 23.5 37.6 20.9C35.9 23 33.6 24 30.9 24 28.2 24 25.8 23.1 24.2 21.5 22.6 19.9 21.6 17.6 21.6 14.9 21.6 12.1 22.5 9.9 24.2 8.3 25.9 6.7 28.2 5.8 30.9 5.8 33.8 5.8 35.8 6.7 37.6 8.7L37.6 0 40.8 0 40.8 23.5ZM31.2 8.6C27.3 8.6 24.9 10.9 24.9 14.9 24.9 18.8 27.3 21.1 31.2 21.1 35.2 21.1 37.6 18.8 37.6 14.9 37.6 11 35.2 8.6 31.2 8.6Z" id="wordmark-d"></path>
|
||||
<polygon id="wordmark-r" points="55.2 8.9 47.5 8.9 47.5 24 44.4 24 44.4 6 55.2 6"></polygon>
|
||||
<path d="M57.6 1.9C57.6 1.2 57.9 0.6 58.5 0.3 59.1-0.1 59.7-0.1 60.3 0.3 60.9 0.6 61.2 1.2 61.2 1.9 61.2 2.6 60.9 3.3 60.3 3.6 59.7 4 59.1 4 58.5 3.6 57.9 3.3 57.6 2.6 57.6 1.9ZM57.9 6.2L60.9 6.2 60.9 24 57.9 24 57.9 6.2Z" id="wordmark-i"></path>
|
||||
<path d="M64.8 6.4L68 6.4 68 9C69.6 7.1 71.7 6 74.7 6 77.4 6 79.7 6.9 81.4 8.5 83.1 10.1 84 12.4 84 15.1 84 17.9 83.1 20.1 81.4 21.7 79.7 23.3 77.4 24.2 74.6 24.2 71.8 24.2 69.8 23.3 68 21.3L68 30 64.8 30 64.8 6.4ZM74.3 21.4C78.3 21.4 80.7 19.1 80.7 15.1 80.7 11.2 78.3 8.9 74.3 8.9 70.4 8.9 68 11.2 68 15.1 68 19 70.4 21.4 74.3 21.4Z" id="wordmark-p"></path>
|
||||
</g>
|
||||
</svg>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\CrmMigrator;
|
||||
|
||||
use FluentCrm\App\Models\Tag;
|
||||
use FluentCrm\App\Services\CrmMigrator\Api\MailChimp;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class MailChimpMigrator extends BaseMigrator
|
||||
{
|
||||
private $tagNameCache = [];
|
||||
private $taggingArray = [];
|
||||
|
||||
public function getInfo()
|
||||
{
|
||||
$infoSvg = $this->getInfoIcon();
|
||||
|
||||
$logo = $this->getSvgLogo();
|
||||
|
||||
return [
|
||||
'title' => 'MailChimp',
|
||||
'description' => __('Transfer your mailchimp lists, tags and contacts from MailChimp to FluentCRM', 'fluent-crm'),
|
||||
'logo' => fluentCrmMix('images/migrators/mailchimp.png'),
|
||||
'logo_svg' => $logo,
|
||||
'supports' => [
|
||||
'tags' => true,
|
||||
'lists' => true,
|
||||
'empty_tags' => true,
|
||||
'active_imports_only' => true,
|
||||
'auto_tag_mapper' => true
|
||||
],
|
||||
'credentials' => [
|
||||
'api_key' => ''
|
||||
],
|
||||
'credential_fields' => [
|
||||
'api_key' => [
|
||||
'label' => __('API Key', 'fluent-crm'),
|
||||
'placeholder' => __('MailChimp API Key', 'fluent-crm'),
|
||||
'data_type' => 'password',
|
||||
'type' => 'input-text',
|
||||
'inline_help' => $infoSvg . ' ' . __('You can find your API key at MailChimp Account -> Extras -> API keys', 'fluent-crm')
|
||||
]
|
||||
],
|
||||
'refresh_on_list_change' => true,
|
||||
'doc_url' => 'https://fluentcrm.com/docs/migrating-into-fluentcrm-from-mailchimp/'
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyCredentials($credential)
|
||||
{
|
||||
$api = $this->getApi($credential);
|
||||
try {
|
||||
$result = $api->get('lists');
|
||||
if (!$api->success()) {
|
||||
throw new \Exception($api->getLastError());
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
return new \WP_Error('api_error', $exception->getMessage());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getListTagMappings($postedData)
|
||||
{
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
$lists = $api->get('lists', ['count' => 9999]);
|
||||
$formattedLists = [];
|
||||
|
||||
foreach ($lists['lists'] as $list) {
|
||||
$formattedLists[] = [
|
||||
'id' => $list['id'],
|
||||
'name' => $list['name'],
|
||||
'description' => __('(Contacts count ', 'fluent-crm') . Arr::get($list, 'stats.member_count') . ')'
|
||||
];
|
||||
}
|
||||
|
||||
$data = [
|
||||
'lists' => $formattedLists,
|
||||
'tags' => [],
|
||||
'contact_fields' => [],
|
||||
'contact_fillables' => []
|
||||
];
|
||||
|
||||
$settings = Arr::get($postedData, 'map_settings', []);
|
||||
|
||||
if (!empty($settings['list_id'])) {
|
||||
$tags = $api->get('lists/' . $settings['list_id'] . '/tag-search?count=9999');
|
||||
$formattedTags = [];
|
||||
|
||||
foreach ($tags['tags'] as $tag) {
|
||||
$formattedTags[] = [
|
||||
'remote_name' => (string)$tag['name'],
|
||||
'remote_id' => (string)$tag['id'],
|
||||
'will_create' => 'no',
|
||||
'fluentcrm_id' => ''
|
||||
];
|
||||
}
|
||||
$data['tags'] = $formattedTags;
|
||||
$mergeFields = $api->get('lists/' . $settings['list_id'] . '/merge-fields', array('count' => 9999));
|
||||
|
||||
$contactFields = $mergeFields['merge_fields'];
|
||||
|
||||
$mcFieldTypes = ['text', 'radio', 'phone', 'imageurl', 'url', 'dropdown', 'date', 'number', 'address'];
|
||||
$formattedContactFields = [];
|
||||
|
||||
$addressMapped = false;
|
||||
foreach ($contactFields as $field) {
|
||||
$fieldType = $field['type'];
|
||||
|
||||
if (!in_array($fieldType, $mcFieldTypes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = [
|
||||
'type' => 'any',
|
||||
'remote_label' => $field['name'],
|
||||
'remote_tag' => $field['tag'],
|
||||
'fluentcrm_field' => '',
|
||||
'remote_type' => $fieldType,
|
||||
'will_skip' => 'no'
|
||||
];
|
||||
|
||||
if ($fieldType == 'address') {
|
||||
$item['type'] = 'selections';
|
||||
$item['options'] = ['contact_address' => 'Contact Address'];
|
||||
|
||||
if (!$addressMapped) {
|
||||
$addressMapped = true;
|
||||
$item['fluentcrm_field'] = 'contact_address';
|
||||
}
|
||||
} else if ($fieldType == 'date') {
|
||||
$item['date_format'] = $field['options']['date_format'];
|
||||
}
|
||||
|
||||
if ($item['remote_tag'] == 'FNAME') {
|
||||
$item['fluentcrm_field'] = 'full_name';
|
||||
} else if ($item['remote_label'] == 'First Name') {
|
||||
$item['fluentcrm_field'] = 'first_name';
|
||||
} else if ($item['remote_label'] == 'Last Name') {
|
||||
$item['fluentcrm_field'] = 'last_name';
|
||||
}
|
||||
|
||||
$formattedContactFields[] = $item;
|
||||
}
|
||||
|
||||
$data['contact_fields'] = $formattedContactFields;
|
||||
$data['contact_fillables'] = $this->getFillables();
|
||||
|
||||
$data['all_ready'] = true;
|
||||
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getSummary($postedData)
|
||||
{
|
||||
$settings = Arr::get($postedData, 'map_settings', []);
|
||||
$listId = Arr::get($settings, 'list_id');
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
|
||||
$list = $api->get('lists/' . $listId);
|
||||
|
||||
$membersCount = Arr::get($list, 'stats.member_count', 0);
|
||||
$allCount = Arr::get($list, 'stats.unsubscribe_count') + $membersCount;
|
||||
|
||||
$count = $allCount;
|
||||
|
||||
if (Arr::get($settings, 'import_active_only') == 'yes') {
|
||||
$count = $membersCount;
|
||||
}
|
||||
|
||||
$message = __('Based on your selections ', 'fluent-crm') . $count . __(' contacts will be imported', 'fluent-crm');
|
||||
|
||||
return [
|
||||
'subscribed_count' => $membersCount,
|
||||
'unsubscribed_count' => Arr::get($list, 'stats.unsubscribe_count', 0),
|
||||
'all_count' => $allCount,
|
||||
'message' => $message
|
||||
];
|
||||
}
|
||||
|
||||
public function runImport($postedData)
|
||||
{
|
||||
|
||||
$isAutoTagMapping = Arr::get($postedData, 'auto_mapping') == 'yes';
|
||||
|
||||
if (!defined('FLUENTCRM_DISABLE_TAG_LIST_EVENTS')) {
|
||||
define('FLUENTCRM_DISABLE_TAG_LIST_EVENTS', true);
|
||||
}
|
||||
|
||||
$mapSettings = Arr::get($postedData, 'map_settings', []);
|
||||
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
|
||||
$processPerPage = 100;
|
||||
|
||||
$params = [
|
||||
'offset' => Arr::get($postedData, 'completed', 0),
|
||||
'count' => $processPerPage
|
||||
];
|
||||
|
||||
if(!$isAutoTagMapping) {
|
||||
$tagMappings = Arr::get($postedData, 'tags', []);
|
||||
$this->taggingArray = $this->mapTags($tagMappings);
|
||||
}
|
||||
|
||||
if ($mapSettings['import_active_only'] == 'yes') {
|
||||
$params['status'] = 'subscribed';
|
||||
}
|
||||
|
||||
$members = $api->get('lists/' . $mapSettings['list_id'] . '/members', $params);
|
||||
|
||||
$subscribers = $members['members'];
|
||||
|
||||
$fieldMaps = Arr::get($postedData, 'contact_fields', []);
|
||||
|
||||
foreach ($subscribers as $subscriber) {
|
||||
$created_at = gmdate('Y-m-d H:i:s');
|
||||
if (!empty($subscriber['timestamp_signup'])) {
|
||||
$created_at = gmdate('Y-m-d H:i:s', strtotime($subscriber['timestamp_signup']));
|
||||
}
|
||||
|
||||
$data = [
|
||||
'email' => $subscriber['email_address'],
|
||||
'full_name' => $subscriber['full_name'],
|
||||
'created_at' => $created_at,
|
||||
'source' => $subscriber['source'],
|
||||
'ip' => $subscriber['ip_signup'],
|
||||
'country' => Arr::get($subscriber, 'location.country_code'),
|
||||
'status' => $subscriber['status']
|
||||
];
|
||||
|
||||
$mergeData = $this->getMergedData($subscriber['merge_fields'], $fieldMaps);
|
||||
|
||||
if (!empty($mergeData['contact_address']) && is_array($mergeData['contact_address'])) {
|
||||
$address = $mergeData['contact_address'];
|
||||
unset($mergeData['contact_address']);
|
||||
|
||||
$address = array_filter($address);
|
||||
|
||||
$addressMappings = [
|
||||
'addr1' => 'address_line_1',
|
||||
'addr2' => 'address_line_2',
|
||||
'city' => 'city',
|
||||
'country' => 'country',
|
||||
'state' => 'state',
|
||||
'zip' => 'postal_code'
|
||||
];
|
||||
|
||||
foreach ($address as $key => $value) {
|
||||
if (isset($addressMappings[$key])) {
|
||||
$mergeData[$addressMappings[$key]] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($mergeData) {
|
||||
$data = array_merge($data, $mergeData);
|
||||
}
|
||||
|
||||
if (!empty($mapSettings['local_list_id'])) {
|
||||
$data['lists'] = [$mapSettings['local_list_id']];
|
||||
}
|
||||
|
||||
$data['tags'] = $this->formatRemoteTags($subscriber['tags'], $isAutoTagMapping, $mapSettings);
|
||||
|
||||
$contact = FluentCrmApi('contacts')->createOrUpdate($data);
|
||||
|
||||
if (isset($params['status']) && $params['status'] == 'subscribed' && $contact && $contact->status != 'subscribed') {
|
||||
$contact->updateStatus('subscribed');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$completed = Arr::get($postedData, 'completed', 0) + $processPerPage;
|
||||
|
||||
return [
|
||||
'completed' => $completed,
|
||||
'total' => $members['total_items'],
|
||||
'has_more' => $completed < $members['total_items']
|
||||
];
|
||||
}
|
||||
|
||||
private function formatRemoteTags($remoteTags, $isAuto, $mapSettings)
|
||||
{
|
||||
$tagIds = [];
|
||||
if($isAuto) {
|
||||
foreach ($remoteTags as $tag) {
|
||||
$tagName = sanitize_text_field($tag['name']);
|
||||
if(!empty($this->tagNameCache[$tagName])) {
|
||||
$tagIds[] = $this->tagNameCache[$tagName];
|
||||
} else {
|
||||
$createdTag = Tag::updateOrCreate(
|
||||
['slug' => sanitize_title($tagName, 'display')],
|
||||
['title' => sanitize_text_field($tagName)]
|
||||
);
|
||||
if($createdTag) {
|
||||
$this->tagNameCache[$createdTag->title] = $createdTag->id;
|
||||
$tagIds[] = $createdTag->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($tagIds) && !empty($mapSettings['local_tag_id'])) {
|
||||
$tagIds = [$mapSettings['local_tag_id']];
|
||||
}
|
||||
|
||||
return $tagIds;
|
||||
}
|
||||
|
||||
foreach ($remoteTags as $contactTag) {
|
||||
if (!empty($this->taggingArray[$contactTag['id']])) {
|
||||
$tagIds[] = $this->taggingArray[$contactTag['id']];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($tagIds) && !empty($mapSettings['local_tag_id'])) {
|
||||
$tagIds = [$mapSettings['local_tag_id']];
|
||||
}
|
||||
return $tagIds;
|
||||
}
|
||||
|
||||
private function getApi($credential)
|
||||
{
|
||||
return new MailChimp($credential['api_key']);
|
||||
}
|
||||
|
||||
public function getSvgLogo()
|
||||
{
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M16 2C8.27813 2 2 8.27812 2 16C2 23.7219 8.27813 30 16 30C23.7219 30 30 23.7219 30 16C30 8.27812 23.7219 2 16 2Z" fill="#FDDD4C"/>
|
||||
<path d="M9.86884 19.6502C9.56272 19.1867 10.3327 18.4853 10.0195 17.8963C9.83897 17.5567 9.54389 17.345 9.18952 17.3006C8.84941 17.2579 8.49905 17.3824 8.27573 17.6257C7.92315 18.0094 7.86832 18.5317 7.93652 18.7165C7.96148 18.7842 8.00071 18.8027 8.02879 18.8067C8.22083 18.832 8.32889 18.3391 8.39519 18.2364C8.57851 17.9535 8.99476 17.8679 9.28223 18.0538C9.82731 18.4064 9.35467 18.9762 9.39545 19.4574C9.43513 19.926 9.72709 20.1144 9.98919 20.1342C10.2442 20.1438 10.4225 20.0022 10.4675 19.8988C10.5754 19.6519 10.1207 20.0315 9.86884 19.6502Z" fill="black"/>
|
||||
<path d="M21.2577 15.3958C21.1147 15.3761 20.9585 15.3765 20.7959 15.3958C20.6725 15.2401 20.5619 14.9879 20.4995 14.6936C20.3885 14.17 20.4001 13.7907 20.7099 13.7414C21.0197 13.6922 21.1694 14.009 21.2804 14.5326C21.3549 14.8846 21.3406 15.208 21.2577 15.3958Z" fill="black"/>
|
||||
<path d="M17.8646 15.7074C17.8769 15.8259 17.8812 15.9459 17.8777 16.0564C18.1775 16.074 18.3898 16.2163 18.4463 16.3067C18.4753 16.3533 18.4637 16.3837 18.4544 16.3977C18.4233 16.446 18.3568 16.4386 18.2175 16.4229C18.0958 16.4093 17.9648 16.3973 17.8286 16.4035C17.7545 16.6308 17.5348 16.6521 17.3804 16.484C17.2726 16.5168 17.0608 16.6521 16.9976 16.5051C16.9971 16.4322 17.0734 16.3261 17.2115 16.2325C17.1172 16.0526 17.054 15.8601 17.0167 15.6607C16.8209 15.696 16.6447 15.7508 16.5066 15.7938C16.4418 15.814 16.1853 15.9299 16.1552 15.7993C16.1351 15.7091 16.2755 15.5604 16.424 15.453C16.5898 15.3353 16.7738 15.2516 16.9646 15.2033C16.9605 14.9193 17.0329 14.7211 17.2393 14.6883C17.4951 14.6476 17.6537 14.8446 17.7634 15.2093C18.0726 15.2952 18.3814 15.5082 18.5177 15.7284C18.5707 15.8138 18.581 15.8798 18.5466 15.9146C18.4609 16.0034 17.9862 15.7459 17.8646 15.7074Z" fill="black"/>
|
||||
<path d="M19.8977 16.9838C20.0938 17.0788 20.3096 17.0414 20.38 16.9002C20.4504 16.7589 20.3484 16.5675 20.1522 16.4725C19.9561 16.3775 19.7404 16.4149 19.6699 16.5561C19.5995 16.6973 19.7016 16.8887 19.8977 16.9838Z" fill="black"/>
|
||||
<path d="M20.8632 16.2824C20.8667 16.0664 20.9991 15.8939 21.1583 15.8965C21.3174 15.8996 21.4435 16.0765 21.44 16.2921C21.4364 16.5077 21.304 16.6802 21.1449 16.6775C20.9857 16.6749 20.8596 16.498 20.8632 16.2824Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.1785 18.9281C25.1772 18.9236 25.1816 18.9392 25.1785 18.9281C25.4981 18.9281 26 19.2907 26 20.1667C26 21.0384 25.6336 22.0257 25.5471 22.2449C24.2273 25.3671 21.0772 27.1051 17.3248 26.9951C13.8266 26.8926 10.8432 25.0705 9.53762 22.1001C8.7482 22.101 7.93426 21.7582 7.31556 21.217C6.66343 20.6468 6.26137 19.9089 6.18292 19.1393C6.12185 18.5401 6.19629 17.9826 6.38662 17.4964L5.65337 16.883C2.29777 14.0864 12.7929 2.57165 16.1494 5.46156C16.1663 5.47608 17.2914 6.56551 17.2941 6.56815C18.9125 5.88985 23.2489 4.5984 23.2537 7.60479C23.2555 8.60315 22.611 9.76738 21.5782 10.8238C22.7718 11.9164 22.457 13.4685 22.6471 14.8925L23.0616 15.006C23.8519 15.2247 24.414 15.5164 24.6891 15.8015C24.9641 16.0862 25.1005 16.3621 25.15 16.6855C25.1963 16.9464 25.1901 17.4071 24.8415 17.9223C24.967 18.2547 25.0816 18.5861 25.1785 18.9281ZM9.47834 21.2901C9.59557 21.2927 9.71191 21.2852 9.82602 21.2659C11.0523 21.0595 11.3732 19.7448 11.1713 18.4538C10.9431 16.9961 9.94414 16.4822 9.26616 16.4457C9.07761 16.436 8.90243 16.4527 8.75801 16.4813C7.5478 16.722 6.86447 17.7384 6.99908 19.0584C7.12077 20.253 8.3448 21.2601 9.47834 21.2901ZM6.73743 16.8395C7.14351 16.2574 7.80722 15.8363 8.56054 15.6915C9.51934 13.151 11.1205 10.8102 13.2396 9.19934C14.8122 7.90443 16.5082 6.97559 16.5082 6.97559C16.5082 6.97559 15.5949 5.93016 15.319 5.85316C13.622 5.40084 9.95707 7.89563 7.61689 11.1921C6.67012 12.5257 5.3146 14.8876 5.96272 16.1025C6.04251 16.253 6.49494 16.6397 6.73743 16.8395ZM20.4955 20.7066C20.4981 20.7339 20.4812 20.7621 20.4567 20.7722C20.4567 20.7722 19.0931 21.3983 16.9272 20.737C17.0051 21.3862 17.7924 21.6322 18.3425 21.7213C21.0263 22.1771 23.5355 20.6622 24.1002 20.2807C24.1972 20.2152 24.099 20.3836 24.0815 20.4083C23.3901 21.2887 21.5314 22.3082 19.1132 22.3078C18.0585 22.3073 17.0044 21.9408 16.6174 21.3772C16.017 20.5029 16.5876 19.2265 17.5883 19.3593C19.2798 19.5479 21.0139 19.4062 22.5798 18.6888C23.9451 18.0631 24.4608 17.375 24.3833 16.8175C24.263 15.9535 22.9821 15.8184 22.3346 15.6084C22.0529 15.5164 21.9138 15.4429 21.8822 14.9198C21.8684 14.6914 21.8282 13.8946 21.8135 13.565C21.7877 12.9882 21.7172 12.1993 21.2216 11.8737C21.0923 11.7887 20.9488 11.7478 20.7977 11.7399C20.6772 11.7341 20.6057 11.7507 20.5673 11.7596C20.5587 11.7616 20.5517 11.7632 20.5463 11.7641C20.2786 11.809 20.1146 11.9444 19.9212 12.1041C19.9101 12.1133 19.8988 12.1226 19.8875 12.1319C19.2692 12.6406 18.7472 12.7237 18.1664 12.6991C17.9855 12.6915 17.7991 12.6735 17.6024 12.6544C17.4215 12.6369 17.232 12.6185 17.0302 12.6067L16.7837 12.5926C15.8111 12.5433 14.768 13.3723 14.5946 14.5497C14.4009 15.864 15.1355 16.669 15.6132 17.1925C15.7313 17.3219 15.8337 17.434 15.9025 17.5325C15.9466 17.5919 15.9979 17.6755 15.9979 17.7551C15.9979 17.8502 15.9354 17.9254 15.8744 17.9896C14.8821 18.9968 14.5648 20.5971 14.9388 21.9307C14.9856 22.097 15.0448 22.2563 15.1148 22.4085C15.9921 24.4321 18.7138 25.3746 21.3722 24.5174C23.2354 23.9167 24.8816 22.465 25.2168 20.4624C25.2966 19.9406 25.1794 19.7391 25.0198 19.6418C24.8509 19.5393 24.6485 19.5749 24.6485 19.5749C24.6485 19.5749 24.5562 18.9515 24.295 18.3848C23.5199 18.9884 22.5223 19.4126 21.7627 19.6277C20.5458 19.9724 19.2312 20.1065 17.9737 19.944C17.4636 19.8781 17.1209 19.8338 16.974 20.3049C18.6514 20.9112 20.4268 20.6516 20.4268 20.6516C20.4611 20.6481 20.4919 20.6727 20.4955 20.7066ZM15.656 8.94062C14.7386 9.40394 13.7143 10.2289 12.8825 11.178C12.8531 11.2119 12.8968 11.2585 12.9329 11.2326C13.6514 10.7165 14.6365 10.2369 15.927 9.92622C17.3725 9.57818 18.7642 9.72426 19.6142 9.91654C19.657 9.92622 19.6837 9.85362 19.6459 9.8325C19.0842 9.52142 18.2221 9.31022 17.6106 9.30582C17.5807 9.30538 17.5638 9.27106 17.5816 9.2473C17.6872 9.10694 17.8321 8.96834 17.9645 8.86802C17.9939 8.84514 17.9761 8.79807 17.9386 8.80026C17.1779 8.84638 15.4402 9.63869 15.4465 9.60986C15.491 9.39866 15.6314 9.1197 15.7041 8.98946C15.7215 8.95866 15.6876 8.92478 15.656 8.94062Z" fill="black"/>
|
||||
</svg>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\CrmMigrator;
|
||||
|
||||
use FluentCrm\App\Services\CrmMigrator\Api\MailerLite;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class MailerLiteMigrator extends BaseMigrator
|
||||
{
|
||||
public function getInfo()
|
||||
{
|
||||
$infoSvg = $this->getInfoIcon();
|
||||
|
||||
$logo = $this->getSvgLogo();
|
||||
|
||||
return [
|
||||
'title' => 'MailerLite',
|
||||
'description' => __('Migrate your MailerLite contacts and associate to FluentCRM', 'fluent-crm'),
|
||||
'logo' => fluentCrmMix('images/migrators/mailerlite.png'),
|
||||
'logo_svg' => $logo,
|
||||
'supports' => [
|
||||
'tags' => true,
|
||||
'lists' => false,
|
||||
'empty_tags' => false,
|
||||
'active_imports_only' => true
|
||||
],
|
||||
'field_map_info' => __('Email Address and First name will be mapped automatically', 'fluent-crm'),
|
||||
'tags_map_info' => __('Only Selected Groups will be imported from MailerLite', 'fluent-crm'),
|
||||
'credentials' => [
|
||||
'api_key' => ''
|
||||
],
|
||||
'credential_fields' => [
|
||||
'api_key' => [
|
||||
'label' => __('API Key', 'fluent-crm'),
|
||||
'placeholder' => __('MailerLite API Key', 'fluent-crm'),
|
||||
'data_type' => 'password',
|
||||
'type' => 'input-text',
|
||||
'inline_help' => $infoSvg . ' ' . __('You can find your API key at MailerLite', 'fluent-crm') . ' <a href="https://app.mailerlite.com/integrations/api/" target="_blank" rel="noopener">'. __("Account -> Integrations -> Developer API", "fluent-crm") .'</a>'
|
||||
]
|
||||
],
|
||||
'refresh_on_list_change' => false,
|
||||
'doc_url' => 'https://fluentcrm.com/docs/migrating-into-fluentcrm-from-mailerlite/'
|
||||
];
|
||||
}
|
||||
|
||||
public function verifyCredentials($credential)
|
||||
{
|
||||
$api = $this->getApi($credential);
|
||||
|
||||
try {
|
||||
$result = $api->auth_test();
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
return new \WP_Error('api_error', $exception->getMessage());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getListTagMappings($postedData)
|
||||
{
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
|
||||
$groups = $api->getGroups();
|
||||
|
||||
$formattedTags = [];
|
||||
|
||||
foreach ($groups as $tag) {
|
||||
$formattedTags[] = [
|
||||
'remote_name' => (string)$tag['name'],
|
||||
'remote_id' => (string)$tag['id'],
|
||||
'will_create' => 'no',
|
||||
'fluentcrm_id' => ''
|
||||
];
|
||||
}
|
||||
|
||||
$data['tags'] = $formattedTags;
|
||||
|
||||
$contactFields = $api->getCustomFields();
|
||||
$formattedContactFields = [];
|
||||
|
||||
$autoFieldMaps = [
|
||||
'name' => 'first_name',
|
||||
'last_name' => 'last_name',
|
||||
'country' => 'country',
|
||||
'city' => 'city',
|
||||
'phone' => 'phone',
|
||||
'state' => 'state',
|
||||
'zip' => 'postal_code'
|
||||
];
|
||||
|
||||
foreach ($contactFields as $field) {
|
||||
$item = [
|
||||
'type' => 'any',
|
||||
'remote_label' => $field['title'],
|
||||
'remote_tag' => $field['key'],
|
||||
'fluentcrm_field' => '',
|
||||
'will_skip' => 'no'
|
||||
];
|
||||
|
||||
|
||||
$fieldKey = $field['key'];
|
||||
|
||||
if ($fieldKey == 'email') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($autoFieldMaps[$fieldKey])) {
|
||||
$item['fluentcrm_field'] = $autoFieldMaps[$fieldKey];
|
||||
}
|
||||
|
||||
$formattedContactFields[] = $item;
|
||||
}
|
||||
|
||||
$data['contact_fields'] = $formattedContactFields;
|
||||
$data['contact_fillables'] = $this->getFillables();
|
||||
|
||||
$data['all_ready'] = true;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getSummary($postedData)
|
||||
{
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
|
||||
$groups = $api->getGroups();
|
||||
|
||||
if (is_wp_error($groups)) {
|
||||
return $groups;
|
||||
}
|
||||
|
||||
$tagMappings = Arr::get($postedData, 'tags', []);
|
||||
|
||||
$tagCounts = 0;
|
||||
|
||||
foreach ($tagMappings as $tagMapping) {
|
||||
if ($tagMapping['will_create'] == 'yes') {
|
||||
$tagCounts++;
|
||||
continue;
|
||||
}
|
||||
if ($tagMapping['will_create'] == 'no' || empty($tagMapping['fluentcrm_id'])) {
|
||||
continue;
|
||||
}
|
||||
$tagCounts++;
|
||||
}
|
||||
|
||||
$message = __('Based on your selections, ', 'fluent-crm') . $tagCounts . __(' groups and associate contacts will be imported from MailerLite', 'fluent-crm');
|
||||
|
||||
return [
|
||||
'subscribed_count' => 1,
|
||||
'unsubscribed_count' => 0,
|
||||
'all_count' => 1,
|
||||
'message' => $message
|
||||
];
|
||||
}
|
||||
|
||||
public function runImport($postedData)
|
||||
{
|
||||
if (!defined('FLUENTCRM_DISABLE_TAG_LIST_EVENTS')) {
|
||||
define('FLUENTCRM_DISABLE_TAG_LIST_EVENTS', true);
|
||||
}
|
||||
|
||||
$api = $this->getApi($postedData['credential']);
|
||||
|
||||
$tagMappings = Arr::get($postedData, 'tags', []);
|
||||
|
||||
$taggingArray = $this->mapTags($tagMappings);
|
||||
|
||||
$taggingKeys = array_keys($taggingArray);
|
||||
|
||||
if (!$taggingKeys) {
|
||||
return new \WP_Error('not_found', 'No Tag found based on your selection');
|
||||
}
|
||||
|
||||
$limitPerChunk = 50;
|
||||
|
||||
$import_tracker = Arr::get($postedData, 'import_tracker', []);
|
||||
|
||||
if (empty($import_tracker)) {
|
||||
$import_tracker = [
|
||||
'current_index' => 0,
|
||||
'offset' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$currentTagId = $taggingKeys[$import_tracker['current_index']];
|
||||
|
||||
$subscribers = $api->getGroupSubscribers($currentTagId, [
|
||||
'offset' => $import_tracker['offset'],
|
||||
'limit' => $limitPerChunk
|
||||
]);
|
||||
|
||||
if (is_wp_error($subscribers)) {
|
||||
return $subscribers;
|
||||
}
|
||||
|
||||
$fieldMaps = Arr::get($postedData, 'contact_fields', []);
|
||||
|
||||
$mapSettings = Arr::get($postedData, 'map_settings', []);
|
||||
|
||||
foreach ($subscribers as $subscriber) {
|
||||
|
||||
if ($subscriber['type'] != 'active') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'email' => $subscriber['email'],
|
||||
'first_name' => $subscriber['name'],
|
||||
'created_at' => gmdate('Y-m-d H:i:s', strtotime($subscriber['date_created'])),
|
||||
'source' => 'MailerLite',
|
||||
'status' => 'subscribed',
|
||||
'ip' => $subscriber['signup_ip']
|
||||
];
|
||||
|
||||
$remoteData = $subscriber['fields'];
|
||||
|
||||
$formattedRemoteData = [];
|
||||
|
||||
foreach ($remoteData as $remoteDatum) {
|
||||
$formattedRemoteData[$remoteDatum['key']] = $remoteDatum['value'];
|
||||
}
|
||||
|
||||
$mergeData = $this->getMergedData($formattedRemoteData, $fieldMaps);
|
||||
|
||||
if ($mergeData) {
|
||||
$data = array_merge($data, $mergeData);
|
||||
}
|
||||
|
||||
if (!empty($mapSettings['local_list_id'])) {
|
||||
$data['lists'] = [$mapSettings['local_list_id']];
|
||||
}
|
||||
|
||||
$data['tags'] = [$taggingArray[$currentTagId]];
|
||||
|
||||
$contact = FluentCrmApi('contacts')->createOrUpdate($data);
|
||||
|
||||
if ($contact && $contact->status != 'subscribed') {
|
||||
$contact->updateStatus('subscribed');
|
||||
}
|
||||
}
|
||||
|
||||
$stepCompletedCount = $limitPerChunk + $import_tracker['offset'];
|
||||
|
||||
$groupContactsCount = $api->getContactCountByGroup($currentTagId);
|
||||
|
||||
if (is_wp_error($groupContactsCount)) {
|
||||
return $groupContactsCount;
|
||||
}
|
||||
$stepCompleted = $stepCompletedCount >= $groupContactsCount;
|
||||
|
||||
if (!$stepCompleted) {
|
||||
$import_tracker = [
|
||||
'current_index' => $import_tracker['current_index'],
|
||||
'offset' => $stepCompletedCount
|
||||
];
|
||||
} else {
|
||||
$nextIndex = $import_tracker['current_index'] + 1;
|
||||
$import_tracker = [
|
||||
'current_index' => $nextIndex,
|
||||
'offset' => 0
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'completed' => 0,
|
||||
'total' => 0,
|
||||
'import_tracker' => $import_tracker,
|
||||
'has_more' => isset($taggingKeys[$import_tracker['current_index']]),
|
||||
'message' => __('Importer is running now. ', 'fluent-crm').( $import_tracker['current_index']+1 ) .__(' tags have been imported so far', 'fluent-crm')
|
||||
];
|
||||
}
|
||||
|
||||
private function getApi($credential)
|
||||
{
|
||||
return new MailerLite($credential['api_key']);
|
||||
}
|
||||
|
||||
public function getSvgLogo()
|
||||
{
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="26" viewBox="0 0 32 26" fill="none">
|
||||
<g clip-path="url(#clip0_6972_60099)">
|
||||
<path d="M28.3562 0H3.50365C1.56496 0 0 1.56937 0 3.51351V25.836L4.83504 21.0342H28.3796C30.3182 21.0342 31.8832 19.4649 31.8832 17.5207V3.51351C31.8599 1.56937 30.2949 0 28.3562 0ZM7.45109 15.7405C7.45109 16.2559 7.03066 16.6775 6.51679 16.6775C6.00292 16.6775 5.58248 16.2559 5.58248 15.7405V5.78559C5.58248 5.27027 6.00292 4.84865 6.51679 4.84865C7.03066 4.84865 7.45109 5.27027 7.45109 5.78559V15.7405ZM11.7956 15.7405C11.7956 16.2559 11.3752 16.6775 10.8613 16.6775C10.3474 16.6775 9.92701 16.2559 9.92701 15.7405V9.15856C9.92701 8.64324 10.3474 8.22162 10.8613 8.22162C11.3752 8.22162 11.7956 8.64324 11.7956 9.15856V15.7405ZM11.9124 6.16036C11.9124 6.72252 11.4686 7.16757 10.908 7.16757H10.8146C10.254 7.16757 9.81022 6.72252 9.81022 6.16036V6.09009C9.81022 5.52793 10.254 5.08288 10.8146 5.08288H10.908C11.4686 5.08288 11.9124 5.52793 11.9124 6.09009V6.16036ZM18.4993 16.4432C18.0788 16.6541 17.6117 16.7477 17.1212 16.7477C15.5095 16.7477 14.6453 15.9748 14.6453 14.4991V9.97838H13.8044C13.5241 9.97838 13.2905 9.76757 13.2905 9.48649V9.46306C13.2905 9.2991 13.3839 9.13513 13.5241 9.01802L15.6029 6.98018C15.7197 6.86306 15.8599 6.79279 16.0234 6.76937C16.3037 6.76937 16.5606 7.0036 16.5606 7.28468V7.30811V8.29189H18.0555C18.5226 8.29189 18.8963 8.66667 18.8963 9.13513C18.8963 9.6036 18.5226 9.97838 18.0555 9.97838H16.5606V14.382C16.5606 15.0144 16.8876 15.0613 17.3314 15.0613C17.5183 15.0613 17.7051 15.0378 17.8686 14.991C17.9854 14.9441 18.1255 14.9441 18.2423 14.9207C18.6628 14.9207 19.0131 15.2721 19.0365 15.7171C18.9898 16.0216 18.7796 16.3261 18.4993 16.4432ZM24.2219 14.991C24.8993 15.0144 25.5766 14.8504 26.1839 14.5459C26.3007 14.4757 26.4409 14.4523 26.5577 14.4523C27.0248 14.4523 27.3985 14.8036 27.3985 15.2721V15.2955C27.3752 15.6234 27.1883 15.9279 26.8847 16.0685C26.2307 16.4432 25.5299 16.7712 24.0818 16.7712C21.4657 16.7712 19.8774 15.155 19.8774 12.4613C19.8774 9.2991 21.9796 8.15135 23.7547 8.15135C26.4175 8.15135 27.6321 10.2829 27.6321 12.2505C27.6555 12.7423 27.2584 13.164 26.7679 13.1874C26.7445 13.1874 26.7212 13.1874 26.6978 13.1874H21.7927C22.0496 14.3586 22.8905 14.991 24.2219 14.991Z" fill="#21C16C"/>
|
||||
<path d="M23.8018 9.81448C22.7741 9.79106 21.9098 10.564 21.8164 11.5947H25.8106C25.6938 10.564 24.8295 9.79106 23.8018 9.81448Z" fill="#21C16C"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6972_60099">
|
||||
<rect width="32" height="26" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services;
|
||||
|
||||
/**
|
||||
* DbPerformanceService
|
||||
*
|
||||
* Health-checks and repairs the small set of "critical" indexes that the
|
||||
* hot-path queries (campaign sending, automation runner, dedupe-protecting
|
||||
* unique keys) depend on.
|
||||
*
|
||||
* Why this exists: index creation runs on plugin activation / DB version bumps.
|
||||
* On large or busy tables an ALTER can fail mid-flight (lock-wait timeout,
|
||||
* killed request, insufficient privilege, a "Duplicate entry" left behind by
|
||||
* un-deduped rows). When that happens the DB version option is still bumped, so
|
||||
* the migrator never retries and the index stays missing — silently degrading
|
||||
* performance or, for the unique keys, allowing duplicate rows to accumulate.
|
||||
*
|
||||
* This service is the single source of truth for how each critical index is
|
||||
* built (sweep + dedupe + online-or-plain ALTER). The relevant migrations
|
||||
* delegate to ensureCriticalIndex() instead of carrying their own copy of that
|
||||
* logic, and the runtime path (getIndexHealth/repairIndex) uses the same code
|
||||
* to detect drift on load — via a lightweight cached check — and heal it on
|
||||
* demand. Migration and runtime therefore can never diverge.
|
||||
*/
|
||||
class DbPerformanceService
|
||||
{
|
||||
/**
|
||||
* Build the health status for every critical index.
|
||||
*
|
||||
* @param bool $fromDb When false, serve from the cached snapshot if it is
|
||||
* complete (covers every known index). When the cache
|
||||
* is missing any index it is treated as stale and a
|
||||
* live DB check is performed. Pass true to always hit
|
||||
* the DB (e.g. right after a repair).
|
||||
* @return array<string, array> Index name => meta + 'status' ('yes'|'no').
|
||||
*/
|
||||
public static function getIndexHealth($fromDb = true)
|
||||
{
|
||||
$indexActions = self::getCriticalIndexLists();
|
||||
|
||||
if (!$fromDb) {
|
||||
$existing = fluentcrm_get_option('_db_index_health', []);
|
||||
$broken = false;
|
||||
if ($existing) {
|
||||
foreach ($indexActions as $indexName => $indexData) {
|
||||
if (!isset($existing[$indexName])) {
|
||||
// A known index is absent from the cache — snapshot is
|
||||
// stale (e.g. a new critical index was introduced), so
|
||||
// fall through to a fresh DB check below.
|
||||
$broken = true;
|
||||
continue;
|
||||
}
|
||||
$indexData['name'] = $indexName;
|
||||
$indexData['status'] = $existing[$indexName];
|
||||
$indexActions[$indexName] = $indexData;
|
||||
}
|
||||
|
||||
if (!$broken) {
|
||||
return $indexActions;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Group required indexes by table so we run a single SHOW INDEX per table.
|
||||
$formattedIndex = [];
|
||||
foreach ($indexActions as $indexName => $indexData) {
|
||||
if (!isset($formattedIndex[$indexData['table']])) {
|
||||
$formattedIndex[$indexData['table']] = [];
|
||||
}
|
||||
$formattedIndex[$indexData['table']][$indexName] = $indexData;
|
||||
}
|
||||
|
||||
$indexedResults = [];
|
||||
$cachedData = [];
|
||||
|
||||
foreach ($formattedIndex as $tableName => $requiredIndexes) {
|
||||
$table = $wpdb->prefix . $tableName;
|
||||
|
||||
// Full per-key structure (uniqueness + ordered columns + prefix
|
||||
// lengths), so a same-named index with the WRONG columns/order is
|
||||
// reported broken rather than silently accepted as healthy.
|
||||
$info = self::getIndexInfo($table);
|
||||
|
||||
foreach ($requiredIndexes as $indexName => $indexData) {
|
||||
$status = self::indexIsHealthy($info, $indexName, $indexData) ? 'yes' : 'no';
|
||||
$indexData['name'] = $indexName;
|
||||
$indexData['status'] = $status;
|
||||
$indexedResults[$indexName] = $indexData;
|
||||
$cachedData[$indexName] = $status;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the original ordering defined in getCriticalIndexLists().
|
||||
$ordered = [];
|
||||
foreach ($indexActions as $indexName => $indexData) {
|
||||
if (isset($indexedResults[$indexName])) {
|
||||
$ordered[$indexName] = $indexedResults[$indexName];
|
||||
}
|
||||
}
|
||||
|
||||
fluentcrm_update_option('_db_index_health', $cachedData);
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any critical index is currently missing.
|
||||
*
|
||||
* @param bool $fromDb See getIndexHealth(). Defaults to the cached check so
|
||||
* it is cheap to call on every app load.
|
||||
* @return bool
|
||||
*/
|
||||
public static function hasBrokenIndex($fromDb = false)
|
||||
{
|
||||
foreach (self::getIndexHealth($fromDb) as $indexData) {
|
||||
if ($indexData['status'] !== 'yes') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a single critical index exists (and, for unique keys, is actually
|
||||
* unique), creating it with the appropriate cleanup if not.
|
||||
*
|
||||
* This is the single source of truth for how each critical index is built.
|
||||
* It is intentionally low-level — it touches only $wpdb (no option store, no
|
||||
* WP_Error), so it is safe to call from the migration classes during plugin
|
||||
* activation, before the framework is fully booted. repairIndex() wraps this
|
||||
* with verification and health-cache refresh for the runtime/AJAX path.
|
||||
*
|
||||
* For the uniqueness constraints the table is swept + deduped first; without
|
||||
* it the ADD UNIQUE KEY fails with "Duplicate entry" on any table that
|
||||
* accumulated dupes while the constraint was absent.
|
||||
*
|
||||
* @param string $indexName One of the keys from getCriticalIndexLists().
|
||||
* @return bool True if the index is known and the ensure ran (it may still
|
||||
* no-op when already present); false for an unknown index.
|
||||
*/
|
||||
public static function ensureCriticalIndex($indexName)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$indexLists = self::getCriticalIndexLists();
|
||||
if (!isset($indexLists[$indexName])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$index = $indexLists[$indexName];
|
||||
$table = $wpdb->prefix . $index['table'];
|
||||
|
||||
if ($index['type'] === 'unique') {
|
||||
self::ensureUniqueKey($table, $indexName, $index);
|
||||
} else {
|
||||
self::ensurePlainIndex($table, $indexName, $index['columns']);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair a single critical index from the runtime/AJAX path.
|
||||
*
|
||||
* Delegates the actual DDL to ensureCriticalIndex(), then verifies against
|
||||
* the live DB and refreshes the health cache so the caller and any later
|
||||
* cached read both see the true post-repair state.
|
||||
*
|
||||
* @param string $indexName One of the keys from getCriticalIndexLists().
|
||||
* @return array|\WP_Error The refreshed index record on success, WP_Error
|
||||
* if the index is unknown or still missing after
|
||||
* the repair attempt.
|
||||
*/
|
||||
public static function repairIndex($indexName)
|
||||
{
|
||||
if (!self::ensureCriticalIndex($indexName)) {
|
||||
return new \WP_Error(
|
||||
'invalid_index',
|
||||
__('The provided index is not a known critical index.', 'fluent-crm')
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
// Capture the ALTER's error before the verification queries below reset
|
||||
// $wpdb->last_error, so a privilege/lock failure can still be surfaced.
|
||||
$repairError = $wpdb->last_error;
|
||||
|
||||
// Verify against the live DB (and refresh the cache).
|
||||
$health = self::getIndexHealth(true);
|
||||
|
||||
if (!isset($health[$indexName]) || $health[$indexName]['status'] !== 'yes') {
|
||||
return new \WP_Error(
|
||||
'repair_failed',
|
||||
sprintf(
|
||||
/* translators: %s: index name */
|
||||
__('Could not create the "%s" index. Please check your database user privileges or repair it manually.', 'fluent-crm'),
|
||||
$indexName
|
||||
),
|
||||
['db_error' => $repairError]
|
||||
);
|
||||
}
|
||||
|
||||
return $health[$indexName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair every missing critical index in one pass.
|
||||
*
|
||||
* @return array{repaired: string[], failed: string[], health: array}
|
||||
*/
|
||||
public static function repairBrokenIndexes()
|
||||
{
|
||||
$health = self::getIndexHealth(true);
|
||||
|
||||
$repaired = [];
|
||||
$failed = [];
|
||||
foreach ($health as $indexName => $indexData) {
|
||||
if ($indexData['status'] === 'yes') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = self::repairIndex($indexName);
|
||||
if (is_wp_error($result)) {
|
||||
$failed[] = $indexName;
|
||||
} else {
|
||||
$repaired[] = $indexName;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'repaired' => $repaired,
|
||||
'failed' => $failed,
|
||||
'health' => self::getIndexHealth(false), // freshly cached by the calls above
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a plain (non-unique) composite index exists with exactly the
|
||||
* expected columns. A same-named index whose column list/order/prefix has
|
||||
* drifted is dropped and recreated, so the performance assumption it backs
|
||||
* actually holds — name-presence alone is not enough.
|
||||
*
|
||||
* @param string $table Fully-prefixed table name.
|
||||
* @param string $indexName Key name to create.
|
||||
* @param array $columns Expected column spec — see getCriticalIndexLists().
|
||||
* @return void
|
||||
*/
|
||||
private static function ensurePlainIndex($table, $indexName, array $columns)
|
||||
{
|
||||
$info = self::getIndexInfo($table);
|
||||
$columnSql = self::buildColumnSql($columns);
|
||||
|
||||
if (isset($info[$indexName])) {
|
||||
if (self::indexColumnsMatch($info[$indexName]['columns'], $columns)) {
|
||||
return; // present with the right columns — nothing to do.
|
||||
}
|
||||
// Same name, wrong columns — replace it in one statement.
|
||||
self::alterWithOnlineFallback($table, "DROP INDEX `{$indexName}`, ADD INDEX `{$indexName}` ({$columnSql})");
|
||||
return;
|
||||
}
|
||||
|
||||
self::alterWithOnlineFallback($table, "ADD INDEX `{$indexName}` ({$columnSql})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a UNIQUE KEY, cleaning the table first so the ALTER can't fail with
|
||||
* "Duplicate entry".
|
||||
*
|
||||
* This is the canonical convergence logic the owning migrations delegate to:
|
||||
* a key of the same name that exists but is non-unique OR has drifted columns
|
||||
* is dropped and re-added; offending rows are swept/deduped before the ALTER
|
||||
* runs. The cleanup differs per table, so each has its own dedicated method
|
||||
* ('cleanup' names which one). If cleanup fails (lock-wait timeout, missing
|
||||
* privilege) we skip the ALTER rather than let it throw — the caller's
|
||||
* verification step reports the index as still broken and a later attempt
|
||||
* can retry.
|
||||
*
|
||||
* @param string $table Fully-prefixed table name.
|
||||
* @param string $indexName Unique key name to create.
|
||||
* @param array $index Index descriptor from getCriticalIndexLists().
|
||||
* @return void
|
||||
*/
|
||||
private static function ensureUniqueKey($table, $indexName, array $index)
|
||||
{
|
||||
$info = self::getIndexInfo($table);
|
||||
$existing = isset($info[$indexName]) ? $info[$indexName] : null;
|
||||
|
||||
// Already a correct unique key (unique AND the right columns) — done.
|
||||
if ($existing
|
||||
&& $existing['non_unique'] === 0
|
||||
&& self::indexColumnsMatch($existing['columns'], $index['columns'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Run the table-specific cleanup. Each method sweeps garbage rows and
|
||||
// removes duplicates with the survivor rule appropriate for that table.
|
||||
$clean = false;
|
||||
switch ($index['cleanup']) {
|
||||
case 'subscriber_pivot':
|
||||
$clean = self::cleanupSubscriberPivot($table);
|
||||
break;
|
||||
case 'funnel_subscribers':
|
||||
$clean = self::cleanupFunnelSubscribers($table);
|
||||
break;
|
||||
case 'funnel_metrics':
|
||||
$clean = self::cleanupFunnelMetrics($table);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!$clean) {
|
||||
return; // cleanup failed — don't risk a "Duplicate entry" ALTER.
|
||||
}
|
||||
|
||||
$columnSql = self::buildColumnSql($index['columns']);
|
||||
|
||||
if ($existing) {
|
||||
// Same name but non-unique or wrong columns — replace it in one statement.
|
||||
self::alterWithOnlineFallback($table, "DROP INDEX `{$indexName}`, ADD UNIQUE KEY `{$indexName}` ({$columnSql})");
|
||||
} else {
|
||||
self::alterWithOnlineFallback($table, "ADD UNIQUE KEY `{$indexName}` ({$columnSql})");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an index ALTER, preferring a non-blocking online build but always
|
||||
* falling back to a plain ALTER so the index is created on any server.
|
||||
*
|
||||
* Why both: ALGORITHM=INPLACE, LOCK=NONE lets the table stay readable AND
|
||||
* writable during the build, which avoids stalling live traffic (e.g. the
|
||||
* email pipeline on a multi-million-row fc_campaign_emails). But it is not
|
||||
* universally available — the ALGORITHM/LOCK syntax did not exist before
|
||||
* MySQL 5.6 / MariaDB 10.0 (where it is a hard parse error), and some
|
||||
* engines/operations can't satisfy LOCK=NONE. FluentCRM runs on WordPress
|
||||
* hosts we don't control, and these keys — the unique ones especially — are
|
||||
* a correctness dependency for the app (INSERT IGNORE / firstOrCreate rely
|
||||
* on them). So we try online first, then retry plain: a brief write-block on
|
||||
* an old server is an acceptable price for a key that actually gets created.
|
||||
*
|
||||
* @param string $table Fully-prefixed table name.
|
||||
* @param string $operations The ALTER body, e.g. "ADD UNIQUE KEY `x` (...)"
|
||||
* or "DROP INDEX `x`, ADD UNIQUE KEY `x` (...)".
|
||||
* @return int|bool Result of the successful query, or false if both failed.
|
||||
*/
|
||||
private static function alterWithOnlineFallback($table, $operations)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
// Attempt the non-blocking online build first.
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$result = $wpdb->query("ALTER TABLE {$table} {$operations}, ALGORITHM=INPLACE, LOCK=NONE");
|
||||
if ($result !== false) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Online DDL unavailable (old server, FK/engine constraint, etc.) — retry
|
||||
// with a plain ALTER that works everywhere, even if it briefly blocks
|
||||
// writes. If this also fails (real duplicate, no privilege) the caller's
|
||||
// verification reports the index missing and a later run can retry.
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
return $wpdb->query("ALTER TABLE {$table} {$operations}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup for fc_subscriber_pivot ahead of the subscriber_object_type_unique
|
||||
* key, which enforces uniqueness on (subscriber_id, object_id, object_type).
|
||||
*
|
||||
* Sweeps rows where subscriber_id or object_id is NULL or 0 — these are
|
||||
* never a valid list/tag relationship (they come from past writer bugs that
|
||||
* lacked a real id) and would otherwise survive into the unique key and
|
||||
* block legitimate future writes. The schema declares both columns
|
||||
* BIGINT UNSIGNED NOT NULL, but we still guard IS NULL so the repair is
|
||||
* correct even on an older live table whose definition has drifted.
|
||||
*
|
||||
* Then keeps the lowest id per (subscriber_id, object_id, object_type) group
|
||||
* so the survivor has the earliest created_at ("we already had this
|
||||
* attachment"). (SubscriberPivot::migrate() delegates here.)
|
||||
*
|
||||
* @param string $table Fully-prefixed fc_subscriber_pivot table name.
|
||||
* @return bool True if the table is safe to receive the unique key.
|
||||
*/
|
||||
private static function cleanupSubscriberPivot($table)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->query("DELETE FROM {$table} WHERE subscriber_id IS NULL OR subscriber_id = 0 OR object_id IS NULL OR object_id = 0");
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$hasDuplicates = (int) $wpdb->get_var("SELECT COUNT(*) FROM (
|
||||
SELECT subscriber_id, object_id, object_type
|
||||
FROM {$table}
|
||||
GROUP BY subscriber_id, object_id, object_type
|
||||
HAVING COUNT(*) > 1
|
||||
) dups");
|
||||
|
||||
if ($hasDuplicates <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$deleted = $wpdb->query("DELETE sp FROM {$table} sp
|
||||
INNER JOIN (
|
||||
SELECT subscriber_id, object_id, object_type, MIN(id) as keep_id
|
||||
FROM {$table}
|
||||
GROUP BY subscriber_id, object_id, object_type
|
||||
HAVING COUNT(*) > 1
|
||||
) dups ON sp.subscriber_id = dups.subscriber_id
|
||||
AND sp.object_id = dups.object_id
|
||||
AND sp.object_type = dups.object_type
|
||||
AND sp.id != dups.keep_id");
|
||||
|
||||
return $deleted !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup for fc_funnel_subscribers ahead of the funnel_subscriber_idx key.
|
||||
*
|
||||
* Sweeps rows with NULL/0 funnel_id or subscriber_id, then dedupes keeping
|
||||
* the row that has progressed FURTHEST through the funnel rather than the
|
||||
* newest row. This is a state table, not a pure pivot: a naive MAX(id)
|
||||
* survivor can discard a row that already executed sequences in favour of a
|
||||
* newer, less advanced duplicate, which then re-fires sequences the contact
|
||||
* already received. Survivor rule: highest IFNULL(last_sequence_id, 0) wins,
|
||||
* tiebroken by MAX(id). The IFNULL is essential — last_sequence_id is NULL
|
||||
* for freshly enrolled contacts, and SQL's three-valued logic would
|
||||
* otherwise match no survivor for all-NULL groups, leaving the dupes in
|
||||
* place and failing the ALTER. (FunnelSubscribers::migrate() delegates here.)
|
||||
*
|
||||
* @param string $table Fully-prefixed fc_funnel_subscribers table name.
|
||||
* @return bool True if the table is safe to receive the unique key.
|
||||
*/
|
||||
private static function cleanupFunnelSubscribers($table)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->query("DELETE FROM {$table} WHERE funnel_id IS NULL OR funnel_id = 0 OR subscriber_id IS NULL OR subscriber_id = 0");
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$hasDuplicates = (int) $wpdb->get_var("SELECT COUNT(*) FROM (
|
||||
SELECT funnel_id, subscriber_id
|
||||
FROM {$table}
|
||||
GROUP BY funnel_id, subscriber_id
|
||||
HAVING COUNT(*) > 1
|
||||
) dups");
|
||||
|
||||
if ($hasDuplicates <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$deleted = $wpdb->query("DELETE fs FROM {$table} fs
|
||||
INNER JOIN (
|
||||
SELECT t1.funnel_id, t1.subscriber_id, MAX(t1.id) as keep_id
|
||||
FROM {$table} t1
|
||||
INNER JOIN (
|
||||
SELECT funnel_id, subscriber_id, MAX(IFNULL(last_sequence_id, 0)) as max_seq
|
||||
FROM {$table}
|
||||
GROUP BY funnel_id, subscriber_id
|
||||
HAVING COUNT(*) > 1
|
||||
) dups ON t1.funnel_id = dups.funnel_id
|
||||
AND t1.subscriber_id = dups.subscriber_id
|
||||
AND IFNULL(t1.last_sequence_id, 0) = dups.max_seq
|
||||
GROUP BY t1.funnel_id, t1.subscriber_id
|
||||
) survivors ON fs.funnel_id = survivors.funnel_id
|
||||
AND fs.subscriber_id = survivors.subscriber_id
|
||||
AND fs.id != survivors.keep_id");
|
||||
|
||||
return $deleted !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup for fc_funnel_metrics ahead of the funnel_seq_subscriber_unique
|
||||
* key, which enforces uniqueness on (funnel_id, sequence_id, subscriber_id).
|
||||
*
|
||||
* Sweeps rows where any of the three key columns is NULL or 0 — a metric is
|
||||
* only meaningful when tied to a real funnel, sequence and subscriber, so
|
||||
* such rows are invalid leftovers. They are also collapsed into a single
|
||||
* GROUP BY bucket by the dedupe below, so removing them up front keeps the
|
||||
* survivor selection clean. (All three are BIGINT UNSIGNED NULL in the
|
||||
* schema, so both NULL and 0 are reachable.)
|
||||
*
|
||||
* Then keeps the latest entry (MAX id) per (funnel_id, sequence_id,
|
||||
* subscriber_id) group. (FunnelMetrics::migrate() delegates here; this adds
|
||||
* the NULL/0 pre-sweep the old inline migration lacked.)
|
||||
*
|
||||
* @param string $table Fully-prefixed fc_funnel_metrics table name.
|
||||
* @return bool True if the table is safe to receive the unique key.
|
||||
*/
|
||||
private static function cleanupFunnelMetrics($table)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->query("DELETE FROM {$table} WHERE funnel_id IS NULL OR funnel_id = 0 OR sequence_id IS NULL OR sequence_id = 0 OR subscriber_id IS NULL OR subscriber_id = 0");
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$hasDuplicates = (int) $wpdb->get_var("SELECT COUNT(*) FROM (
|
||||
SELECT funnel_id, sequence_id, subscriber_id
|
||||
FROM {$table}
|
||||
GROUP BY funnel_id, sequence_id, subscriber_id
|
||||
HAVING COUNT(*) > 1
|
||||
) dups");
|
||||
|
||||
if ($hasDuplicates <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$deleted = $wpdb->query("DELETE fm FROM {$table} fm
|
||||
INNER JOIN (
|
||||
SELECT funnel_id, sequence_id, subscriber_id, MAX(id) AS keep_id
|
||||
FROM {$table}
|
||||
GROUP BY funnel_id, sequence_id, subscriber_id
|
||||
HAVING COUNT(*) > 1
|
||||
) dups ON fm.funnel_id = dups.funnel_id
|
||||
AND fm.sequence_id = dups.sequence_id
|
||||
AND fm.subscriber_id = dups.subscriber_id
|
||||
AND fm.id != dups.keep_id");
|
||||
|
||||
return $deleted !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every index on a table into a structured map keyed by index name.
|
||||
*
|
||||
* SHOW INDEX emits one row per indexed column; we fold them into:
|
||||
* [ Key_name => [
|
||||
* 'non_unique' => 0|1,
|
||||
* 'columns' => [ ['name' => ..., 'sub_part' => int|null], ... ] // ordered by Seq_in_index
|
||||
* ] ]
|
||||
* so callers can verify the full column sequence and prefix lengths, not
|
||||
* just the index name.
|
||||
*
|
||||
* @param string $table Fully-prefixed table name.
|
||||
* @return array<string, array{non_unique:int, columns:array}>
|
||||
*/
|
||||
private static function getIndexInfo($table)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$rows = $wpdb->get_results("SHOW INDEX FROM {$table}");
|
||||
if (!$rows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$info = [];
|
||||
foreach ($rows as $row) {
|
||||
$name = $row->Key_name;
|
||||
if (!isset($info[$name])) {
|
||||
$info[$name] = ['non_unique' => (int) $row->Non_unique, 'columns' => []];
|
||||
}
|
||||
// Seq_in_index is 1-based; key by it so out-of-order rows still sort.
|
||||
$info[$name]['columns'][(int) $row->Seq_in_index] = [
|
||||
'name' => $row->Column_name,
|
||||
'sub_part' => ($row->Sub_part === null) ? null : (int) $row->Sub_part,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($info as &$data) {
|
||||
ksort($data['columns']);
|
||||
$data['columns'] = array_values($data['columns']);
|
||||
}
|
||||
unset($data);
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a required index is present, of the right kind, AND backed by the
|
||||
* exact expected columns. This is the health gate that closes the
|
||||
* "same-named index with the wrong columns reads as healthy" hole.
|
||||
*
|
||||
* @param array $info Output of getIndexInfo().
|
||||
* @param string $indexName Required index name.
|
||||
* @param array $index Index descriptor from getCriticalIndexLists().
|
||||
* @return bool
|
||||
*/
|
||||
private static function indexIsHealthy($info, $indexName, array $index)
|
||||
{
|
||||
if (!isset($info[$indexName])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actual = $info[$indexName];
|
||||
|
||||
// A same-named non-unique index does not enforce uniqueness.
|
||||
if ($index['type'] === 'unique' && $actual['non_unique'] !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::indexColumnsMatch($actual['columns'], $index['columns']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare an index's actual column sequence (from getIndexInfo) against the
|
||||
* expected one (from getCriticalIndexLists). Column names compare
|
||||
* case-insensitively; order matters.
|
||||
*
|
||||
* Prefix handling: when a prefix length is expected (e.g. object_type(50)),
|
||||
* a full-column index (Sub_part = NULL) is accepted because MySQL stores a
|
||||
* prefix as a full-column index when the prefix length equals the column
|
||||
* length — which is exactly what object_type(50) becomes on the current
|
||||
* VARCHAR(50) schema, and a full column is never weaker than the prefix.
|
||||
* A prefix where a full column is expected, or a different prefix length,
|
||||
* is a mismatch.
|
||||
*
|
||||
* @param array $actualColumns Ordered [['name'=>, 'sub_part'=>], ...].
|
||||
* @param array $expectedColumns Ordered [['name'=>, 'sub_part'=>?], ...].
|
||||
* @return bool
|
||||
*/
|
||||
private static function indexColumnsMatch($actualColumns, array $expectedColumns)
|
||||
{
|
||||
if (count($actualColumns) !== count($expectedColumns)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($expectedColumns as $i => $expected) {
|
||||
if (!isset($actualColumns[$i])) {
|
||||
return false;
|
||||
}
|
||||
$actual = $actualColumns[$i];
|
||||
|
||||
if (strcasecmp($actual['name'], $expected['name']) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expectedSub = isset($expected['sub_part']) ? (int) $expected['sub_part'] : null;
|
||||
$actualSub = isset($actual['sub_part']) ? $actual['sub_part'] : null;
|
||||
|
||||
if ($expectedSub === null) {
|
||||
// Full column expected — a prefix index is weaker.
|
||||
if ($actualSub !== null) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Prefix expected — accept the exact prefix, or a full-column
|
||||
// index (NULL) which equals/exceeds the prefix.
|
||||
if ($actualSub !== null && $actualSub !== $expectedSub) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SQL column spec — e.g. "`subscriber_id`, `object_id`,
|
||||
* `object_type`(50)" — for the ADD [UNIQUE] INDEX statement from the
|
||||
* structured column definition.
|
||||
*
|
||||
* @param array $columns Ordered [['name'=>, 'sub_part'=>?], ...].
|
||||
* @return string
|
||||
*/
|
||||
private static function buildColumnSql(array $columns)
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($columns as $col) {
|
||||
$sql = '`' . $col['name'] . '`';
|
||||
if (!empty($col['sub_part'])) {
|
||||
$sql .= '(' . (int) $col['sub_part'] . ')';
|
||||
}
|
||||
$parts[] = $sql;
|
||||
}
|
||||
|
||||
return implode(', ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical list of performance- and integrity-critical indexes.
|
||||
*
|
||||
* Each entry is self-describing so repairIndex() can heal it without
|
||||
* touching the migration classes:
|
||||
* - type : 'index' | 'unique'
|
||||
* - table : unprefixed table name
|
||||
* - title : human label for the UI
|
||||
* - columns : ordered column definition — [['name' => 'col', 'sub_part' =>
|
||||
* 50?], ...]. Used both to build the ADD statement
|
||||
* (buildColumnSql) and to verify the live index matches
|
||||
* (indexColumnsMatch), so health and creation share one spec.
|
||||
* - cleanup : (unique only) which dedicated cleanup method makes the table
|
||||
* safe for the key — 'subscriber_pivot' | 'funnel_subscribers'
|
||||
* | 'funnel_metrics'.
|
||||
*
|
||||
* Every index is created via alterWithOnlineFallback(), which prefers a
|
||||
* non-blocking online ALTER and falls back to a plain one, so no per-index
|
||||
* online flag is needed.
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
private static function getCriticalIndexLists()
|
||||
{
|
||||
global $wpdb;
|
||||
// The campaign-emails composite index is created with the table prefix
|
||||
// baked into its name (see CampaignEmails migration), so we match that.
|
||||
$campaignEmailIndexPrefix = $wpdb->prefix . 'fc_cam_';
|
||||
|
||||
$indexActions = [
|
||||
'subscriber_object_type_unique' => [
|
||||
'type' => 'unique',
|
||||
'table' => 'fc_subscriber_pivot',
|
||||
'title' => __('Subscriber Tags / Lists Relationship Uniqueness', 'fluent-crm'),
|
||||
'columns' => [
|
||||
['name' => 'subscriber_id'],
|
||||
['name' => 'object_id'],
|
||||
['name' => 'object_type', 'sub_part' => 50],
|
||||
],
|
||||
'cleanup' => 'subscriber_pivot',
|
||||
],
|
||||
$campaignEmailIndexPrefix . 'cid_status' => [
|
||||
'type' => 'index',
|
||||
'table' => 'fc_campaign_emails',
|
||||
'title' => __('Campaign Emails Sending Index', 'fluent-crm'),
|
||||
'columns' => [
|
||||
['name' => 'campaign_id'],
|
||||
['name' => 'status'],
|
||||
],
|
||||
],
|
||||
'funnel_subscriber_idx' => [
|
||||
'type' => 'unique',
|
||||
'table' => 'fc_funnel_subscribers',
|
||||
'title' => __('Per Automation Subscriber Uniqueness', 'fluent-crm'),
|
||||
'columns' => [
|
||||
['name' => 'funnel_id'],
|
||||
['name' => 'subscriber_id'],
|
||||
],
|
||||
'cleanup' => 'funnel_subscribers',
|
||||
],
|
||||
'status_next_exec_idx' => [
|
||||
'type' => 'index',
|
||||
'table' => 'fc_funnel_subscribers',
|
||||
'title' => __('Automation Runner Performance Indexing', 'fluent-crm'),
|
||||
'columns' => [
|
||||
['name' => 'status'],
|
||||
['name' => 'next_execution_time'],
|
||||
],
|
||||
],
|
||||
'type_action_name_idx' => [
|
||||
'type' => 'index',
|
||||
'table' => 'fc_funnel_sequences',
|
||||
'title' => __('Automation Goal Quick Lookup Indexing', 'fluent-crm'),
|
||||
'columns' => [
|
||||
['name' => 'type'],
|
||||
['name' => 'action_name'],
|
||||
],
|
||||
],
|
||||
'funnel_seq_subscriber_unique' => [
|
||||
'type' => 'unique',
|
||||
'table' => 'fc_funnel_metrics',
|
||||
'title' => __('Automation Subscriber Metrics Uniqueness', 'fluent-crm'),
|
||||
'columns' => [
|
||||
['name' => 'funnel_id'],
|
||||
['name' => 'sequence_id'],
|
||||
['name' => 'subscriber_id'],
|
||||
],
|
||||
'cleanup' => 'funnel_metrics',
|
||||
],
|
||||
];
|
||||
|
||||
return $indexActions;
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations;
|
||||
|
||||
|
||||
class BricksBuilderIntegration
|
||||
{
|
||||
|
||||
public function register()
|
||||
{
|
||||
add_filter('bricks/conditions/groups', [$this, 'addConditionGroup']);
|
||||
add_filter('bricks/conditions/options', [$this, 'addConditionOptions']);
|
||||
add_filter('bricks/conditions/result', [$this, 'checkCondition'], 10, 3);
|
||||
}
|
||||
|
||||
public function addConditionGroup($groups)
|
||||
{
|
||||
// Ensure your group name is unique (best to prefix it)
|
||||
$groups[] = [
|
||||
'name' => 'fluent_crm',
|
||||
'label' => esc_html__('FluentCRM', 'fluent-crm'),
|
||||
];
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
public function addConditionOptions($options)
|
||||
{
|
||||
// Ensure key is unique, and that group exists
|
||||
$tags = \FluentCrm\App\Models\Tag::select(['id', 'title'])->orderBy('title', 'ASC')->get();
|
||||
$lists = \FluentCrm\App\Models\Lists::select(['id', 'title'])->orderBy('title', 'ASC')->get();
|
||||
|
||||
$formattedTags = [];
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
$formattedTags[$tag->id] = $tag->title;
|
||||
}
|
||||
|
||||
$formattedLists = [];
|
||||
|
||||
foreach ($lists as $list) {
|
||||
$formattedLists[$list->id] = $list->title;
|
||||
}
|
||||
|
||||
$options[] = [
|
||||
'key' => 'fluent_crm_tags',
|
||||
'label' => esc_html__('FluentCRM Tags', 'fluent-crm'),
|
||||
'group' => 'fluent_crm',
|
||||
'compare' => [
|
||||
'type' => 'select',
|
||||
'options' => [
|
||||
'==' => esc_html__('includes in', 'fluent-crm'),
|
||||
'!=' => esc_html__('not includes', 'fluent-crm'),
|
||||
],
|
||||
'placeholder' => esc_html__('is', 'fluent-crm'),
|
||||
],
|
||||
'value' => [
|
||||
'type' => 'select',
|
||||
'multiple' => true,
|
||||
'options' => $formattedTags,
|
||||
'placeholder' => esc_html__('Select Tags', 'fluent-crm'),
|
||||
],
|
||||
];
|
||||
|
||||
$options[] = [
|
||||
'key' => 'fluent_crm_lists',
|
||||
'label' => esc_html__('FluentCRM Lists', 'fluent-crm'),
|
||||
'group' => 'fluent_crm',
|
||||
'compare' => [
|
||||
'type' => 'select',
|
||||
'options' => [
|
||||
'==' => esc_html__('includes in', 'fluent-crm'),
|
||||
'!=' => esc_html__('not includes', 'fluent-crm'),
|
||||
],
|
||||
'placeholder' => esc_html__('is', 'fluent-crm'),
|
||||
],
|
||||
'value' => [
|
||||
'type' => 'select',
|
||||
'multiple' => true,
|
||||
'options' => $formattedLists,
|
||||
'placeholder' => esc_html__('Select Lists', 'fluent-crm'),
|
||||
],
|
||||
];
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public function checkCondition($result, $condition_key, $condition)
|
||||
{
|
||||
$acceptedKeys = ['fluent_crm_tags', 'fluent_crm_lists'];
|
||||
if (!in_array($condition_key, $acceptedKeys, true)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// In my example, if compare is empty, we set it to '==' as default
|
||||
$compare = \FluentCrm\Framework\Support\Arr::get($condition, 'compare', '==');
|
||||
|
||||
$targetIds = \FluentCrm\Framework\Support\Arr::get($condition, 'value', []);
|
||||
$targetIds = array_filter(array_map('intval', $targetIds));
|
||||
|
||||
if (!$compare || !$targetIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$currentContact = fluentcrm_get_current_contact();
|
||||
|
||||
if (!$currentContact) {
|
||||
return $compare != '==';
|
||||
}
|
||||
|
||||
if ($condition_key == 'fluent_crm_tags') {
|
||||
$result = $currentContact->hasAnyTagId($targetIds);
|
||||
} else {
|
||||
$result = $currentContact->hasAnyListId($targetIds);
|
||||
}
|
||||
|
||||
if ($compare == '==') {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return !$result;
|
||||
}
|
||||
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\Benchmarks;
|
||||
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\CartHelper;
|
||||
use FluentCrm\App\Services\Funnel\BaseBenchMark;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class OrderSuccessBenchmark extends BaseBenchMark
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluent_cart/order_paid_done';
|
||||
$this->actionArgNum = 3;
|
||||
$this->priority = 20;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getBlock()
|
||||
{
|
||||
return [
|
||||
'title' => __('Order Paid (Payment/Subscription)', 'fluent-crm'),
|
||||
'description' => __('This will run once new order will be placed as paid in FluentCRM', 'fluent-crm'),
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><g id="surface1"><path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.398438 0 L 21.601562 0 C 22.925781 0 24 1.074219 24 2.398438 L 24 21.601562 C 24 22.925781 22.925781 24 21.601562 24 L 2.398438 24 C 1.074219 24 0 22.925781 0 21.601562 L 0 2.398438 C 0 1.074219 1.074219 0 2.398438 0 Z M 2.398438 0 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 10.925781 16.476562 L 3.769531 16.476562 L 4.894531 13.878906 C 5.222656 13.117188 5.972656 12.625 6.804688 12.625 L 15.328125 12.625 L 14.746094 13.964844 C 14.085938 15.488281 12.585938 16.476562 10.925781 16.476562 Z M 10.925781 16.476562 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 16.851562 11.394531 L 6.789062 11.394531 L 7.367188 10.054688 C 8.027344 8.53125 9.53125 7.542969 11.191406 7.542969 L 19.886719 7.542969 L 18.761719 10.140625 C 18.433594 10.902344 17.683594 11.394531 16.851562 11.394531 Z M 16.851562 11.394531 "/></g></svg>',
|
||||
'settings' => [
|
||||
'product_ids' => [],
|
||||
'product_categories' => [],
|
||||
'type' => 'required',
|
||||
'can_enter' => 'yes'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getDefaultSettings()
|
||||
{
|
||||
return [
|
||||
'product_ids' => [],
|
||||
'product_categories' => [],
|
||||
'type' => 'required',
|
||||
'can_enter' => 'yes'
|
||||
];
|
||||
}
|
||||
|
||||
public function getBlockFields($funnel)
|
||||
{
|
||||
return [
|
||||
'title' => __('New Order Paid in FluentCart', 'fluent-crm'),
|
||||
'sub_title' => __('This will run once new order will be placed as paid in FluentCart', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'product_ids' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Target Products', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_products',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select for which products this automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run to any product purchase', 'fluent-crm')
|
||||
],
|
||||
'product_categories' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Or Target Product Categories', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_product_categories',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select for which product category the automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run to any category products', 'fluent-crm')
|
||||
],
|
||||
'type' => $this->benchmarkTypeField(),
|
||||
'can_enter' => $this->canEnterField()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($benchMark, $originalArgs)
|
||||
{
|
||||
$orderData = $originalArgs[0] ?? [];
|
||||
$order = Arr::get($orderData, 'order', []);
|
||||
$customer = Arr::get($orderData, 'customer', []);
|
||||
|
||||
$settings = $benchMark->settings;;
|
||||
|
||||
if (!$this->checkConditions($settings, $order)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = CartHelper::prepareSubscriberData($customer);
|
||||
|
||||
if (!is_email($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData['status'] = 'subscribed';
|
||||
$subscriber = FunnelHelper::createOrUpdateContact($subscriberData);
|
||||
|
||||
$funnelProcessor = new FunnelProcessor();
|
||||
$funnelProcessor->startFunnelFromSequencePoint($benchMark, $subscriber, [], [
|
||||
'benchmark_value' => $order->total_paid, // converted to cents
|
||||
'benchmark_currency' => $order->currency,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
private function checkConditions($conditions, $order)
|
||||
{
|
||||
$orderItems = Arr::get($order, 'order_items', []);
|
||||
// Post IDs of ordered products are the product IDs in FluentCart
|
||||
|
||||
$orderedProductIds = [];
|
||||
foreach ($orderItems as $item) {
|
||||
$productId = $item->post_id;
|
||||
if ($productId) {
|
||||
$orderedProductIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
$orderProductCategories = CartHelper::getProductCategoriesByIds($orderedProductIds);
|
||||
|
||||
$selectedProductIds = Arr::get($conditions, 'product_ids', []);
|
||||
|
||||
$selectedProductCategories = Arr::get($conditions, 'product_categories', []);
|
||||
|
||||
// If no products or categories are selected, return true
|
||||
if (empty($selectedProductIds) && empty($selectedProductCategories)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for matches in product IDs and categories
|
||||
$productMatch = !empty($selectedProductIds) && !empty(array_intersect($selectedProductIds, $orderedProductIds));
|
||||
$categoryMatch = !empty($selectedProductCategories) && !empty(array_intersect($selectedProductCategories, $orderProductCategories));
|
||||
|
||||
// Return true if either matches
|
||||
return $productMatch || $categoryMatch;
|
||||
}
|
||||
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart;
|
||||
|
||||
use FluentCart\Api\Taxonomy;
|
||||
use FluentCart\App\Helpers\Status;
|
||||
use FluentCart\App\Models\Coupon;
|
||||
use FluentCart\App\Models\Product;
|
||||
use FluentCart\App\Models\ProductVariation;
|
||||
use FluentCart\Framework\Database\Orm\Collection;
|
||||
use FluentCart\Framework\Support\Str;
|
||||
use FluentCart\App\Models\Customer;
|
||||
|
||||
class CartHelper
|
||||
{
|
||||
public static function getFluentCartProducts($items, $search, $ids = [])
|
||||
{
|
||||
$search = (string)$search;
|
||||
$ids = is_array($ids) ? $ids : [];
|
||||
|
||||
try {
|
||||
$productQuery = Product::query()->published();
|
||||
if ($search) {
|
||||
$productQuery->where('post_title', 'like', '%' . $search . '%');
|
||||
}
|
||||
|
||||
$queried = $productQuery
|
||||
->orderBy('post_title')
|
||||
->limit(50)
|
||||
->get(['ID', 'post_title']);
|
||||
|
||||
$options = [];
|
||||
$pushedIds = [];
|
||||
foreach ($queried as $product) {
|
||||
$options[] = [
|
||||
'id' => $product->ID,
|
||||
'title' => $product->ID . '# ' . $product->post_title,
|
||||
];
|
||||
$pushedIds[] = $product->ID;
|
||||
}
|
||||
|
||||
if ($ids) {
|
||||
$remaining = array_diff($ids, $pushedIds);
|
||||
if ($remaining) {
|
||||
$extraProducts = Product::query()->published()->whereIn('ID', $remaining)->get(['ID', 'post_title']);
|
||||
foreach ($extraProducts as $product) {
|
||||
$options[] = [
|
||||
'id' => $product->ID,
|
||||
'title' => $product->ID . '# ' . $product->post_title,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public static function getFluentCartCoupons($items, $search, $ids)
|
||||
{
|
||||
try {
|
||||
$coupons = Coupon::all(['id', 'title'])
|
||||
->map(function ($coupon) {
|
||||
return [
|
||||
'id' => $coupon->id,
|
||||
'title' => $coupon->title,
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
} catch (\Exception $e) {
|
||||
$coupons = [];
|
||||
}
|
||||
|
||||
return $coupons;
|
||||
}
|
||||
|
||||
public static function getFluentCartProductCategories($items, $search, $ids)
|
||||
{
|
||||
try {
|
||||
$taxonomies = Taxonomy::getTaxonomies();
|
||||
|
||||
$taxonomies = Collection::make($taxonomies)
|
||||
->map(function ($taxonomy) {
|
||||
return [
|
||||
'name' => $taxonomy,
|
||||
'label' => Str::headline($taxonomy),
|
||||
'terms' => Taxonomy::getFormattedTerms($taxonomy),
|
||||
];
|
||||
});
|
||||
|
||||
$categories = Collection::make($taxonomies['product-categories']['terms'])
|
||||
->map(function ($term) {
|
||||
return [
|
||||
'id' => $term['value'],
|
||||
'title' => $term['label'],
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
} catch (\Exception $e) {
|
||||
$categories = [];
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
public static function getProductCategoriesByIds($ids)
|
||||
{
|
||||
try {
|
||||
$products = Product::with('wp_terms')->whereIn('id', $ids)->get();
|
||||
|
||||
$categories = $products->flatMap(function ($product) {
|
||||
return $product->wp_terms->pluck('term_taxonomy_id');
|
||||
})->unique()->values()->toArray();
|
||||
} catch (\Exception $e) {
|
||||
$categories = [];
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
public static function getFluentCartSubscriptionProducts($items, $search, $ids)
|
||||
{
|
||||
$search = (string)$search;
|
||||
$ids = is_array($ids) ? $ids : [];
|
||||
|
||||
try {
|
||||
$variationQuery = ProductVariation::query()
|
||||
->where('payment_type', 'subscription')
|
||||
->where('item_status', 'active');
|
||||
|
||||
if ($search) {
|
||||
$variationQuery->where('variation_title', 'like', '%' . $search . '%');
|
||||
}
|
||||
|
||||
$productIds = $variationQuery->pluck('post_id')->unique()->slice(0, 50)->values();
|
||||
|
||||
$pushedIds = $productIds->toArray();
|
||||
if ($ids) {
|
||||
$appendIds = array_diff($ids, $pushedIds);
|
||||
if ($appendIds) {
|
||||
$productIds = $productIds->merge($appendIds);
|
||||
}
|
||||
}
|
||||
|
||||
if ($productIds->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$products = Product::query()
|
||||
->published()
|
||||
->whereIn('ID', $productIds->toArray())
|
||||
->orderBy('post_title')
|
||||
->get(['ID', 'post_title']);
|
||||
|
||||
$formatted = [];
|
||||
foreach ($products as $product) {
|
||||
$formatted[] = [
|
||||
'id' => $product->ID,
|
||||
'title' => $product->ID . '# ' . $product->post_title,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public static function prepareSubscriberData($customer)
|
||||
{
|
||||
if(!is_object($customer)) {
|
||||
$customer = (object) $customer;
|
||||
}
|
||||
return [
|
||||
'email' => $customer->email,
|
||||
'first_name' => $customer->first_name,
|
||||
'last_name' => $customer->last_name,
|
||||
'full_name' => $customer->first_name . ' ' . $customer->last_name,
|
||||
'user_id' => $customer->user_id,
|
||||
'postal_code' => $customer->postcode,
|
||||
'country' => $customer->country,
|
||||
'state' => $customer->state,
|
||||
'city' => $customer->city,
|
||||
'phone' => $customer->phone,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getCustomersByProductIds($productIds, $offset = 0, $limit = 100)
|
||||
{
|
||||
$customers = [];
|
||||
try {
|
||||
|
||||
$customers = Customer::query()->whereHas('success_order_items', function ($q) use ($productIds) {
|
||||
$q->whereIn('post_id', $productIds);
|
||||
})->offset($offset)->limit($limit)->get();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
|
||||
return $customers;
|
||||
}
|
||||
|
||||
public static function getPurchasedProductsByCustomerId($customerId)
|
||||
{
|
||||
$productIds = [];
|
||||
try {
|
||||
$orderIds = fluentCrmDb()->table('fct_orders')
|
||||
->where('customer_id', $customerId)
|
||||
->pluck('id');
|
||||
|
||||
$productIds = fluentCrmDb()->table('fct_order_items')
|
||||
->whereIn('order_id', $orderIds)
|
||||
->pluck('post_id');
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
|
||||
return $productIds;
|
||||
}
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart;
|
||||
|
||||
use Automattic\WooCommerce\Blocks\BlockTypes\Cart;
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Models\Tag;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
|
||||
class CartImporter
|
||||
{
|
||||
// Customers processed per request (page size)
|
||||
const PER_PAGE = 150;
|
||||
// public function __construct()
|
||||
// {
|
||||
// $this->importKey = 'fluent_cart';
|
||||
// parent::__construct();
|
||||
// }
|
||||
|
||||
|
||||
private static function getPluginName()
|
||||
{
|
||||
return 'FluentCart';
|
||||
}
|
||||
|
||||
// public function getInfo()
|
||||
// {
|
||||
// return [
|
||||
// 'label' => $this->getPluginName(),
|
||||
// 'logo' => fluentCrmMix('images/woo.svg'),
|
||||
// 'disabled' => false
|
||||
// ];
|
||||
// }
|
||||
|
||||
public static function processUserDriver($config, $request)
|
||||
{
|
||||
$summary = $request->get('summary');
|
||||
|
||||
if ($summary) {
|
||||
|
||||
// Initialize defaults to avoid undefined variables in fallback returns
|
||||
$formattedCustomers = [];
|
||||
$total = 0;
|
||||
|
||||
$config = $request->get('config');
|
||||
|
||||
$type = Arr::get($config, 'import_type');
|
||||
|
||||
if ($type == 'customers_sync') {
|
||||
// $customersQuery = fluentCrmDb()->table('wc_customer_lookup');
|
||||
// $total = $customersQuery->count();
|
||||
// $formattedUsers = $customersQuery->select(['first_name', 'last_name', 'email'])->limit(5)->get();
|
||||
|
||||
// foreach ($formattedUsers as $formattedUser) {
|
||||
// $formattedUser->name = trim($formattedUser->first_name . ' ' . $formattedUser->last_name);
|
||||
// }
|
||||
|
||||
} else if ($type == 'product_tags') {
|
||||
$productIds = [];
|
||||
foreach ($config['product_type_maps'] as $map) {
|
||||
$productIds[] = absint($map['field_key']);
|
||||
}
|
||||
|
||||
// get customers who purchased these products from fluent cart
|
||||
// get orderIds from
|
||||
|
||||
$customers = CartHelper::getCustomersByProductIds($productIds, 0, 100000);
|
||||
|
||||
$total = count($customers);
|
||||
|
||||
fluentcrm_update_option('_fluent_cart_product_tag_total_count', $total);
|
||||
|
||||
|
||||
$formattedCustomers = [];
|
||||
foreach ($customers as $customer) {
|
||||
$formattedCustomers[] = [
|
||||
'name' => $customer->first_name . ' ' . $customer->last_name,
|
||||
'email' => $customer->email
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'import_info' => [
|
||||
'subscribers' => $formattedCustomers,
|
||||
'total' => $total,
|
||||
'has_tag_config' => false,
|
||||
'has_list_config' => true,
|
||||
'has_status_config' => false,
|
||||
'has_update_config' => false,
|
||||
'has_silent_config' => true
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'import_info' => [
|
||||
'subscribers' => $formattedCustomers,
|
||||
'total' => $total,
|
||||
'has_tag_config' => true,
|
||||
'has_list_config' => true,
|
||||
'has_status_config' => true,
|
||||
'has_update_config' => false,
|
||||
'has_silent_config' => true
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$importType = 'customers_sync';
|
||||
|
||||
/* translators: %s: the external commerce plugin name */
|
||||
$importTitle = sprintf(__('Sync %s Customers Now', 'fluent-crm'), self::getPluginName());
|
||||
|
||||
if(defined('FLUENTCART_VERSION')) {
|
||||
$importType = 'product_tags';
|
||||
/* translators: %s: the external commerce plugin name */
|
||||
$importTitle = sprintf(__('Import %s Customers Now', 'fluent-crm'), self::getPluginName());
|
||||
}
|
||||
|
||||
$configFields = [
|
||||
'config' => [
|
||||
'import_type' => $importType,
|
||||
'product_type_maps' => [
|
||||
[
|
||||
'field_key' => '',
|
||||
'field_value' => ''
|
||||
]
|
||||
]
|
||||
],
|
||||
'fields' => [
|
||||
'product_type_maps' => [
|
||||
'label' => __('Please map your Product and associate FluentCRM Tags', 'fluent-crm'),
|
||||
'type' => 'form-many-drop-down-mapper',
|
||||
/* translators: %s: the external commerce plugin name */
|
||||
'local_label' => sprintf(__('Select %s Product', 'fluent-crm'), self::getPluginName()),
|
||||
'remote_label' => __('Select FluentCRM Tag that will be applied', 'fluent-crm'),
|
||||
/* translators: %s: the external commerce plugin name */
|
||||
'local_placeholder' => sprintf(__('Select %s Product', 'fluent-crm'), self::getPluginName()),
|
||||
'remote_placeholder' => __('Select FluentCRM Tag', 'fluent-crm'),
|
||||
'field_ajax_selector' => [
|
||||
'option_key' => 'fluent_cart_products'
|
||||
],
|
||||
'value_option_selector' => [
|
||||
'option_key' => 'tags',
|
||||
'creatable' => true
|
||||
],
|
||||
'dependency' => [
|
||||
'depends_on' => 'import_type',
|
||||
'operator' => '=',
|
||||
'value' => 'product_tags'
|
||||
]
|
||||
],
|
||||
'sync_import_html' => [
|
||||
'type' => 'html-viewer',
|
||||
'heading' => __('FluentCart Data Sync', 'fluent-crm'),
|
||||
'info' => __('You can sync all your FluentCart Customers into FluentCRM and all future customers and purchase data will be synced.', 'fluent-crm').'<br />'.__('After this sync you can import by product by product and provide appropriate tags', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'import_type',
|
||||
'operator' => '=',
|
||||
'value' => 'customers_sync'
|
||||
]
|
||||
]
|
||||
],
|
||||
'labels' => [
|
||||
'step_2' => __('Next [Review Data]', 'fluent-crm'),
|
||||
'step_3' => $importTitle
|
||||
]
|
||||
];
|
||||
|
||||
return $configFields;
|
||||
}
|
||||
|
||||
|
||||
public static function importData($returnData, $config, $page)
|
||||
{
|
||||
$inputs = Arr::only($config, [
|
||||
'lists', 'tags', 'status', 'double_optin_email', 'import_silently'
|
||||
]);
|
||||
|
||||
$inputs = wp_parse_args($inputs, [
|
||||
'lists' => [],
|
||||
'tags' => [],
|
||||
// keep backward compatibility but use `status` key everywhere
|
||||
'status' => 'subscribed',
|
||||
'double_optin_email' => 'no',
|
||||
'import_silently' => 'yes'
|
||||
]);
|
||||
|
||||
if (Arr::get($inputs, 'import_silently') == 'yes') {
|
||||
if (!defined('FLUENTCRM_DISABLE_TAG_LIST_EVENTS')) {
|
||||
define('FLUENTCRM_DISABLE_TAG_LIST_EVENTS', true);
|
||||
}
|
||||
}
|
||||
|
||||
$sendDoubleOptin = Arr::get($inputs, 'double_optin_email') == 'yes';
|
||||
$contactStatus = Arr::get($inputs, 'status', 'subscribed');
|
||||
|
||||
$productTagMaps = [];
|
||||
|
||||
$productIds = [];
|
||||
foreach ($config['product_type_maps'] as $map) {
|
||||
$productId = absint($map['field_key']);
|
||||
$productIds[] = $productId;
|
||||
if (!isset($productTagMaps[$productId])) {
|
||||
$productTagMaps[$productId] = [];
|
||||
}
|
||||
$productTagMaps[$productId][] = absint($map['field_value']);
|
||||
}
|
||||
|
||||
$productIds = array_unique($productIds);
|
||||
|
||||
|
||||
$startTime = time();
|
||||
$runTime = 20; // seconds
|
||||
|
||||
// normalize and initialize paging
|
||||
$perPage = self::PER_PAGE;
|
||||
$page = max(1, absint($page));
|
||||
|
||||
// reset counters on first page to start a fresh run
|
||||
if ($page === 1) {
|
||||
fluentcrm_update_option('_fluent_cart_sync_count', 0);
|
||||
fluentcrm_update_option('_fluent_cart_import_current_page', 1);
|
||||
}
|
||||
|
||||
$customers = CartHelper::getCustomersByProductIds($productIds, ($page - 1) * $perPage, $perPage);
|
||||
|
||||
// Even if the current page has no customers (e.g., last sparse page),
|
||||
// advance the page pointer to avoid getting stuck reprocessing the same page.
|
||||
if (empty($customers)) {
|
||||
fluentcrm_update_option('_fluent_cart_import_current_page', $page);
|
||||
return self::getSyncStatus();
|
||||
}
|
||||
|
||||
$importedCustomers = [];
|
||||
// pushing all customers without tags
|
||||
foreach ($customers as $customer) {
|
||||
$subscribers = CartHelper::prepareSubscriberData($customer);
|
||||
if($customer->user_id) {
|
||||
$subscribers = Helper::getWPMapUserInfo($customer->user_id);
|
||||
}
|
||||
Subscriber::import(
|
||||
[$subscribers],
|
||||
[],
|
||||
Arr::get($inputs, 'lists', []),
|
||||
true,
|
||||
$contactStatus,
|
||||
$sendDoubleOptin
|
||||
);
|
||||
|
||||
// keeping track of imported customers
|
||||
$purchasedProducts = self::purchasedProductsOfImportedCustomer($customer->id, $productIds);
|
||||
$assignedTags = self::mapCustomerTags($purchasedProducts, $productTagMaps);
|
||||
|
||||
$importedCustomers[] = [
|
||||
'customer_id' => $customer->id,
|
||||
'email' => $subscribers['email'],
|
||||
'user_id' => $customer->user_id,
|
||||
'purchased_products' => $purchasedProducts,
|
||||
'tags' => $assignedTags
|
||||
];
|
||||
|
||||
// update tags for the subscriber
|
||||
if ($assignedTags) {
|
||||
$importedSubscriber = Subscriber::where('email', $subscribers['email'])->first();
|
||||
if ($importedSubscriber) {
|
||||
$importedSubscriber->attachTags($assignedTags);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$totalSynced = count($importedCustomers) + (($page - 1) * $perPage);
|
||||
|
||||
fluentcrm_update_option('_fluent_cart_sync_count', $totalSynced);
|
||||
fluentcrm_update_option('_fluent_cart_import_current_page', $page);
|
||||
|
||||
// check time limit
|
||||
|
||||
if (time() - $startTime > $runTime) {
|
||||
return self::getSyncStatus();
|
||||
}
|
||||
|
||||
return self::getSyncStatus();
|
||||
}
|
||||
|
||||
|
||||
private static function mapCustomerTags($purchasedProducts, $productTagMaps)
|
||||
{
|
||||
$assignedTags = [];
|
||||
foreach ($purchasedProducts as $purchasedProduct) {
|
||||
if (isset($productTagMaps[$purchasedProduct])) {
|
||||
$assignedTags = array_merge($productTagMaps[$purchasedProduct], $assignedTags); //
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($assignedTags)); // return only unique tags
|
||||
}
|
||||
|
||||
private static function purchasedProductsOfImportedCustomer($customerId, $productIds)
|
||||
{
|
||||
$allProductsIds = CartHelper::getPurchasedProductsByCustomerId($customerId);
|
||||
// check in products
|
||||
// getPurchasedProductsByCustomerId() returns a Collection via pluck(), convert to array for array_intersect()
|
||||
return array_intersect($allProductsIds->toArray(), $productIds); // return only those products which are in productIds
|
||||
}
|
||||
|
||||
private static function getSyncStatus()
|
||||
{
|
||||
$total = fluentcrm_get_option('_fluent_cart_product_tag_total_count', 0);
|
||||
$perPage = self::PER_PAGE;
|
||||
|
||||
// Calculate page-based progress for the frontend
|
||||
$totalPages = max(1, (int) ceil($total / max(1, $perPage)));
|
||||
$currentPage = (int) fluentcrm_get_option('_fluent_cart_import_current_page', 1);
|
||||
$currentPage = max(1, min($currentPage, $totalPages));
|
||||
|
||||
$hasMore = $currentPage < $totalPages;
|
||||
|
||||
return [
|
||||
// Total number of pages to import
|
||||
'page_total' => $totalPages,
|
||||
// Total records for reference
|
||||
'record_total' => $total,
|
||||
'has_more' => $hasMore,
|
||||
'current_page' => $currentPage,
|
||||
'next_page' => $hasMore ? ($currentPage + 1) : 0,
|
||||
'reload_page' => !$hasMore
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart;
|
||||
|
||||
use FluentCart\App\Helpers\AddressHelper;
|
||||
use FluentCrm\App\Services\AutoSubscribe;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
/**
|
||||
* Adds a newsletter opt-in checkbox to the FluentCart checkout form and
|
||||
* subscribes the customer to FluentCRM when the order is paid.
|
||||
*
|
||||
* Controlled by the 'FluentCart Checkout Subscription Field' panel in
|
||||
* FluentCRM -> Settings -> General Settings (option key:
|
||||
* fluent_cart_checkout_form_subscribe_settings, defined in the base plugin's
|
||||
* AutoSubscribe service).
|
||||
*/
|
||||
class CheckoutSubscription
|
||||
{
|
||||
/**
|
||||
* Form field name posted with the checkout request and the
|
||||
* order meta key holding the captured opt-in state.
|
||||
*/
|
||||
const OPTIN_FIELD = '_fc_cart_checkout_subscribe';
|
||||
|
||||
/**
|
||||
* Order meta flag preventing the same order from being processed twice.
|
||||
*/
|
||||
const PROCESSED_META = '_fc_cart_checkout_optin_processed';
|
||||
|
||||
public function init()
|
||||
{
|
||||
// Renders inside the checkout <form> on both the standard checkout
|
||||
// page and the modal checkout (FormData picks the value up on submit)
|
||||
add_action('fluent_cart/before_payment_methods', [$this, 'renderOptinCheckbox']);
|
||||
|
||||
// Fires while the order is being created from the checkout request —
|
||||
// the only point where the raw POST data is available
|
||||
add_action('fluent_cart/checkout/prepare_other_data', [$this, 'captureOptinState']);
|
||||
|
||||
// Async (Action Scheduler) hook recommended by FluentCart for
|
||||
// third-party post-payment processing
|
||||
add_action('fluent_cart/order_paid_done', [$this, 'maybeSubscribeContact'], 20);
|
||||
}
|
||||
|
||||
protected function getSettings()
|
||||
{
|
||||
return (new AutoSubscribe())->getFluentCartCheckoutSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the opt-in checkbox above the payment methods section,
|
||||
* using FluentCart's native checkbox markup classes.
|
||||
*/
|
||||
public function renderOptinCheckbox($data)
|
||||
{
|
||||
$settings = $this->getSettings();
|
||||
|
||||
if (Arr::get($settings, 'status') != 'yes') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Arr::get($settings, 'show_only_new') == 'yes') {
|
||||
$contact = fluentcrm_get_current_contact();
|
||||
if ($contact && $contact->status == 'subscribed') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$label = Arr::get($settings, 'checkbox_label');
|
||||
if (!$label) {
|
||||
$label = __('Sign me up for the newsletter!', 'fluent-crm');
|
||||
}
|
||||
|
||||
$isChecked = Arr::get($settings, 'auto_checked') == 'yes';
|
||||
?>
|
||||
<div class="fct_checkout_form_section fcrm_checkout_subscribe" data-fct-checkout-form-section>
|
||||
<div class="fct_form_section_body">
|
||||
<label class="fct_input_label fct_input_label_checkbox" for="fcrm_cart_subscribe">
|
||||
<input type="checkbox" class="fct-input fct-input-checkbox" id="fcrm_cart_subscribe"
|
||||
name="<?php echo esc_attr(self::OPTIN_FIELD); ?>" value="1" <?php checked($isChecked); ?>>
|
||||
<?php echo esc_html($label); ?>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the posted opt-in state to order meta so the async
|
||||
* order-paid handler can read it later. Missing value = unchecked.
|
||||
*/
|
||||
public function captureOptinState($eventData)
|
||||
{
|
||||
$order = Arr::get($eventData, 'order');
|
||||
|
||||
if (!$order || !is_object($order)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = $this->getSettings();
|
||||
if (Arr::get($settings, 'status') != 'yes') {
|
||||
return;
|
||||
}
|
||||
|
||||
$isChecked = Arr::get((array)Arr::get($eventData, 'request_data', []), self::OPTIN_FIELD) == '1';
|
||||
|
||||
$order->updateMeta(self::OPTIN_FIELD, $isChecked ? '1' : '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates/updates the FluentCRM contact when a paid order has the
|
||||
* opt-in flag. Runs async via fluent_cart/order_paid_done. Idempotent —
|
||||
* payment retries or hook re-runs will not duplicate processing.
|
||||
*/
|
||||
public function maybeSubscribeContact($eventData)
|
||||
{
|
||||
$order = Arr::get($eventData, 'order');
|
||||
$customer = Arr::get($eventData, 'customer');
|
||||
|
||||
if (!$order || !$customer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Treat both the final marker and an in-flight claim as processed so
|
||||
// serial retries of the paid-order event (e.g. Action Scheduler
|
||||
// re-runs) cannot double-process. This read-then-write guard is not
|
||||
// atomic, so it does not protect against truly concurrent invocations
|
||||
$processedState = $order->getMeta(self::PROCESSED_META);
|
||||
if ($processedState == 'yes' || $processedState == 'processing') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($order->getMeta(self::OPTIN_FIELD) != '1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$settings = $this->getSettings();
|
||||
if (Arr::get($settings, 'status') != 'yes') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$email = sanitize_email($customer->email);
|
||||
if (!$email || !is_email($email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$orderBillingAddress = $order->billing_address;
|
||||
|
||||
$address1 = Arr::get($orderBillingAddress, 'address_1', '');
|
||||
$address2 = Arr::get($orderBillingAddress, 'address_2', '');
|
||||
|
||||
$state = AddressHelper::getStateNameByCode($customer->state, $customer->country);
|
||||
|
||||
$subscriberData = [
|
||||
'first_name' => sanitize_text_field($customer->first_name),
|
||||
'last_name' => sanitize_text_field($customer->last_name),
|
||||
'country' => sanitize_text_field($customer->country),
|
||||
'state' => sanitize_text_field($state),
|
||||
'city' => sanitize_text_field($customer->city),
|
||||
'postal_code' => sanitize_text_field($customer->postcode),
|
||||
'address_line_1' => sanitize_text_field($address1),
|
||||
'address_line_2' => sanitize_text_field($address2),
|
||||
'email' => $email
|
||||
];
|
||||
|
||||
if ($listId = Arr::get($settings, 'target_list')) {
|
||||
$subscriberData['lists'] = [$listId];
|
||||
}
|
||||
|
||||
if ($tags = Arr::get($settings, 'target_tags')) {
|
||||
$subscriberData['tags'] = $tags;
|
||||
}
|
||||
|
||||
if (Arr::get($settings, 'double_optin') == 'yes') {
|
||||
$subscriberData['status'] = 'pending';
|
||||
} else {
|
||||
$subscriberData['status'] = 'subscribed';
|
||||
}
|
||||
|
||||
$subscriberData = apply_filters('fluent_crm/fluent_cart_checkout_auto_subscribe_data', $subscriberData, $order);
|
||||
|
||||
// Claim the order before contact creation / email side effects so an
|
||||
// Action Scheduler retry re-entering this handler cannot run them twice
|
||||
$order->updateMeta(self::PROCESSED_META, 'processing');
|
||||
|
||||
try {
|
||||
$contact = FunnelHelper::createOrUpdateContact($subscriberData);
|
||||
|
||||
if (!$contact) {
|
||||
// Release the claim so a later retry can attempt processing again
|
||||
$order->updateMeta(self::PROCESSED_META, '0');
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($contact->status == 'pending') {
|
||||
$contact->sendDoubleOptinEmail();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Release the claim on failure too — otherwise the order would be
|
||||
// stuck in 'processing' and permanently skipped by the guard above
|
||||
$order->updateMeta(self::PROCESSED_META, '0');
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$order->updateMeta(self::PROCESSED_META, 'yes');
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart;
|
||||
|
||||
use FluentCart\Api\ModuleSettings;
|
||||
use FluentCart\App\Helpers\Helper;
|
||||
use FluentCart\App\Models\Customer;
|
||||
use FluentCart\App\Models\Order;
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\Benchmarks\OrderSuccessBenchmark;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\SmartCode\SmartCodeParser;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\SmartCode\SmartCodeRegister;
|
||||
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers\OrderCanceledTrigger;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers\OrderPaidTrigger;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers\OrderDeliveredTrigger;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers\OrderRefundedTrigger;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers\OrderShippedTrigger;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers\OrderStatusChangedTrigger;
|
||||
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers\SubscriptionActivatedTrigger;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers\SubscriptionCancelledTrigger;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers\SubscriptionEndOfTermTrigger;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers\SubscriptionExpiredTrigger;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers\SubscriptionRenewedTrigger;
|
||||
|
||||
|
||||
class FluentCart
|
||||
{
|
||||
public function init()
|
||||
{
|
||||
$this->addAutomations();
|
||||
$this->addHooks();
|
||||
|
||||
SmartCodeRegister::push();
|
||||
|
||||
(new RevenueTracker())->init();
|
||||
}
|
||||
|
||||
public function addAutomations()
|
||||
{
|
||||
new OrderPaidTrigger();
|
||||
new OrderShippedTrigger();
|
||||
new OrderDeliveredTrigger();
|
||||
new OrderRefundedTrigger();
|
||||
new OrderCanceledTrigger();
|
||||
|
||||
// new OrderStatusChangedTrigger();
|
||||
new SubscriptionExpiredTrigger();
|
||||
|
||||
//subscription activated
|
||||
new SubscriptionActivatedTrigger();
|
||||
|
||||
//subscription cancelled
|
||||
new SubscriptionCancelledTrigger();
|
||||
|
||||
//subscription renewed
|
||||
new SubscriptionRenewedTrigger();
|
||||
|
||||
//subscription end of term(completed)
|
||||
new SubscriptionEndOfTermTrigger();
|
||||
|
||||
|
||||
// Goals
|
||||
new OrderSuccessBenchmark();
|
||||
}
|
||||
|
||||
public function addHooks()
|
||||
{
|
||||
|
||||
add_filter('fluent_crm/get_import_driver_fluent_cart', [CartImporter::class, 'processUserDriver'], 10, 2);
|
||||
add_filter('fluent_crm/post_import_driver_fluent_cart', [CartImporter::class, 'importData'], 10, 3);
|
||||
|
||||
add_filter('fluentcrm_ajax_options_fluent_cart_products', [$this, 'getProducts'], 10, 3);
|
||||
add_filter('fluentcrm_ajax_options_fluent_cart_product_categories', [$this, 'getProductCategories'], 10, 3);
|
||||
add_filter('fluentcrm_ajax_options_fluent_cart_subscription_products', [$this, 'getSubscriptionProducts'], 10, 3);
|
||||
|
||||
add_filter('fluent_crm/funnel_icons', [$this, 'addCartIcon'], 10 , 1);
|
||||
add_filter('fluent_crm/purchase_history_fluent_cart', [$this, 'purchaseHistory'], 10, 2);
|
||||
|
||||
add_filter('fluent_crm/smartcode_group_callback_cart_order', [SmartCodeParser::class, 'parseCartOrder'], 10, 4);
|
||||
add_filter('fluent_crm/smartcode_group_callback_cart_customer', [SmartCodeParser::class, 'parseCartCustomer'], 10, 4);
|
||||
// add_filter('fluent_crm/smartcode_group_callback_cart_transaction', [SmartCodeParser::class, 'parseCartTransaction'], 10, 4);
|
||||
add_filter('fluent_crm/smartcode_group_callback_cart_receipt', [SmartCodeParser::class, 'parseCartReceipt'], 10, 4);
|
||||
|
||||
|
||||
add_filter('fluentcrm_automation_condition_groups', array($this, 'addAutomationConditions'), 10, 2);
|
||||
add_filter('fluentcrm_automation_conditions_assess_fluent_cart', array($this, 'assessAutomationConditions'), 10, 3);
|
||||
// add_filter('fluentcrm_automation_conditions_assess_woo_order', array($this, 'assessAutomationOrderConditions'), 10, 5);
|
||||
}
|
||||
|
||||
public function getProducts($items, $search, $ids)
|
||||
{
|
||||
return CartHelper::getFluentCartProducts($items, $search, $ids);
|
||||
}
|
||||
|
||||
public function getProductCategories($items, $search, $ids)
|
||||
{
|
||||
return CartHelper::getFluentCartProductCategories($items, $search, $ids);
|
||||
}
|
||||
|
||||
public function getSubscriptionProducts($items, $search, $ids)
|
||||
{
|
||||
return CartHelper::getFluentCartSubscriptionProducts($items, $search, $ids);
|
||||
}
|
||||
|
||||
public function addCartIcon($icons)
|
||||
{
|
||||
$icons['fluentcart'] = [
|
||||
'svg' => '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="18" height="18" rx="1.8" fill="#00009F"/><path d="M9.19408 13.3557H3.82861L4.67063 11.4086C4.91784 10.8369 5.48112 10.4668 6.10395 10.4668H12.4955L12.0607 11.4722C11.5663 12.6155 10.4397 13.3557 9.19408 13.3557Z" fill="white"/><path d="M13.6389 9.54518H6.0918L6.52656 8.5398C7.02098 7.39646 8.14753 6.65625 9.39319 6.65625H15.9142L15.0722 8.60341C14.825 9.17507 14.2617 9.54518 13.6389 9.54518Z" fill="white"/></svg>',
|
||||
];
|
||||
return $icons;
|
||||
}
|
||||
|
||||
public function purchaseHistory($data, $subscriber)
|
||||
{
|
||||
$customer = Customer::where('email', $subscriber->email)->first();
|
||||
if (!$customer) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ordersQuery = Order::with('appliedCoupons')->where('customer_id', $customer->id);
|
||||
$totalCount = $ordersQuery->count();
|
||||
|
||||
if (!$totalCount) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Pagination params (using super global to avoid dependency on request wrapper here)
|
||||
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
||||
if ($page < 1) { $page = 1; }
|
||||
$perPage = isset($_GET['per_page']) ? (int)$_GET['per_page'] : 10;
|
||||
if ($perPage < 1) { $perPage = 10; }
|
||||
|
||||
$orders = $ordersQuery
|
||||
->orderBy('id', 'DESC')
|
||||
->limit($perPage)
|
||||
->offset(($page - 1) * $perPage)
|
||||
->get();
|
||||
|
||||
// Use a helper method for formatting
|
||||
$formattedOrders = $this->formatOrders($orders);
|
||||
|
||||
return [
|
||||
'data' => $formattedOrders,
|
||||
'total' => $totalCount,
|
||||
'sidebar_html' => $this->getSidebarHtml($subscriber),
|
||||
'after_html' => '',
|
||||
'has_recount' => false,
|
||||
'columns_config' => [
|
||||
'order' => ['label' => __('Order', 'fluent-crm'), 'width' => '100px', 'sortable' => true, 'key' => 'id'],
|
||||
'date' => ['label' => __('Date', 'fluent-crm'), 'sortable' => true, 'key' => 'created_at'],
|
||||
// 'coupon' => ['label' => __('Coupon', 'fluent-crm'), 'sortable' => true, 'key' => 'created_at'],
|
||||
'status' => ['label' => __('Status', 'fluent-crm'), 'width' => '140px', 'sortable' => false],
|
||||
// 'payment' => ['label' => __('Payment', 'fluent-crm'), 'width' => '140px', 'sortable' => false, 'key' => 'payment_status'],
|
||||
'total' => ['label' => __('Total', 'fluent-crm'), 'width' => '120px', 'sortable' => true, 'key' => 'total'],
|
||||
'action' => ['label' => __('', 'fluent-crm'), 'width' => '100px', 'sortable' => false],
|
||||
],
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
private function formatOrders($orders)
|
||||
{
|
||||
$formattedOrders = [];
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$orderActionHtml = '<a target="_blank" href="' . admin_url('admin.php?page=fluent-cart#/orders/' . $order->id . '/view') . '">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.5 5.5V7H4.75V15.25H13V11.5H14.5V16C14.5 16.1989 14.421 16.3897 14.2803 16.5303C14.1397 16.671 13.9489 16.75 13.75 16.75H4C3.80109 16.75 3.61032 16.671 3.46967 16.5303C3.32902 16.3897 3.25 16.1989 3.25 16V6.25C3.25 6.05109 3.32902 5.86032 3.46967 5.71967C3.61032 5.57902 3.80109 5.5 4 5.5H8.5ZM16.75 3.25V9.25H15.25V5.80975L9.40525 11.6553L8.34475 10.5948L14.1888 4.75H10.75V3.25H16.75Z" fill="#525866"/>
|
||||
</svg>
|
||||
</a>';
|
||||
$coupons = implode(', ', array_column($order['appliedCoupons']->toArray(), 'code'));
|
||||
|
||||
$date = '<span class="order_id">#' . $order->id .'</span><span class="order_date">'.date_i18n(get_option('date_format'), strtotime($order->created_at)).'</span>';
|
||||
$status = '<span class="fcrm_badge fcrm_badge_'.esc_attr($order->status).'">'. \FluentCrm\App\Services\Helper::getStatusText($order->status) .'</span>';
|
||||
|
||||
$formattedOrders[] = [
|
||||
'date' => $date,
|
||||
'status' => $status,
|
||||
// 'payment' => $order->payment_status,
|
||||
// 'coupons' => $coupons,
|
||||
'total' => Helper::toDecimal($order->total_amount), // Adjust if using a different helper
|
||||
'action' => $orderActionHtml,
|
||||
];
|
||||
}
|
||||
|
||||
return $formattedOrders;
|
||||
}
|
||||
|
||||
private function getSidebarHtml($subscriber = null)
|
||||
{
|
||||
// We will build a similar widget like WooCommerce's purchase sidebar
|
||||
// Show a quick customer summary + recently purchased products (flat list of items)
|
||||
$customer = null;
|
||||
if ($subscriber && !empty($subscriber->email)) {
|
||||
$customer = Customer::where('email', $subscriber->email)->first();
|
||||
}
|
||||
|
||||
if (!$customer) {
|
||||
// Fallback: Just return the static block as before
|
||||
return '<div class="fluent-crm-sidebar-content">'
|
||||
. '<h3>' . __('Product History', 'fluent-crm') . '</h3>'
|
||||
. '<p>' . __('View your purchase history from FluentCart.', 'fluent-crm') . '</p>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
// Aggregate order stats
|
||||
$ordersQuery = Order::where('customer_id', $customer->id);
|
||||
$orderCount = (clone $ordersQuery)->count();
|
||||
|
||||
if (!$orderCount) {
|
||||
return '<div class="fluent-crm-sidebar-content">'
|
||||
. '<h3>' . __('Product History', 'fluent-crm') . '</h3>'
|
||||
. '<p>' . __('No purchases found for this contact in FluentCart.', 'fluent-crm') . '</p>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
$totalSpent = (clone $ordersQuery)->sum('total_amount');
|
||||
$firstOrder = (clone $ordersQuery)->orderBy('id', 'ASC')->value('created_at');
|
||||
$lastOrder = (clone $ordersQuery)->orderBy('id', 'DESC')->value('created_at');
|
||||
|
||||
// Fetch recent purchased items (latest 15 order items across latest orders)
|
||||
// We'll eager load order_items for performance
|
||||
$recentOrders = (clone $ordersQuery)
|
||||
->with(['order_items' => function($q){
|
||||
$q->orderBy('id', 'DESC');
|
||||
}])
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(200)
|
||||
->get();
|
||||
|
||||
$items = [];
|
||||
foreach ($recentOrders as $order) {
|
||||
foreach ($order->order_items as $orderItem) {
|
||||
|
||||
$itemDisplayName = $orderItem->post_title;
|
||||
|
||||
if($orderItem->post_title != $orderItem->title) {
|
||||
$itemDisplayName = $orderItem->post_title . ' - ' . $orderItem->title;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'name' => $itemDisplayName,
|
||||
'price' => isset($orderItem->line_total) ? Helper::toDecimal($orderItem->line_total) : 0,
|
||||
'created_at' => $order->created_at,
|
||||
'order_id' => $order->id,
|
||||
];
|
||||
if (count($items) >= 15) {
|
||||
break 2; // Exit both loops once we have enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$html = '<div class="fluent-crm-sidebar-content fc_payment_summary">';
|
||||
$html .= '<h3 class="history_title">' . __('Order Summary', 'fluent-crm') . '</h3>';
|
||||
$html .= '<div class="fc_history_widget"><ul class="fc_full_listed">';
|
||||
$html .= '<li><span class="fc_list_sub">' . __('Total Orders', 'fluent-crm') . '</span><span class="fc_list_value">' . intval($orderCount) . '</span></li>';
|
||||
$html .= '<li><span class="fc_list_sub">' . __('Total Spent', 'fluent-crm') . '</span><span class="fc_list_value">' . esc_html(Helper::toDecimal($totalSpent)) . '</span></li>';
|
||||
if ($firstOrder) {
|
||||
$html .= '<li><span class="fc_list_sub">' . __('First Order', 'fluent-crm') . '</span><span class="fc_list_value">' . date_i18n(get_option('date_format'), strtotime($firstOrder)) . '</span></li>';
|
||||
}
|
||||
if ($lastOrder) {
|
||||
$html .= '<li><span class="fc_list_sub">' . __('Last Order', 'fluent-crm') . '</span><span class="fc_list_value">' . date_i18n(get_option('date_format'), strtotime($lastOrder)) . '</span></li>';
|
||||
}
|
||||
$html .= '</ul></div>';
|
||||
|
||||
$html .= '<h3 class="history_title">' . __('Purchased Products', 'fluent-crm') . '</h3>';
|
||||
$html .= '<div class="fc_history_widget"><ul class="fc_full_listed max_height_550">';
|
||||
foreach ($items as $item) {
|
||||
$orderUrl = admin_url('admin.php?page=fluent-cart#/orders/' . $item['order_id'] . '/view');
|
||||
$badges = '<span class="el-tag el-tag--primary">' . esc_html(Helper::toDecimal($item['price'])) . '</span>';
|
||||
$badges .= '<span class="el-tag el-tag--primary"><a target="_blank" rel="noopener" href="' . esc_url($orderUrl) . '">' . date_i18n(get_option('date_format'), strtotime($item['created_at'])) . '</a></span>';
|
||||
$html .= '<li class="fc_product_name">' . esc_html($item['name']) . ' ' . $badges . '</li>';
|
||||
}
|
||||
if (!$items) {
|
||||
$html .= '<li>' . __('No purchased products found.', 'fluent-crm') . '</li>';
|
||||
}
|
||||
$html .= '</ul></div>';
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
|
||||
public function addAutomationConditions($groups)
|
||||
{
|
||||
$conditionItems = [
|
||||
[
|
||||
'value' => 'commerce_exist',
|
||||
'label' => __('Is a customer?', 'fluent-crm'),
|
||||
'type' => 'selections',
|
||||
'is_multiple' => false,
|
||||
'disable_values' => true,
|
||||
'value_description' => __('This filter will check if a contact has at least one shop order or not', 'fluent-crm'),
|
||||
'custom_operators' => [
|
||||
'exist' => __('Yes', 'fluent-crm'),
|
||||
'not_exist' => __('No', 'fluent-crm'),
|
||||
]
|
||||
],
|
||||
[
|
||||
'value' => 'ltv',
|
||||
'label' => __('Lifetime Value', 'fluent-crm'),
|
||||
'type' => 'numeric'
|
||||
],
|
||||
[
|
||||
'value' => 'aov',
|
||||
'label' => __('Average Order Value', 'fluent-crm'),
|
||||
'type' => 'numeric',
|
||||
],
|
||||
[
|
||||
'value' => 'first_purchase_date',
|
||||
'label' => __('First Order Date', 'fluent-crm'),
|
||||
'type' => 'dates'
|
||||
],
|
||||
[
|
||||
'value' => 'last_purchase_date',
|
||||
'label' => __('Last Order Date', 'fluent-crm'),
|
||||
'type' => 'dates'
|
||||
],
|
||||
[
|
||||
'value' => 'purchased_items',
|
||||
'label' => __('Products', 'fluent-crm'),
|
||||
'type' => 'selections',
|
||||
'component' => 'product_selector',
|
||||
'is_multiple' => true,
|
||||
'custom_operators' => [
|
||||
'exist' => __('purchased', 'fluent-crm'),
|
||||
'not_exist' => __('not purchased', 'fluent-crm'),
|
||||
],
|
||||
'help' => __('Will filter the contacts who have at least one order', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'value' => 'variation_purchased',
|
||||
'label' => __('Product Variations', 'fluent-crm'),
|
||||
'type' => 'cascade_selections',
|
||||
'provider' => 'fct_variations',
|
||||
'is_multiple' => true,
|
||||
'value_description' => __('This filter will check if a contact has purchased at least one specific product variation or not', 'fluent-crm'),
|
||||
'custom_operators' => [
|
||||
'exist' => __('purchased', 'fluent-crm'),
|
||||
'not_exist' => __('not purchased', 'fluent-crm'),
|
||||
]
|
||||
],
|
||||
[
|
||||
'value' => 'purchased_categories',
|
||||
'label' => __('Product Categories', 'fluent-crm'),
|
||||
'type' => 'selections',
|
||||
'component' => 'tax_selector',
|
||||
'taxonomy' => 'product-categories',
|
||||
'is_multiple' => true,
|
||||
'disabled' => true,
|
||||
'help' => __('Will filter the contacts who have at least one order', 'fluent-crm'),
|
||||
'custom_operators' => [
|
||||
'exist' => __('purchased', 'fluent-crm'),
|
||||
'not_exist' => __('not purchased', 'fluent-crm'),
|
||||
]
|
||||
],
|
||||
[
|
||||
'value' => 'commerce_coupons',
|
||||
'label' => __('Used Coupons', 'fluent-crm'),
|
||||
'type' => 'selections',
|
||||
'component' => 'ajax_selector',
|
||||
'option_key' => 'fct_coupons',
|
||||
'is_multiple' => true,
|
||||
'disabled' => true,
|
||||
'custom_operators' => [
|
||||
'exist' => __('in', 'fluent-crm'),
|
||||
'not_exist' => __('not in', 'fluent-crm'),
|
||||
],
|
||||
'help' => __('Will filter the contacts who have at least one order', 'fluent-crm')
|
||||
]
|
||||
];
|
||||
|
||||
if (ModuleSettings::isActive('license')) {
|
||||
$conditionItems[] = [
|
||||
'value' => 'active_licenses',
|
||||
'label' => __('Active Licenses', 'fluent-crm'),
|
||||
'type' => 'selections',
|
||||
'component' => 'product_selector',
|
||||
'is_multiple' => true,
|
||||
'custom_operators' => [
|
||||
'exist' => __('have', 'fluent-crm'),
|
||||
'not_exist' => __('do not have', 'fluent-crm'),
|
||||
],
|
||||
'help' => __('Will filter the contacts who have at least one active licenses or not', 'fluent-crm')
|
||||
];
|
||||
$conditionItems[] = [
|
||||
'value' => 'active_variation_licenses',
|
||||
'label' => __('Active Variation Licenses', 'fluent-crm'),
|
||||
'type' => 'cascade_selections',
|
||||
'provider' => 'fct_variations',
|
||||
'is_multiple' => true,
|
||||
'value_description' => __('This filter will check if a contact has at least one specific variation license or not', 'fluent-crm'),
|
||||
'custom_operators' => [
|
||||
'exist' => __('have', 'fluent-crm'),
|
||||
'not_exist' => __('do not have', 'fluent-crm'),
|
||||
]
|
||||
];
|
||||
$conditionItems[] = [
|
||||
'value' => 'expired_licenses',
|
||||
'label' => __('Expired Licenses', 'fluent-crm'),
|
||||
'type' => 'selections',
|
||||
'component' => 'product_selector',
|
||||
'is_multiple' => true,
|
||||
'custom_operators' => [
|
||||
'exist' => __('have', 'fluent-crm'),
|
||||
'not_exist' => __('do not have', 'fluent-crm'),
|
||||
],
|
||||
'help' => __('Will filter the contacts who have at least one expired licenses or not', 'fluent-crm')
|
||||
];
|
||||
$conditionItems[] = [
|
||||
'value' => 'expired_variation_licenses',
|
||||
'label' => __('Expired Variation Licenses', 'fluent-crm'),
|
||||
'type' => 'cascade_selections',
|
||||
'provider' => 'fct_variations',
|
||||
'is_multiple' => true,
|
||||
'value_description' => __('This filter will check if a contact has at least one specific variation expired license or not', 'fluent-crm'),
|
||||
'custom_operators' => [
|
||||
'exist' => __('have', 'fluent-crm'),
|
||||
'not_exist' => __('do not have', 'fluent-crm'),
|
||||
]
|
||||
];
|
||||
$conditionItems[] = [
|
||||
'value' => 'license_exist',
|
||||
'label' => __('Has any active license?', 'fluent-crm'),
|
||||
'type' => 'selections',
|
||||
'is_multiple' => false,
|
||||
'disable_values' => true,
|
||||
'value_description' => __('Check if contacts has any active license from any products', 'fluent-crm'),
|
||||
'custom_operators' => [
|
||||
'exist' => __('Yes', 'fluent-crm'),
|
||||
'not_exist' => __('No', 'fluent-crm'),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$groups['fluent_cart'] = [
|
||||
'label' => __('FluentCart', 'fluent-crm'),
|
||||
'value' => 'fluent_cart',
|
||||
'children' => $conditionItems
|
||||
];
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
public function assessAutomationConditions($result, $conditions, $subscriber)
|
||||
{
|
||||
$legacyConditions = [];
|
||||
// if (Commerce::isEnabled('woo')) {
|
||||
$formattedConditions = [];
|
||||
|
||||
$commerceProps = [
|
||||
'commerce_exist',
|
||||
'ltv', // lifetime value
|
||||
'aov', // average order value
|
||||
'first_purchase_date',
|
||||
'last_purchase_date',
|
||||
'purchased_items', // products purchased
|
||||
'variation_purchased', // product variations purchased
|
||||
'purchased_categories', // product categories
|
||||
'commerce_coupons', // used coupons
|
||||
];
|
||||
|
||||
foreach ($conditions as $condition) {
|
||||
$prop = $condition['data_key'];
|
||||
$operator = $condition['operator'];
|
||||
if (in_array($prop, $commerceProps)) {
|
||||
$formattedConditions[] = [
|
||||
'operator' => $operator,
|
||||
'value' => $condition['data_value'],
|
||||
'property' => $prop,
|
||||
];
|
||||
} else {
|
||||
$legacyConditions[] = $condition;
|
||||
}
|
||||
}
|
||||
|
||||
if ($formattedConditions) {
|
||||
$hasSubscriber = Subscriber::where('id', $subscriber->id)->where(function ($q) use ($formattedConditions) {
|
||||
do_action_ref_array('fluentcrm_contacts_filter_fluent_cart', [&$q, $formattedConditions]);
|
||||
})->first();
|
||||
if (!$hasSubscriber) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// } else {
|
||||
// $legacyConditions = $conditions;
|
||||
// }
|
||||
|
||||
if ($legacyConditions) {
|
||||
$cartCustomer = Customer::query()
|
||||
->where('email', $subscriber->email)
|
||||
->when($subscriber->user_id, function ($q) use ($subscriber) {
|
||||
return $q->orWhere('user_id', $subscriber->user_id);
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$cartCustomer) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart;
|
||||
|
||||
use FluentCrm\App\Models\Campaign;
|
||||
use FluentCrm\App\Services\Helper as FluentCrmHelper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
/**
|
||||
* Stamps the originating campaign id (`_fc_cid` cookie set by RedirectionHandler when an
|
||||
* email link is clicked) onto each FluentCart order at creation time, then rolls the
|
||||
* paid total into the `_campaign_revenue` campaign meta when the order is paid.
|
||||
*
|
||||
* This mirrors the WooCommerce/EDD revenue attribution flow used by
|
||||
* CampaignAnalyticsController so the existing campaign analytics UI works for FluentCart.
|
||||
*/
|
||||
class RevenueTracker
|
||||
{
|
||||
public function init()
|
||||
{
|
||||
add_action('fluent_cart/order_created', [$this, 'attributeOrder'], 10, 1);
|
||||
add_action('fluent_cart/order_paid', [$this, 'recordRevenue'], 10, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the originating campaign id on the order while we still have access to
|
||||
* the visitor's `fc_cid` cookie (the request runs in the user's browser context here;
|
||||
* by the time `order_paid` fires from a gateway webhook the cookie is gone).
|
||||
*/
|
||||
public function attributeOrder($eventData)
|
||||
{
|
||||
$cid = $this->getCampaignIdFromCookie();
|
||||
if (!$cid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = Arr::get($eventData, 'order');
|
||||
if (!$order || empty($order->id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($order->getMeta('_fc_cid')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order->updateMeta('_fc_cid', $cid);
|
||||
}
|
||||
|
||||
/**
|
||||
* On payment success, accumulate this order's total into the campaign's
|
||||
* `_campaign_revenue` meta. Helper::recordCampaignRevenue handles dedup
|
||||
* (same order_id won't be counted twice) and per-currency bucketing.
|
||||
*/
|
||||
public function recordRevenue($eventData)
|
||||
{
|
||||
$order = Arr::get($eventData, 'order');
|
||||
if (!$order || empty($order->id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cid = (int) $order->getMeta('_fc_cid');
|
||||
if (!$cid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// FluentCart stores monetary amounts as integer cents — same convention the
|
||||
// Woo re-sync uses when writing into `_campaign_revenue`.
|
||||
$amountCents = (int) $order->total_amount;
|
||||
if ($amountCents <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currency = $order->currency ? strtoupper($order->currency) : 'USD';
|
||||
|
||||
FluentCrmHelper::recordCampaignRevenue($cid, $amountCents, $order->id, $currency);
|
||||
}
|
||||
|
||||
private function getCampaignIdFromCookie()
|
||||
{
|
||||
if (empty($_COOKIE['fc_cid'])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$cid = (int) $_COOKIE['fc_cid'];
|
||||
if ($cid <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$exists = Campaign::withoutGlobalScopes()->where('id', $cid)->exists();
|
||||
|
||||
return $exists ? $cid : 0;
|
||||
}
|
||||
}
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\SmartCode;
|
||||
|
||||
use FluentCart\App\Helpers\Helper;
|
||||
use FluentCart\App\Models\Order;
|
||||
use FluentCart\App\Services\Payments\PaymentReceipt;
|
||||
use FluentCart\App\Services\URL;
|
||||
use FluentCrm\App\Models\FunnelSubscriber;
|
||||
use FluentCart\Api\Resource\OrderResource;
|
||||
|
||||
class SmartCodeParser {
|
||||
|
||||
public static function parseCartOrder($code, $valueKey, $defaultValue, $subscriber)
|
||||
{
|
||||
$funnelSub = FunnelSubscriber::where('id', $subscriber->funnel_subscriber_id)->first();
|
||||
try {
|
||||
if (!$funnelSub) {
|
||||
return $defaultValue;
|
||||
}
|
||||
$order = OrderResource::view($funnelSub['source_ref_id']);
|
||||
} catch (\Exception $exception) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
if (empty($order) || !isset($order['order'])) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
return self::parseOrderProps($order['order'], $valueKey, $defaultValue);
|
||||
}
|
||||
|
||||
|
||||
public static function parseCartCustomer($code, $valueKey, $defaultValue, $subscriber)
|
||||
{
|
||||
|
||||
// Try to load customer directly from Customer model
|
||||
$customer = \FluentCart\App\Models\Customer::where('email', $subscriber->email)->first();
|
||||
if ($customer) {
|
||||
return self::parseCustomerProps($customer->toArray(), $valueKey, $defaultValue);
|
||||
}
|
||||
|
||||
|
||||
// Fallback: Load from order payload if direct model not found
|
||||
try {
|
||||
$funnelSub = FunnelSubscriber::where('id', $subscriber->funnel_subscriber_id)->first();
|
||||
|
||||
if (!$funnelSub) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
$order = OrderResource::view($funnelSub['source_ref_id']);
|
||||
} catch (\Exception $exception) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
if (empty($order) || !isset($order['order']['customer'])) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
// Ensure array structure
|
||||
$orderCustomer = is_array($order['order']['customer'])
|
||||
? $order['order']['customer']
|
||||
: (method_exists($order['order']['customer'], 'toArray')
|
||||
? $order['order']['customer']->toArray()
|
||||
: []);
|
||||
|
||||
if (!$orderCustomer) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
return self::parseCustomerProps($orderCustomer, $valueKey, $defaultValue);
|
||||
}
|
||||
|
||||
public static function parseCartTransaction($code, $valueKey, $defaultValue, $subscriber)
|
||||
{
|
||||
$funnelSub = FunnelSubscriber::where('id', $subscriber->funnel_subscriber_id)->first();
|
||||
try {
|
||||
if (!$funnelSub) {
|
||||
return $defaultValue;
|
||||
}
|
||||
$order = OrderResource::view($funnelSub['source_ref_id']);
|
||||
} catch (\Exception $exception) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
return self::parseTransactionProps($order['order']['transactions'], $valueKey, $defaultValue);
|
||||
}
|
||||
|
||||
public static function parseCartReceipt($code, $valueKey, $defaultValue, $subscriber)
|
||||
{
|
||||
$funnelSub = FunnelSubscriber::where('id', $subscriber->funnel_subscriber_id)->first();
|
||||
|
||||
if (!$funnelSub) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
try {
|
||||
$order = OrderResource::view($funnelSub['source_ref_id']);
|
||||
} catch (\Exception $exception) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
return self::parseReceiptProps($order, $valueKey, $defaultValue);
|
||||
}
|
||||
|
||||
public static function parseOrderProps($order, $valueKey, $defaultValue)
|
||||
{
|
||||
if (!$order) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
switch ($valueKey) {
|
||||
case 'order_id':
|
||||
return $order['id'];
|
||||
case 'status':
|
||||
return $order['status'];
|
||||
case 'invoice_no':
|
||||
return $order['invoice_no'];
|
||||
case 'receipt_number':
|
||||
return $order['receipt_number'];
|
||||
case 'type':
|
||||
return $order['type'];
|
||||
case 'customer_id':
|
||||
return $order['customer_id'];
|
||||
case 'payment_method':
|
||||
return $order['payment_method'];
|
||||
case 'payment_method_title':
|
||||
return $order['payment_method_title'];
|
||||
case 'payment_status':
|
||||
return $order['payment_status'];
|
||||
case 'currency':
|
||||
return $order['currency'];
|
||||
case 'subtotal':
|
||||
return Helper::toDecimal($order['subtotal']);
|
||||
case 'shipping_total':
|
||||
return Helper::toDecimal($order['shipping_total']);
|
||||
case 'total_amount':
|
||||
return Helper::toDecimal($order['total_amount']);
|
||||
case 'note':
|
||||
return $order['note'];
|
||||
case 'completed_at':
|
||||
return $order['completed_at'];
|
||||
case 'total_refund':
|
||||
return Helper::toDecimal($order['total_refund']);
|
||||
case 'uuid':
|
||||
return $order['uuid'];
|
||||
case 'created_at':
|
||||
return $order['created_at'];
|
||||
case 'total_paid':
|
||||
return Helper::toDecimal($order['total_paid']);
|
||||
case 'shipping_status':
|
||||
return $order['shipping_status'];
|
||||
// case 'order_url':
|
||||
// return URL::getCustomerOrderUrl ($order['uuid']);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static function parseTransactionProps($transaction, $valueKey, $defaultValue)
|
||||
{
|
||||
// this method is not called for now check FluentCart->addHooks Method
|
||||
//Considering the latest transaction
|
||||
usort($transaction, function ($a, $b) {
|
||||
return strtotime($b['created_at']) - strtotime($a['created_at']);
|
||||
});
|
||||
$transaction = $transaction[0];
|
||||
if (!$transaction) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
switch ($valueKey) {
|
||||
case 'transaction_id':
|
||||
return $transaction['id'];
|
||||
case 'order_id':
|
||||
return $transaction['order_id'];
|
||||
case 'order_type':
|
||||
return $transaction['order_type'];
|
||||
case 'vendor_charge_id':
|
||||
return $transaction['vendor_charge_id'];
|
||||
case 'payment_method':
|
||||
return $transaction['payment_method'];
|
||||
case 'payment_mode':
|
||||
return $transaction['payment_mode'];
|
||||
case 'payment_method_type':
|
||||
return $transaction['payment_method_type'];
|
||||
case 'transaction_type':
|
||||
return $transaction['transaction_type'];
|
||||
case 'subscription_id':
|
||||
return $transaction['subscription_id'];
|
||||
case 'card_last_4':
|
||||
return $transaction['card_last_4'];
|
||||
case 'card_brand':
|
||||
return $transaction['card_brand'];
|
||||
case 'status':
|
||||
return $transaction['status'];
|
||||
case 'total':
|
||||
return $transaction['total'];
|
||||
case 'rate':
|
||||
return $transaction['rate'];
|
||||
case 'meta':
|
||||
return $transaction['meta'];
|
||||
case 'uuid':
|
||||
return $transaction['uuid'];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static function parseCustomerProps($customer, $valueKey, $defaultValue)
|
||||
{
|
||||
if (!$customer) {
|
||||
return $defaultValue;
|
||||
}
|
||||
switch ($valueKey) {
|
||||
case 'user_id':
|
||||
return $customer['user_id'];
|
||||
case 'contact_id':
|
||||
return $customer['contact_id'];
|
||||
case 'first_name':
|
||||
return $customer['first_name'];
|
||||
case 'last_name':
|
||||
return $customer['last_name'];
|
||||
case 'email':
|
||||
return $customer['email'];
|
||||
case 'status':
|
||||
return $customer['status'];
|
||||
case 'purchase_value':
|
||||
return $customer['purchase_value'];
|
||||
case 'purchase_count':
|
||||
return $customer['purchase_count'];
|
||||
case 'first_purchase_date':
|
||||
return $customer['first_purchase_date'];
|
||||
case 'last_purchase_date':
|
||||
return $customer['last_purchase_date'];
|
||||
case 'aov':
|
||||
return $customer['aov'];
|
||||
case 'notes':
|
||||
return $customer['notes'];
|
||||
case 'uuid':
|
||||
return $customer['uuid'];
|
||||
case 'country':
|
||||
return $customer['country'];
|
||||
case 'city':
|
||||
return $customer['city'];
|
||||
case 'state':
|
||||
return $customer['state'];
|
||||
case 'postcode':
|
||||
return $customer['postcode'];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static function parseReceiptProps($order, $valueKey, $defaultValue)
|
||||
{
|
||||
if (!$order) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
switch ($valueKey) {
|
||||
case 'payment_summary':
|
||||
return self::PaymentReceipt($order);
|
||||
case 'order_summary':
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static function paymentReceipt($order)
|
||||
{
|
||||
$order = Order::with('order_items')->find($order['order']['id']);
|
||||
$paymentReceipt = new PaymentReceipt($order);
|
||||
$showQuantityColumn = false;
|
||||
|
||||
// Check if any item in the receipt is not a subscription.
|
||||
// If such an item exists, set $showQuantityColumn to true.
|
||||
foreach ($paymentReceipt->getItems() as $item) {
|
||||
if ($item['payment_type'] !== 'subscription') {
|
||||
$showQuantityColumn = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$shop = Helper::shopConfig();
|
||||
$currencySign = $shop['currency_sign'];
|
||||
|
||||
ob_start();
|
||||
do_action('fluent_cart/views/checkout_order_summary', compact('order', 'paymentReceipt', 'showQuantityColumn', 'currencySign'));
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\SmartCode;
|
||||
|
||||
class SmartCodeRegister
|
||||
{
|
||||
public static function push()
|
||||
{
|
||||
$smartCodesConfig = [
|
||||
'cart_order' => [
|
||||
'title' => __('Cart Order', 'fluent-crm'),
|
||||
'description' => __('Order-related smart codes', 'fluent-crm'),
|
||||
'shortcodes' => [
|
||||
'{{cart_order.order_id}}' => __('Order ID', 'fluent-crm'),
|
||||
'{{cart_order.status}}' => __('Order Status', 'fluent-crm'),
|
||||
'{{cart_order.invoice_no}}' => __('Invoice Number', 'fluent-crm'),
|
||||
'{{cart_order.type}}' => __('Order Type', 'fluent-crm'),
|
||||
'{{cart_order.payment_method}}' => __('Payment Method', 'fluent-crm'),
|
||||
'{{cart_order.payment_method_title}}' => __('Payment Method Title', 'fluent-crm'),
|
||||
'{{cart_order.payment_status}}' => __('Payment Status', 'fluent-crm'),
|
||||
'{{cart_order.currency}}' => __('Currency', 'fluent-crm'),
|
||||
'{{cart_order.subtotal}}' => __('Order Subtotal', 'fluent-crm'),
|
||||
'{{cart_order.shipping_total}}' => __('Shipping Total', 'fluent-crm'),
|
||||
'{{cart_order.total_amount}}' => __('Total Amount', 'fluent-crm'),
|
||||
'{{cart_order.note}}' => __('Order Note', 'fluent-crm'),
|
||||
'{{cart_order.completed_at}}' => __('Order Completion Date', 'fluent-crm'),
|
||||
'{{cart_order.total_refund}}' => __('Total Refund', 'fluent-crm'),
|
||||
'{{cart_order.created_at}}' => __('Order Creation Date', 'fluent-crm'),
|
||||
'{{cart_order.total_paid}}' => __('Total Paid Amount', 'fluent-crm'),
|
||||
'{{cart_order.shipping_status}}' => __('Shipping Status', 'fluent-crm'),
|
||||
// '##cart_order.order_url##' => 'Order URL',
|
||||
]
|
||||
],
|
||||
'cart_customer' => [
|
||||
'title' => __('Cart Customer', 'fluent-crm'),
|
||||
'description' => __('Customer-related smart codes', 'fluent-crm'),
|
||||
'shortcodes' => [
|
||||
'{{cart_customer.user_id}}' => __('User ID', 'fluent-crm'),
|
||||
'{{cart_customer.contact_id}}' => __('Customer Contact ID', 'fluent-crm'),
|
||||
'{{cart_customer.email}}' => __('Customer Email', 'fluent-crm'),
|
||||
'{{cart_customer.first_name}}' => __('Customer First Name', 'fluent-crm'),
|
||||
'{{cart_customer.last_name}}' => __('Customer Last Name', 'fluent-crm'),
|
||||
'{{cart_customer.status}}' => __('Customer Status', 'fluent-crm'),
|
||||
'{{cart_customer.purchase_value}}' => __('Total Purchase Value', 'fluent-crm'),
|
||||
'{{cart_customer.purchase_count}}' => __('Total Purchase Count', 'fluent-crm'),
|
||||
'{{cart_customer.first_purchase_date}}' => __('First Purchase Date', 'fluent-crm'),
|
||||
'{{cart_customer.last_purchase_date}}' => __('Last Purchase Date', 'fluent-crm'),
|
||||
'{{cart_customer.aov}}' => __('Average Order Value (AOV)', 'fluent-crm'),
|
||||
'{{cart_customer.notes}}' => __('Customer Notes', 'fluent-crm'),
|
||||
'{{cart_customer.country}}' => __('Customer Country', 'fluent-crm'),
|
||||
'{{cart_customer.city}}' => __('Customer City', 'fluent-crm'),
|
||||
'{{cart_customer.state}}' => __('Customer State', 'fluent-crm'),
|
||||
'{{cart_customer.postcode}}' => __('Customer Postcode', 'fluent-crm'),
|
||||
]
|
||||
],
|
||||
// 'cart_transaction' => [
|
||||
// 'title' => __('Cart Transaction'),
|
||||
// 'description' => __('Transaction-related smart codes'),
|
||||
// 'shortcodes' => [
|
||||
// '{{cart_transaction.transaction_id}}' => 'Transaction ID',
|
||||
// '{{cart_transaction.amount}}' => 'Transaction Amount',
|
||||
// '{{cart_transaction.order_id}}' => 'Order ID',
|
||||
// '{{cart_transaction.order_type}}' => 'Order Type',
|
||||
// '{{cart_transaction.payment_method}}' => 'Payment Method',
|
||||
// '{{cart_transaction.payment_mode}}' => 'Payment Mode',
|
||||
// '{{cart_transaction.payment_method_type}}' => 'Payment Method Type',
|
||||
// '{{cart_transaction.transaction_type}}' => 'Transaction Type',
|
||||
// '{{cart_transaction.subscription_id}}' => 'Subscription ID',
|
||||
// '{{cart_transaction.status}}' => 'Transaction Status',
|
||||
// '{{cart_transaction.total}}' => 'Transaction Total',
|
||||
// '{{cart_transaction.rate}}' => 'Exchange Rate',
|
||||
// ]
|
||||
// ],
|
||||
// 'cart_receipt' => [
|
||||
// 'title' => __('Cart Receipts'),
|
||||
// 'description' => __('Transaction-related smart codes'),
|
||||
// 'shortcodes' => [
|
||||
// '{{cart_receipt.payment_summary}}' => 'Payment Summary',
|
||||
// ]
|
||||
// ],
|
||||
|
||||
];
|
||||
|
||||
// Dynamically register filters for each smart code group
|
||||
|
||||
foreach ($smartCodesConfig as $key => $config) {
|
||||
add_filter('fluent_crm_funnel_context_smart_codes', function ($codes) use ($key, $config) {
|
||||
$codes[] = [
|
||||
'key' => $key,
|
||||
'title' => $config['title'],
|
||||
'description' => $config['description'],
|
||||
'shortcodes' => $config['shortcodes']
|
||||
];
|
||||
return $codes;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\CartHelper;
|
||||
|
||||
class OrderCanceledTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluent_cart/order_status_changed_to_canceled';
|
||||
$this->priority = 20;
|
||||
$this->actionArgNum = 1;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'category' => __('FluentCart', 'fluent-crm'),
|
||||
'label' => __('Order Canceled', 'fluent-crm'),
|
||||
'description' => __('This will start when an order is canceled', 'fluent-crm'),
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><g id="surface1"><path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.398438 0 L 21.601562 0 C 22.925781 0 24 1.074219 24 2.398438 L 24 21.601562 C 24 22.925781 22.925781 24 21.601562 24 L 2.398438 24 C 1.074219 24 0 22.925781 0 21.601562 L 0 2.398438 C 0 1.074219 1.074219 0 2.398438 0 Z M 2.398438 0 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 10.925781 16.476562 L 3.769531 16.476562 L 4.894531 13.878906 C 5.222656 13.117188 5.972656 12.625 6.804688 12.625 L 15.328125 12.625 L 14.746094 13.964844 C 14.085938 15.488281 12.585938 16.476562 10.925781 16.476562 Z M 10.925781 16.476562 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 16.851562 11.394531 L 6.789062 11.394531 L 7.367188 10.054688 C 8.027344 8.53125 9.53125 7.542969 11.191406 7.542969 L 19.886719 7.542969 L 18.761719 10.140625 C 18.433594 10.902344 17.683594 11.394531 16.851562 11.394531 Z M 16.851562 11.394531 "/></g></svg>'
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'subscription_status' => 'subscribed'
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
return [
|
||||
'title' => __('Order Canceled', 'fluent-crm'),
|
||||
'sub_title' => __('This will start when an order is canceled', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'subscription_status' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'editable_statuses',
|
||||
'is_multiple' => false,
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm')
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>' . __('An Automated double-optin email will be sent for new subscribers', 'fluent-crm') . '</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'product_ids' => [],
|
||||
'product_categories' => [],
|
||||
// 'purchase_type' => 'all',
|
||||
'run_multiple' => 'no'
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
// 'update_type' => [
|
||||
// 'type' => 'radio',
|
||||
// 'label' => __('If Contact Already Exist?', 'fluent-crm'),
|
||||
// 'help' => __('Please specify what will happen if the subscriber already exists in the database','fluent-crm'),
|
||||
// 'options' => FunnelHelper::getUpdateOptions()
|
||||
// ],
|
||||
|
||||
'product_ids' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Target Products', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_products',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select for which products this automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run to any product purchase', 'fluent-crm')
|
||||
],
|
||||
|
||||
'product_categories' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Or Target Product Categories', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_product_categories',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select for which product category the automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run to any category products', 'fluent-crm')
|
||||
],
|
||||
|
||||
// 'run_only_if_coupon_applied' => [
|
||||
// 'type' => 'yes_no_check',
|
||||
// 'label' => '',
|
||||
// 'check_label' => __('Run automation only if a coupon is applied to the order', 'fluent-crm'),
|
||||
// ],
|
||||
'run_multiple' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Restart the Automation Multiple times for a contact for this event. (Only enable if you want to restart automation for the same contact)', 'fluent-crm'),
|
||||
'inline_help' => __('If enabled, it will restart the automation for a contact if the contact is already in the automation. Otherwise, it will skip if it already exists', 'fluent-crm')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
$orderData = $originalArgs[0] ?? [];
|
||||
|
||||
$order = Arr::get($orderData, 'order', []);
|
||||
$customer = Arr::get($order, 'customer', []);
|
||||
// $transaction = Arr::get($orderData, 'transaction', []);
|
||||
|
||||
$orderId = Arr::get($order, 'id', 0);
|
||||
|
||||
// Get the funnel settings and conditions
|
||||
$settings = Arr::get($funnel, 'settings', []);
|
||||
|
||||
$subscriberData = CartHelper::prepareSubscriberData($customer);
|
||||
|
||||
if (!is_email($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $order, $subscriberData);
|
||||
|
||||
$willProcess = apply_filters('fluentcrm_funnel_will_process_' . $this->triggerName, $willProcess, $funnel, $subscriberData, $originalArgs);
|
||||
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
|
||||
|
||||
$subscriberData['status'] = (!empty($subscriberData['subscription_status'])) ? $subscriberData['subscription_status'] : 'subscribed';
|
||||
unset($subscriberData['subscription_status']);
|
||||
|
||||
(new FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $orderId
|
||||
]);
|
||||
}
|
||||
|
||||
private function isProcessable($funnel, $order, $subscriberData)
|
||||
{
|
||||
$conditions = Arr::get($funnel, 'conditions', []);
|
||||
$isProcessable = $this->checkConditions($conditions, $order, $subscriberData);
|
||||
|
||||
if(!$isProcessable){
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
|
||||
// check run_only_one
|
||||
if ($subscriber) {
|
||||
$funnelSub = FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id);
|
||||
if ($funnelSub) {
|
||||
$multipleRun = Arr::get($conditions, 'run_multiple') == 'yes';
|
||||
if ($multipleRun) {
|
||||
if ($funnelSub->source_ref_id == $order->id) {
|
||||
return false;
|
||||
}
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
}
|
||||
return $multipleRun;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function checkConditions($conditions, $order, $subscriber)
|
||||
{
|
||||
$orderItems = Arr::get($order, 'order_items', []);
|
||||
// Post IDs of ordered products are the product IDs in FluentCart
|
||||
|
||||
$orderedProductIds = [];
|
||||
foreach ($orderItems as $item) {
|
||||
$productId = $item->post_id;
|
||||
if ($productId) {
|
||||
$orderedProductIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
$orderProductCategories = CartHelper::getProductCategoriesByIds($orderedProductIds);
|
||||
|
||||
$selectedProductIds = Arr::get($conditions, 'product_ids', []);
|
||||
|
||||
$selectedProductCategories = Arr::get($conditions, 'product_categories', []);
|
||||
|
||||
// If no products or categories are selected, return true
|
||||
if (empty($selectedProductIds) && empty($selectedProductCategories)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for matches in product IDs and categories
|
||||
|
||||
$productMatch = !empty($selectedProductIds) && !empty(array_intersect($selectedProductIds, $orderedProductIds));
|
||||
$categoryMatch = !empty($selectedProductCategories) && !empty(array_intersect($selectedProductCategories, $orderProductCategories));
|
||||
|
||||
// Return true if either matches
|
||||
return $productMatch || $categoryMatch;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\CartHelper;
|
||||
|
||||
class OrderDeliveredTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluent_cart/shipping_status_changed_to_delivered';
|
||||
$this->priority = 20;
|
||||
$this->actionArgNum = 1;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'category' => __('FluentCart', 'fluent-crm'),
|
||||
'label' => __('Order Delivered', 'fluent-crm'),
|
||||
'description' => __('This will start when a successful order is delivered', 'fluent-crm'),
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><g id="surface1"><path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.398438 0 L 21.601562 0 C 22.925781 0 24 1.074219 24 2.398438 L 24 21.601562 C 24 22.925781 22.925781 24 21.601562 24 L 2.398438 24 C 1.074219 24 0 22.925781 0 21.601562 L 0 2.398438 C 0 1.074219 1.074219 0 2.398438 0 Z M 2.398438 0 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 10.925781 16.476562 L 3.769531 16.476562 L 4.894531 13.878906 C 5.222656 13.117188 5.972656 12.625 6.804688 12.625 L 15.328125 12.625 L 14.746094 13.964844 C 14.085938 15.488281 12.585938 16.476562 10.925781 16.476562 Z M 10.925781 16.476562 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 16.851562 11.394531 L 6.789062 11.394531 L 7.367188 10.054688 C 8.027344 8.53125 9.53125 7.542969 11.191406 7.542969 L 19.886719 7.542969 L 18.761719 10.140625 C 18.433594 10.902344 17.683594 11.394531 16.851562 11.394531 Z M 16.851562 11.394531 "/></g></svg>'
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'subscription_status' => 'subscribed'
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
return [
|
||||
'title' => __('Order Delivered', 'fluent-crm'),
|
||||
'sub_title' => __('This will start when an order is delivered', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'subscription_status' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'editable_statuses',
|
||||
'is_multiple' => false,
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm')
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>' . __('An Automated double-optin email will be sent for new subscribers', 'fluent-crm') . '</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'product_ids' => [],
|
||||
'product_categories' => [],
|
||||
// 'purchase_type' => 'all',
|
||||
'run_multiple' => 'no'
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
// 'update_type' => [
|
||||
// 'type' => 'radio',
|
||||
// 'label' => __('If Contact Already Exist?', 'fluent-crm'),
|
||||
// 'help' => __('Please specify what will happen if the subscriber already exists in the database','fluent-crm'),
|
||||
// 'options' => FunnelHelper::getUpdateOptions()
|
||||
// ],
|
||||
|
||||
'product_ids' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Target Products', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_products',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select for which products this automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run to any product purchase', 'fluent-crm')
|
||||
],
|
||||
|
||||
'product_categories' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Or Target Product Categories', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_product_categories',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select for which product category the automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run to any category products', 'fluent-crm')
|
||||
],
|
||||
|
||||
// 'run_only_if_coupon_applied' => [
|
||||
// 'type' => 'yes_no_check',
|
||||
// 'label' => '',
|
||||
// 'check_label' => __('Run automation only if a coupon is applied to the order', 'fluent-crm'),
|
||||
// ],
|
||||
'run_multiple' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Restart the Automation Multiple times for a contact for this event. (Only enable if you want to restart automation for the same contact)', 'fluent-crm'),
|
||||
'inline_help' => __('If enabled, it will restart the automation for a contact if the contact is already in the automation. Otherwise, it will skip if it already exists', 'fluent-crm')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
$orderData = $originalArgs[0] ?? [];
|
||||
|
||||
$order = Arr::get($orderData, 'order', []);
|
||||
$customer = Arr::get($order, 'customer', []);
|
||||
// $transaction = Arr::get($orderData, 'transaction', []);
|
||||
|
||||
$orderId = Arr::get($order, 'id', 0);
|
||||
|
||||
// Get the funnel settings and conditions
|
||||
$settings = Arr::get($funnel, 'settings', []);
|
||||
|
||||
$subscriberData = CartHelper::prepareSubscriberData($customer);
|
||||
|
||||
if (!is_email($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $order, $subscriberData);
|
||||
|
||||
$willProcess = apply_filters('fluentcrm_funnel_will_process_' . $this->triggerName, $willProcess, $funnel, $subscriberData, $originalArgs);
|
||||
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
|
||||
|
||||
$subscriberData['status'] = (!empty($subscriberData['subscription_status'])) ? $subscriberData['subscription_status'] : 'subscribed';
|
||||
unset($subscriberData['subscription_status']);
|
||||
|
||||
(new FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $orderId
|
||||
]);
|
||||
}
|
||||
|
||||
private function isProcessable($funnel, $order, $subscriberData)
|
||||
{
|
||||
$conditions = Arr::get($funnel, 'conditions', []);
|
||||
$isProcessable = $this->checkConditions($conditions, $order, $subscriberData);
|
||||
|
||||
if(!$isProcessable){
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
|
||||
// check run_only_one
|
||||
if ($subscriber) {
|
||||
$funnelSub = FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id);
|
||||
if ($funnelSub) {
|
||||
$multipleRun = Arr::get($conditions, 'run_multiple') == 'yes';
|
||||
if ($multipleRun) {
|
||||
if ($funnelSub->source_ref_id == $order->id) {
|
||||
return false;
|
||||
}
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
}
|
||||
return $multipleRun;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function checkConditions($conditions, $order, $subscriber)
|
||||
{
|
||||
$orderItems = Arr::get($order, 'order_items', []);
|
||||
// Post IDs of ordered products are the product IDs in FluentCart
|
||||
|
||||
$orderedProductIds = [];
|
||||
foreach ($orderItems as $item) {
|
||||
$productId = $item->post_id;
|
||||
if ($productId) {
|
||||
$orderedProductIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
$orderProductCategories = CartHelper::getProductCategoriesByIds($orderedProductIds);
|
||||
|
||||
$selectedProductIds = Arr::get($conditions, 'product_ids', []);
|
||||
|
||||
$selectedProductCategories = Arr::get($conditions, 'product_categories', []);
|
||||
|
||||
// If no products or categories are selected, return true
|
||||
if (empty($selectedProductIds) && empty($selectedProductCategories)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for matches in product IDs and categories
|
||||
|
||||
$productMatch = !empty($selectedProductIds) && !empty(array_intersect($selectedProductIds, $orderedProductIds));
|
||||
$categoryMatch = !empty($selectedProductCategories) && !empty(array_intersect($selectedProductCategories, $orderProductCategories));
|
||||
|
||||
// Return true if either matches
|
||||
return $productMatch || $categoryMatch;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\CartHelper;
|
||||
|
||||
class OrderPaidTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluent_cart/order_paid_done';
|
||||
$this->priority = 20;
|
||||
$this->actionArgNum = 1;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'category' => __('FluentCart', 'fluent-crm'),
|
||||
'label' => __('Order Paid (Payment/Subscription)', 'fluent-crm'),
|
||||
'description' => __('This will start when a successful order is created', 'fluent-crm'),
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><g id="surface1"><path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.398438 0 L 21.601562 0 C 22.925781 0 24 1.074219 24 2.398438 L 24 21.601562 C 24 22.925781 22.925781 24 21.601562 24 L 2.398438 24 C 1.074219 24 0 22.925781 0 21.601562 L 0 2.398438 C 0 1.074219 1.074219 0 2.398438 0 Z M 2.398438 0 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 10.925781 16.476562 L 3.769531 16.476562 L 4.894531 13.878906 C 5.222656 13.117188 5.972656 12.625 6.804688 12.625 L 15.328125 12.625 L 14.746094 13.964844 C 14.085938 15.488281 12.585938 16.476562 10.925781 16.476562 Z M 10.925781 16.476562 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 16.851562 11.394531 L 6.789062 11.394531 L 7.367188 10.054688 C 8.027344 8.53125 9.53125 7.542969 11.191406 7.542969 L 19.886719 7.542969 L 18.761719 10.140625 C 18.433594 10.902344 17.683594 11.394531 16.851562 11.394531 Z M 16.851562 11.394531 "/></g></svg>'
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'subscription_status' => 'subscribed'
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
return [
|
||||
'title' => __('Order Paid (Payment/Subscription)', 'fluent-crm'),
|
||||
'sub_title' => __('This will start when an order is paid', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'subscription_status' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'editable_statuses',
|
||||
'is_multiple' => false,
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm')
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>' . __('An Automated double-optin email will be sent for new subscribers', 'fluent-crm') . '</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'product_ids' => [],
|
||||
'product_categories' => [],
|
||||
// 'purchase_type' => 'all',
|
||||
'run_multiple' => 'no'
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
// 'update_type' => [
|
||||
// 'type' => 'radio',
|
||||
// 'label' => __('If Contact Already Exist?', 'fluent-crm'),
|
||||
// 'help' => __('Please specify what will happen if the subscriber already exists in the database','fluent-crm'),
|
||||
// 'options' => FunnelHelper::getUpdateOptions()
|
||||
// ],
|
||||
|
||||
'product_ids' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Target Products', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_products',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select for which products this automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run to any product purchase', 'fluent-crm')
|
||||
],
|
||||
|
||||
'product_categories' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Or Target Product Categories', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_product_categories',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select for which product category the automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run to any category products', 'fluent-crm')
|
||||
],
|
||||
|
||||
// 'run_only_if_coupon_applied' => [
|
||||
// 'type' => 'yes_no_check',
|
||||
// 'label' => '',
|
||||
// 'check_label' => __('Run automation only if a coupon is applied to the order', 'fluent-crm'),
|
||||
// ],
|
||||
'run_multiple' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Restart the Automation Multiple times for a contact for this event. (Only enable if you want to restart automation for the same contact)', 'fluent-crm'),
|
||||
'inline_help' => __('If enabled, it will restart the automation for a contact if the contact is already in the automation. Otherwise, it will skip if it already exists', 'fluent-crm')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
$orderData = $originalArgs[0] ?? [];
|
||||
|
||||
$order = Arr::get($orderData, 'order', []);
|
||||
$customer = Arr::get($orderData, 'customer', []);
|
||||
$transaction = Arr::get($orderData, 'transaction', []);
|
||||
|
||||
$orderId = Arr::get($order, 'id', 0);
|
||||
|
||||
// Get the funnel settings and conditions
|
||||
$settings = Arr::get($funnel, 'settings', []);
|
||||
|
||||
$subscriberData = CartHelper::prepareSubscriberData($customer);
|
||||
|
||||
if (!is_email($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $order, $subscriberData);
|
||||
|
||||
$willProcess = apply_filters('fluentcrm_funnel_will_process_' . $this->triggerName, $willProcess, $funnel, $subscriberData, $originalArgs);
|
||||
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
|
||||
|
||||
$subscriberData['status'] = (!empty($subscriberData['subscription_status'])) ? $subscriberData['subscription_status'] : 'subscribed';
|
||||
unset($subscriberData['subscription_status']);
|
||||
|
||||
(new FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $orderId
|
||||
]);
|
||||
}
|
||||
|
||||
private function isProcessable($funnel, $order, $subscriberData)
|
||||
{
|
||||
$conditions = Arr::get($funnel, 'conditions', []);
|
||||
$isProcessable = $this->checkConditions($conditions, $order, $subscriberData);
|
||||
|
||||
if(!$isProcessable){
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
|
||||
// check run_only_one
|
||||
if ($subscriber) {
|
||||
$funnelSub = FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id);
|
||||
if ($funnelSub) {
|
||||
$multipleRun = Arr::get($conditions, 'run_multiple') == 'yes';
|
||||
if ($multipleRun) {
|
||||
if ($funnelSub->source_ref_id == $order->id) {
|
||||
return false;
|
||||
}
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
}
|
||||
return $multipleRun;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function checkConditions($conditions, $order, $subscriber)
|
||||
{
|
||||
$orderItems = Arr::get($order, 'order_items', []);
|
||||
// Post IDs of ordered products are the product IDs in FluentCart
|
||||
|
||||
$orderedProductIds = [];
|
||||
foreach ($orderItems as $item) {
|
||||
$productId = $item->post_id;
|
||||
if ($productId) {
|
||||
$orderedProductIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
$orderProductCategories = CartHelper::getProductCategoriesByIds($orderedProductIds);
|
||||
|
||||
$selectedProductIds = Arr::get($conditions, 'product_ids', []);
|
||||
|
||||
$selectedProductCategories = Arr::get($conditions, 'product_categories', []);
|
||||
|
||||
// If no products or categories are selected, return true
|
||||
if (empty($selectedProductIds) && empty($selectedProductCategories)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for matches in product IDs and categories
|
||||
|
||||
$productMatch = !empty($selectedProductIds) && !empty(array_intersect($selectedProductIds, $orderedProductIds));
|
||||
$categoryMatch = !empty($selectedProductCategories) && !empty(array_intersect($selectedProductCategories, $orderProductCategories));
|
||||
|
||||
// Return true if either matches
|
||||
return $productMatch || $categoryMatch;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\CartHelper;
|
||||
|
||||
class OrderRefundedTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluent_cart/order_fully_refunded';
|
||||
$this->priority = 20;
|
||||
$this->actionArgNum = 1;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'category' => __('FluentCart', 'fluent-crm'),
|
||||
'label' => __('Order Refunded (Full)', 'fluent-crm'),
|
||||
'description' => __('This will start when a successful order is refunded', 'fluent-crm'),
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><g id="surface1"><path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.398438 0 L 21.601562 0 C 22.925781 0 24 1.074219 24 2.398438 L 24 21.601562 C 24 22.925781 22.925781 24 21.601562 24 L 2.398438 24 C 1.074219 24 0 22.925781 0 21.601562 L 0 2.398438 C 0 1.074219 1.074219 0 2.398438 0 Z M 2.398438 0 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 10.925781 16.476562 L 3.769531 16.476562 L 4.894531 13.878906 C 5.222656 13.117188 5.972656 12.625 6.804688 12.625 L 15.328125 12.625 L 14.746094 13.964844 C 14.085938 15.488281 12.585938 16.476562 10.925781 16.476562 Z M 10.925781 16.476562 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 16.851562 11.394531 L 6.789062 11.394531 L 7.367188 10.054688 C 8.027344 8.53125 9.53125 7.542969 11.191406 7.542969 L 19.886719 7.542969 L 18.761719 10.140625 C 18.433594 10.902344 17.683594 11.394531 16.851562 11.394531 Z M 16.851562 11.394531 "/></g></svg>'
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'subscription_status' => 'subscribed'
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
return [
|
||||
'title' => __('Order Refunded (Full)', 'fluent-crm'),
|
||||
'sub_title' => __('This will start when an order is refunded', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'subscription_status' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'editable_statuses',
|
||||
'is_multiple' => false,
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm')
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>' . __('An Automated double-optin email will be sent for new subscribers', 'fluent-crm') . '</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'product_ids' => [],
|
||||
'product_categories' => [],
|
||||
// 'purchase_type' => 'all',
|
||||
'run_multiple' => 'no'
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
// 'update_type' => [
|
||||
// 'type' => 'radio',
|
||||
// 'label' => __('If Contact Already Exist?', 'fluent-crm'),
|
||||
// 'help' => __('Please specify what will happen if the subscriber already exists in the database','fluent-crm'),
|
||||
// 'options' => FunnelHelper::getUpdateOptions()
|
||||
// ],
|
||||
|
||||
'product_ids' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Target Products', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_products',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select for which products this automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run to any product purchase', 'fluent-crm')
|
||||
],
|
||||
|
||||
'product_categories' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Or Target Product Categories', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_product_categories',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select for which product category the automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run to any category products', 'fluent-crm')
|
||||
],
|
||||
|
||||
// 'run_only_if_coupon_applied' => [
|
||||
// 'type' => 'yes_no_check',
|
||||
// 'label' => '',
|
||||
// 'check_label' => __('Run automation only if a coupon is applied to the order', 'fluent-crm'),
|
||||
// ],
|
||||
'run_multiple' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Restart the Automation Multiple times for a contact for this event. (Only enable if you want to restart automation for the same contact)', 'fluent-crm'),
|
||||
'inline_help' => __('If enabled, it will restart the automation for a contact if the contact is already in the automation. Otherwise, it will skip if it already exists', 'fluent-crm')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
$orderData = $originalArgs[0] ?? [];
|
||||
|
||||
$order = Arr::get($orderData, 'order', []);
|
||||
$customer = Arr::get($order, 'customer', []);
|
||||
// $transaction = Arr::get($orderData, 'transaction', []);
|
||||
|
||||
$orderId = Arr::get($order, 'id', 0);
|
||||
|
||||
// Get the funnel settings and conditions
|
||||
$settings = Arr::get($funnel, 'settings', []);
|
||||
|
||||
$subscriberData = CartHelper::prepareSubscriberData($customer);
|
||||
|
||||
if (!is_email($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $order, $subscriberData);
|
||||
|
||||
$willProcess = apply_filters('fluentcrm_funnel_will_process_' . $this->triggerName, $willProcess, $funnel, $subscriberData, $originalArgs);
|
||||
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
|
||||
|
||||
$subscriberData['status'] = (!empty($subscriberData['subscription_status'])) ? $subscriberData['subscription_status'] : 'subscribed';
|
||||
unset($subscriberData['subscription_status']);
|
||||
|
||||
(new FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $orderId
|
||||
]);
|
||||
}
|
||||
|
||||
private function isProcessable($funnel, $order, $subscriberData)
|
||||
{
|
||||
$conditions = Arr::get($funnel, 'conditions', []);
|
||||
$isProcessable = $this->checkConditions($conditions, $order, $subscriberData);
|
||||
|
||||
if(!$isProcessable){
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
|
||||
// check run_only_one
|
||||
if ($subscriber) {
|
||||
$funnelSub = FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id);
|
||||
if ($funnelSub) {
|
||||
$multipleRun = Arr::get($conditions, 'run_multiple') == 'yes';
|
||||
if ($multipleRun) {
|
||||
if ($funnelSub->source_ref_id == $order->id) {
|
||||
return false;
|
||||
}
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
}
|
||||
return $multipleRun;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function checkConditions($conditions, $order, $subscriber)
|
||||
{
|
||||
$orderItems = Arr::get($order, 'order_items', []);
|
||||
// Post IDs of ordered products are the product IDs in FluentCart
|
||||
|
||||
$orderedProductIds = [];
|
||||
foreach ($orderItems as $item) {
|
||||
$productId = $item->post_id;
|
||||
if ($productId) {
|
||||
$orderedProductIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
$orderProductCategories = CartHelper::getProductCategoriesByIds($orderedProductIds);
|
||||
|
||||
$selectedProductIds = Arr::get($conditions, 'product_ids', []);
|
||||
|
||||
$selectedProductCategories = Arr::get($conditions, 'product_categories', []);
|
||||
|
||||
// If no products or categories are selected, return true
|
||||
if (empty($selectedProductIds) && empty($selectedProductCategories)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for matches in product IDs and categories
|
||||
|
||||
$productMatch = !empty($selectedProductIds) && !empty(array_intersect($selectedProductIds, $orderedProductIds));
|
||||
$categoryMatch = !empty($selectedProductCategories) && !empty(array_intersect($selectedProductCategories, $orderProductCategories));
|
||||
|
||||
// Return true if either matches
|
||||
return $productMatch || $categoryMatch;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\CartHelper;
|
||||
|
||||
class OrderShippedTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluent_cart/shipping_status_changed_to_shipped';
|
||||
$this->priority = 20;
|
||||
$this->actionArgNum = 1;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'category' => __('FluentCart', 'fluent-crm'),
|
||||
'label' => __('Order Shipped', 'fluent-crm'),
|
||||
'description' => __('This will start when a successful order is shipped', 'fluent-crm'),
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><g id="surface1"><path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.398438 0 L 21.601562 0 C 22.925781 0 24 1.074219 24 2.398438 L 24 21.601562 C 24 22.925781 22.925781 24 21.601562 24 L 2.398438 24 C 1.074219 24 0 22.925781 0 21.601562 L 0 2.398438 C 0 1.074219 1.074219 0 2.398438 0 Z M 2.398438 0 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 10.925781 16.476562 L 3.769531 16.476562 L 4.894531 13.878906 C 5.222656 13.117188 5.972656 12.625 6.804688 12.625 L 15.328125 12.625 L 14.746094 13.964844 C 14.085938 15.488281 12.585938 16.476562 10.925781 16.476562 Z M 10.925781 16.476562 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 16.851562 11.394531 L 6.789062 11.394531 L 7.367188 10.054688 C 8.027344 8.53125 9.53125 7.542969 11.191406 7.542969 L 19.886719 7.542969 L 18.761719 10.140625 C 18.433594 10.902344 17.683594 11.394531 16.851562 11.394531 Z M 16.851562 11.394531 "/></g></svg>'
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'subscription_status' => 'subscribed'
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
return [
|
||||
'title' => __('Order Shipped', 'fluent-crm'),
|
||||
'sub_title' => __('This will start when an order is shipped', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'subscription_status' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'editable_statuses',
|
||||
'is_multiple' => false,
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm')
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>' . __('An Automated double-optin email will be sent for new subscribers', 'fluent-crm') . '</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'product_ids' => [],
|
||||
'product_categories' => [],
|
||||
// 'purchase_type' => 'all',
|
||||
'run_multiple' => 'no'
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
// 'update_type' => [
|
||||
// 'type' => 'radio',
|
||||
// 'label' => __('If Contact Already Exist?', 'fluent-crm'),
|
||||
// 'help' => __('Please specify what will happen if the subscriber already exists in the database','fluent-crm'),
|
||||
// 'options' => FunnelHelper::getUpdateOptions()
|
||||
// ],
|
||||
|
||||
'product_ids' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Target Products', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_products',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select for which products this automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run to any product purchase', 'fluent-crm')
|
||||
],
|
||||
|
||||
'product_categories' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Or Target Product Categories', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_product_categories',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select for which product category the automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run to any category products', 'fluent-crm')
|
||||
],
|
||||
|
||||
// 'run_only_if_coupon_applied' => [
|
||||
// 'type' => 'yes_no_check',
|
||||
// 'label' => '',
|
||||
// 'check_label' => __('Run automation only if a coupon is applied to the order', 'fluent-crm'),
|
||||
// ],
|
||||
'run_multiple' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Restart the Automation Multiple times for a contact for this event. (Only enable if you want to restart automation for the same contact)', 'fluent-crm'),
|
||||
'inline_help' => __('If enabled, it will restart the automation for a contact if the contact is already in the automation. Otherwise, it will skip if it already exists', 'fluent-crm')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
$orderData = $originalArgs[0] ?? [];
|
||||
|
||||
$order = Arr::get($orderData, 'order', []);
|
||||
$customer = Arr::get($order, 'customer', []);
|
||||
// $transaction = Arr::get($orderData, 'transaction', []);
|
||||
|
||||
$orderId = Arr::get($order, 'id', 0);
|
||||
|
||||
// Get the funnel settings and conditions
|
||||
$settings = Arr::get($funnel, 'settings', []);
|
||||
|
||||
$subscriberData = CartHelper::prepareSubscriberData($customer);
|
||||
|
||||
if (!is_email($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $order, $subscriberData);
|
||||
|
||||
$willProcess = apply_filters('fluentcrm_funnel_will_process_' . $this->triggerName, $willProcess, $funnel, $subscriberData, $originalArgs);
|
||||
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
|
||||
|
||||
$subscriberData['status'] = (!empty($subscriberData['subscription_status'])) ? $subscriberData['subscription_status'] : 'subscribed';
|
||||
unset($subscriberData['subscription_status']);
|
||||
|
||||
(new FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $orderId
|
||||
]);
|
||||
}
|
||||
|
||||
private function isProcessable($funnel, $order, $subscriberData)
|
||||
{
|
||||
$conditions = Arr::get($funnel, 'conditions', []);
|
||||
$isProcessable = $this->checkConditions($conditions, $order, $subscriberData);
|
||||
|
||||
if(!$isProcessable){
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
|
||||
// check run_only_one
|
||||
if ($subscriber) {
|
||||
$funnelSub = FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id);
|
||||
if ($funnelSub) {
|
||||
$multipleRun = Arr::get($conditions, 'run_multiple') == 'yes';
|
||||
if ($multipleRun) {
|
||||
if ($funnelSub->source_ref_id == $order->id) {
|
||||
return false;
|
||||
}
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
}
|
||||
return $multipleRun;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function checkConditions($conditions, $order, $subscriber)
|
||||
{
|
||||
$orderItems = Arr::get($order, 'order_items', []);
|
||||
// Post IDs of ordered products are the product IDs in FluentCart
|
||||
|
||||
$orderedProductIds = [];
|
||||
foreach ($orderItems as $item) {
|
||||
$productId = $item->post_id;
|
||||
if ($productId) {
|
||||
$orderedProductIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
$orderProductCategories = CartHelper::getProductCategoriesByIds($orderedProductIds);
|
||||
|
||||
$selectedProductIds = Arr::get($conditions, 'product_ids', []);
|
||||
|
||||
$selectedProductCategories = Arr::get($conditions, 'product_categories', []);
|
||||
|
||||
// If no products or categories are selected, return true
|
||||
if (empty($selectedProductIds) && empty($selectedProductCategories)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for matches in product IDs and categories
|
||||
|
||||
$productMatch = !empty($selectedProductIds) && !empty(array_intersect($selectedProductIds, $orderedProductIds));
|
||||
$categoryMatch = !empty($selectedProductCategories) && !empty(array_intersect($selectedProductCategories, $orderProductCategories));
|
||||
|
||||
// Return true if either matches
|
||||
return $productMatch || $categoryMatch;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers;
|
||||
|
||||
use FluentCart\App\Helpers\Status;
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\CartHelper;
|
||||
|
||||
class OrderStatusChangedTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluent_cart/order_status_changed';
|
||||
$this->priority = 20;
|
||||
$this->actionArgNum = 1;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'category' => __('FluentCart', 'fluent-crm'),
|
||||
'label' => __('Order Status Changed', 'fluent-crm'),
|
||||
'description' => __('This funnel will start when an order status updates', 'fluent-crm'),
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><g id="surface1"><path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.398438 0 L 21.601562 0 C 22.925781 0 24 1.074219 24 2.398438 L 24 21.601562 C 24 22.925781 22.925781 24 21.601562 24 L 2.398438 24 C 1.074219 24 0 22.925781 0 21.601562 L 0 2.398438 C 0 1.074219 1.074219 0 2.398438 0 Z M 2.398438 0 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 10.925781 16.476562 L 3.769531 16.476562 L 4.894531 13.878906 C 5.222656 13.117188 5.972656 12.625 6.804688 12.625 L 15.328125 12.625 L 14.746094 13.964844 C 14.085938 15.488281 12.585938 16.476562 10.925781 16.476562 Z M 10.925781 16.476562 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 16.851562 11.394531 L 6.789062 11.394531 L 7.367188 10.054688 C 8.027344 8.53125 9.53125 7.542969 11.191406 7.542969 L 19.886719 7.542969 L 18.761719 10.140625 C 18.433594 10.902344 17.683594 11.394531 16.851562 11.394531 Z M 16.851562 11.394531 "/></g></svg>'
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'subscription_status' => 'subscribed'
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
return [
|
||||
'title' => __('FluentCart Order Status Changed', 'fluent-crm'),
|
||||
'sub_title' => __('This Funnel will start when a Order status will change from one state to another', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'subscription_status' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'editable_statuses',
|
||||
'is_multiple' => false,
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm')
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>' . __('An Automated double-optin email will be sent for new subscribers', 'fluent-crm') . '</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'product_ids' => [],
|
||||
'product_categories' => [],
|
||||
'from_status' => 'any',
|
||||
'to_status' => 'any',
|
||||
'run_multiple' => 'no'
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
$orderStatuses = Status::getOrderStatuses();
|
||||
|
||||
$formattedStatuses = [[
|
||||
'id' => 'any',
|
||||
'title' => __('Any', 'fluent-crm')
|
||||
]];
|
||||
|
||||
foreach ($orderStatuses as $statusId => $statusName) {
|
||||
$formattedStatuses[] = [
|
||||
'id' => $statusId,
|
||||
'title' => $statusName
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'product_ids' => [
|
||||
'type' => 'rest_selector',
|
||||
'option_key' => 'fluent_cart_products',
|
||||
'is_multiple' => true,
|
||||
'label' => __('Target Products', 'fluent-crm'),
|
||||
'help' => __('Select for which products this automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run for any product\'s order status change', 'fluent-crm'),
|
||||
],
|
||||
'product_categories' => [
|
||||
'type' => 'rest_selector',
|
||||
'option_key' => 'fluent_cart_product_categories',
|
||||
'is_multiple' => true,
|
||||
'label' => __('Or Target Product Categories', 'fluent-crm'),
|
||||
'help' => __('Select for which product category the automation will run', 'fluent-crm'),
|
||||
'inline_help' => __('Keep it blank to run to any category products', 'fluent-crm'),
|
||||
],
|
||||
'from_status' => [
|
||||
'type' => 'select',
|
||||
'label' => __('From Order Status', 'fluent-crm'),
|
||||
'help' => __('The current status that will trigger an action when it changes from this status to the \'To Order Status.\'', 'fluent-crm'),
|
||||
'options' => $formattedStatuses
|
||||
],
|
||||
'to_status' => [
|
||||
'type' => 'select',
|
||||
'label' => __('To Order Status', 'fluent-crm'),
|
||||
'help' => __('The target status that will trigger an action when the order moves from the \'From Order Status\' to this status.', 'fluent-crm'),
|
||||
'options' => $formattedStatuses
|
||||
],
|
||||
'run_multiple' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Restart the Automation Multiple times for a contact for this event. (Only enable if you want to restart automation for the same contact)', 'fluent-crm'),
|
||||
'inline_help' => __('If enabled, it will restart the automation for a contact if the contact is already in the automation. Otherwise, it will skip if it already exists', 'fluent-crm')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
$orderData = $originalArgs[0] ?? [];
|
||||
|
||||
$order = Arr::get($orderData, 'order', []);
|
||||
$fromStatus = Arr::get($orderData, 'old_status', '');
|
||||
$toStatus = Arr::get($orderData, 'new_status', '');
|
||||
|
||||
$customer = Arr::get($order, 'customer');
|
||||
|
||||
$orderId = Arr::get($order, 'id', 0);
|
||||
|
||||
$subscriberData = CartHelper::prepareSubscriberData($customer);
|
||||
|
||||
|
||||
if (!is_email($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $subscriberData, $fromStatus, $toStatus, $order);
|
||||
|
||||
|
||||
$willProcess = apply_filters('fluentcrm_funnel_will_process_' . $this->triggerName, $willProcess, $funnel, $subscriberData, $originalArgs);
|
||||
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
|
||||
|
||||
$subscriberData['status'] = (!empty($subscriberData['subscription_status'])) ? $subscriberData['subscription_status'] : 'subscribed';
|
||||
unset($subscriberData['subscription_status']);
|
||||
|
||||
(new FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $orderId
|
||||
]);
|
||||
}
|
||||
|
||||
private function isProcessable($funnel, $subscriberData, $fromStatus, $toStatus, $order)
|
||||
{
|
||||
$conditions = (array)$funnel->conditions;
|
||||
|
||||
$orderItems = Arr::get($order, 'order_items', []);
|
||||
// Post IDs of ordered products are the product IDs in FluentCart
|
||||
|
||||
$orderedProductIds = [];
|
||||
foreach ($orderItems as $item) {
|
||||
$productId = $item->post_id;
|
||||
if ($productId) {
|
||||
$orderedProductIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
$orderProductCategories = CartHelper::getProductCategoriesByIds($orderedProductIds);
|
||||
|
||||
$selectedProductIds = Arr::get($conditions, 'product_ids', []);
|
||||
$selectedProductCategories = Arr::get($conditions, 'product_categories', []);
|
||||
|
||||
if (!empty($selectedProductIds) || !empty($selectedProductCategories)) {
|
||||
$productMatch = !empty($selectedProductIds) && !empty(array_intersect($selectedProductIds, $orderedProductIds));
|
||||
$categoryMatch = !empty($selectedProductCategories) && !empty(array_intersect($selectedProductCategories, $orderProductCategories));
|
||||
|
||||
if (!$productMatch && !$categoryMatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$fromCondition = Arr::get($conditions, 'from_status', 'any');
|
||||
if ($fromCondition !== 'any' && $fromCondition !== $fromStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$toCondition = Arr::get($conditions, 'to_status', 'any');
|
||||
if ($toCondition !== 'any' && $toCondition !== $toStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
|
||||
if ($subscriber) {
|
||||
$funnelSub = FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id);
|
||||
if ($funnelSub) {
|
||||
$multipleRun = Arr::get($conditions, 'run_multiple') == 'yes';
|
||||
if ($multipleRun) {
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
}
|
||||
return $multipleRun;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\CartHelper;
|
||||
|
||||
|
||||
class SubscriptionActivatedTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluent_cart/subscription_activated';
|
||||
$this->priority = 20;
|
||||
$this->actionArgNum = 1;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'ribbon' => 'subscription',
|
||||
'category' => __('FluentCart', 'fluent-crm'),
|
||||
'label' => __('Subscription Activated', 'fluent-crm'),
|
||||
'description' => __('This will start when a subscription is activated', 'fluent-crm'),
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><g id="surface1"><path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.398438 0 L 21.601562 0 C 22.925781 0 24 1.074219 24 2.398438 L 24 21.601562 C 24 22.925781 22.925781 24 21.601562 24 L 2.398438 24 C 1.074219 24 0 22.925781 0 21.601562 L 0 2.398438 C 0 1.074219 1.074219 0 2.398438 0 Z M 2.398438 0 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 10.925781 16.476562 L 3.769531 16.476562 L 4.894531 13.878906 C 5.222656 13.117188 5.972656 12.625 6.804688 12.625 L 15.328125 12.625 L 14.746094 13.964844 C 14.085938 15.488281 12.585938 16.476562 10.925781 16.476562 Z M 10.925781 16.476562 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 16.851562 11.394531 L 6.789062 11.394531 L 7.367188 10.054688 C 8.027344 8.53125 9.53125 7.542969 11.191406 7.542969 L 19.886719 7.542969 L 18.761719 10.140625 C 18.433594 10.902344 17.683594 11.394531 16.851562 11.394531 Z M 16.851562 11.394531 "/></g></svg>'
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'subscription_status' => 'subscribed',
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
$statuses = fluentcrm_subscriber_editable_statuses(true);
|
||||
return [
|
||||
'title' => __('Subscription Activated', 'fluent-crm'),
|
||||
'sub_title' => __('This will start when a subscription is activated', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'subscription_status' => [
|
||||
'type' => 'select',
|
||||
'options' => $statuses,
|
||||
'is_multiple' => false,
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm'),
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>' . __('An Automated double-optin email will be sent for new subscribers', 'fluent-crm') . '</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
|
||||
'product_ids' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Target Products (Subscription Only)', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_subscription_products',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select the products you want to include in the automation.', 'fluent-crm'),
|
||||
'inline_help' => __('You can select multiple products. If you want to run for all products, then leave it empty', 'fluent-crm'),
|
||||
],
|
||||
|
||||
'run_multiple' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Restart the Automation Multiple times for a contact for this event. (Only enable if you want to restart automation for the same contact)', 'fluent-crm'),
|
||||
'inline_help' => __('If enabled, it will restart the automation for a contact if the contact is already in the automation. Otherwise, it will skip if it already exists', 'fluent-crm'),
|
||||
],
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'product_ids' => [],
|
||||
'run_multiple' => 'no'
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
$subscriptionData = $originalArgs[0];
|
||||
|
||||
$subscription = $subscriptionData['subscription'];
|
||||
$order = $subscriptionData['order'];
|
||||
$customer = $subscriptionData['customer'];
|
||||
|
||||
|
||||
$subscriberData = CartHelper::prepareSubscriberData($customer);
|
||||
|
||||
if (!is_email($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $order, $subscriberData);
|
||||
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
|
||||
|
||||
$subscriberData['status'] = $subscriberData['subscription_status'];
|
||||
unset($subscriberData['subscription_status']);
|
||||
|
||||
(new \FluentCrm\App\Services\Funnel\FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $order->id, // optional
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
public function isProcessable($funnel, $order, $subscriberData)
|
||||
{
|
||||
$conditions = Arr::get($funnel, 'conditions', []);
|
||||
$isProcessable = $this->checkConditions($conditions, $order, $subscriberData);
|
||||
|
||||
if(!$isProcessable){
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
|
||||
// check run_only_one
|
||||
if ($subscriber) {
|
||||
$funnelSub = FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id);
|
||||
if ($funnelSub) {
|
||||
$multipleRun = Arr::get($conditions, 'run_multiple') == 'yes';
|
||||
if ($multipleRun) {
|
||||
if ($funnelSub->source_ref_id == $order->id) {
|
||||
return false;
|
||||
}
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
}
|
||||
return $multipleRun;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function checkConditions($conditions, $order, $subscriber)
|
||||
{
|
||||
$selectedProductIds = Arr::get($conditions, 'product_ids', []);
|
||||
|
||||
if (empty($selectedProductIds)) {
|
||||
return true; // No specific products, process all
|
||||
}
|
||||
|
||||
$orderItems = Arr::get($order, 'order_items', []);
|
||||
// Post IDs of ordered products are the product IDs in FluentCart
|
||||
|
||||
$orderedProductIds = [];
|
||||
foreach ($orderItems as $item) {
|
||||
$productId = $item->post_id;
|
||||
if ($productId) {
|
||||
$orderedProductIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
$productMatch = !empty(array_intersect($selectedProductIds, $orderedProductIds));
|
||||
|
||||
return $productMatch; // Return true if any of the selected products match the ordered products
|
||||
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\CartHelper;
|
||||
|
||||
class SubscriptionCancelledTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluent_cart/subscription_canceled';
|
||||
$this->priority = 20;
|
||||
$this->actionArgNum = 1;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'ribbon' => 'subscription',
|
||||
'category' => __('FluentCart', 'fluent-crm'),
|
||||
'label' => __('Subscription Cancelled', 'fluent-crm'),
|
||||
'description' => __('This will start when a subscription is cancelled', 'fluent-crm'),
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><g id="surface1"><path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.398438 0 L 21.601562 0 C 22.925781 0 24 1.074219 24 2.398438 L 24 21.601562 C 24 22.925781 22.925781 24 21.601562 24 L 2.398438 24 C 1.074219 24 0 22.925781 0 21.601562 L 0 2.398438 C 0 1.074219 1.074219 0 2.398438 0 Z M 2.398438 0 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 10.925781 16.476562 L 3.769531 16.476562 L 4.894531 13.878906 C 5.222656 13.117188 5.972656 12.625 6.804688 12.625 L 15.328125 12.625 L 14.746094 13.964844 C 14.085938 15.488281 12.585938 16.476562 10.925781 16.476562 Z M 10.925781 16.476562 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 16.851562 11.394531 L 6.789062 11.394531 L 7.367188 10.054688 C 8.027344 8.53125 9.53125 7.542969 11.191406 7.542969 L 19.886719 7.542969 L 18.761719 10.140625 C 18.433594 10.902344 17.683594 11.394531 16.851562 11.394531 Z M 16.851562 11.394531 "/></g></svg>'
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'subscription_status' => 'subscribed',
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
$statuses = fluentcrm_subscriber_editable_statuses(true);
|
||||
return [
|
||||
'title' => __('Subscription Cancelled', 'fluent-crm'),
|
||||
'sub_title' => __('This will start when a subscription is cancelled', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'subscription_status' => [
|
||||
'type' => 'select',
|
||||
'options' => $statuses,
|
||||
'is_multiple' => false,
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm'),
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>' . __('An Automated double-optin email will be sent for new subscribers', 'fluent-crm') . '</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
|
||||
'product_ids' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Target Products (Subscription Only)', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_subscription_products',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select the products you want to include in the automation.', 'fluent-crm'),
|
||||
'inline_help' => __('You can select multiple products. If you want to run for all products, then leave it empty', 'fluent-crm'),
|
||||
],
|
||||
|
||||
'run_multiple' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Restart the Automation Multiple times for a contact for this event. (Only enable if you want to restart automation for the same contact)', 'fluent-crm'),
|
||||
'inline_help' => __('If enabled, it will restart the automation for a contact if the contact is already in the automation. Otherwise, it will skip if it already exists', 'fluent-crm'),
|
||||
],
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'product_ids' => [],
|
||||
'run_multiple' => 'no'
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
$subscriptionData = $originalArgs[0];
|
||||
|
||||
$subscription = $subscriptionData['subscription'];
|
||||
$order = $subscriptionData['order'];
|
||||
$customer = $subscriptionData['customer'];
|
||||
|
||||
|
||||
$subscriberData = CartHelper::prepareSubscriberData($customer);
|
||||
|
||||
if (!is_email($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $order, $subscriberData);
|
||||
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
|
||||
|
||||
$subscriberData['status'] = $subscriberData['subscription_status'];
|
||||
unset($subscriberData['subscription_status']);
|
||||
|
||||
(new \FluentCrm\App\Services\Funnel\FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $order->id, // optional
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
public function isProcessable($funnel, $order, $subscriberData)
|
||||
{
|
||||
$conditions = Arr::get($funnel, 'conditions', []);
|
||||
$isProcessable = $this->checkConditions($conditions, $order, $subscriberData);
|
||||
|
||||
if(!$isProcessable){
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
|
||||
// check run_only_one
|
||||
if ($subscriber) {
|
||||
$funnelSub = FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id);
|
||||
if ($funnelSub) {
|
||||
$multipleRun = Arr::get($conditions, 'run_multiple') == 'yes';
|
||||
if ($multipleRun) {
|
||||
if ($funnelSub->source_ref_id == $order->id) {
|
||||
return false;
|
||||
}
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
}
|
||||
return $multipleRun;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function checkConditions($conditions, $order, $subscriber)
|
||||
{
|
||||
$selectedProductIds = Arr::get($conditions, 'product_ids', []);
|
||||
|
||||
if (empty($selectedProductIds)) {
|
||||
return true; // No specific products, process all
|
||||
}
|
||||
|
||||
$orderItems = Arr::get($order, 'order_items', []);
|
||||
// Post IDs of ordered products are the product IDs in FluentCart
|
||||
|
||||
$orderedProductIds = [];
|
||||
foreach ($orderItems as $item) {
|
||||
$productId = $item->post_id;
|
||||
if ($productId) {
|
||||
$orderedProductIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
$productMatch = !empty(array_intersect($selectedProductIds, $orderedProductIds));
|
||||
|
||||
return $productMatch; // Return true if any of the selected products match the ordered products
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\CartHelper;
|
||||
|
||||
|
||||
class SubscriptionEndOfTermTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluent_cart/subscription_eot';
|
||||
$this->priority = 20;
|
||||
$this->actionArgNum = 1;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'ribbon' => 'subscription',
|
||||
'category' => __('FluentCart', 'fluent-crm'),
|
||||
'label' => __('Subscription End of Term (Completed)', 'fluent-crm'),
|
||||
'description' => __('This will start when a subscription reaches it\'s end of term(completed)', 'fluent-crm'),
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><g id="surface1"><path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.398438 0 L 21.601562 0 C 22.925781 0 24 1.074219 24 2.398438 L 24 21.601562 C 24 22.925781 22.925781 24 21.601562 24 L 2.398438 24 C 1.074219 24 0 22.925781 0 21.601562 L 0 2.398438 C 0 1.074219 1.074219 0 2.398438 0 Z M 2.398438 0 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 10.925781 16.476562 L 3.769531 16.476562 L 4.894531 13.878906 C 5.222656 13.117188 5.972656 12.625 6.804688 12.625 L 15.328125 12.625 L 14.746094 13.964844 C 14.085938 15.488281 12.585938 16.476562 10.925781 16.476562 Z M 10.925781 16.476562 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 16.851562 11.394531 L 6.789062 11.394531 L 7.367188 10.054688 C 8.027344 8.53125 9.53125 7.542969 11.191406 7.542969 L 19.886719 7.542969 L 18.761719 10.140625 C 18.433594 10.902344 17.683594 11.394531 16.851562 11.394531 Z M 16.851562 11.394531 "/></g></svg>'
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'subscription_status' => 'subscribed',
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
$statuses = fluentcrm_subscriber_editable_statuses(true);
|
||||
return [
|
||||
'title' => __('Subscription End of Term (Completed)', 'fluent-crm'),
|
||||
'sub_title' => __('This will start when a subscription reaches it\'s end of term(completed)', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'subscription_status' => [
|
||||
'type' => 'select',
|
||||
'options' => $statuses,
|
||||
'is_multiple' => false,
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm'),
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>' . __('An Automated double-optin email will be sent for new subscribers', 'fluent-crm') . '</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
|
||||
'product_ids' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Target Products (Subscription Only)', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_subscription_products',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select the products you want to include in the automation.', 'fluent-crm'),
|
||||
'inline_help' => __('You can select multiple products. If you want to run for all products, then leave it empty', 'fluent-crm'),
|
||||
],
|
||||
|
||||
'run_multiple' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Restart the Automation Multiple times for a contact for this event. (Only enable if you want to restart automation for the same contact)', 'fluent-crm'),
|
||||
'inline_help' => __('If enabled, it will restart the automation for a contact if the contact is already in the automation. Otherwise, it will skip if it already exists', 'fluent-crm'),
|
||||
],
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'product_ids' => [],
|
||||
'run_multiple' => 'no'
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
$subscriptionData = $originalArgs[0];
|
||||
|
||||
$subscription = $subscriptionData['subscription'];
|
||||
$order = $subscriptionData['order'];
|
||||
$customer = $subscriptionData['customer'];
|
||||
|
||||
|
||||
$subscriberData = CartHelper::prepareSubscriberData($customer);
|
||||
|
||||
if (!is_email($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $order, $subscriberData);
|
||||
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
|
||||
|
||||
$subscriberData['status'] = $subscriberData['subscription_status'];
|
||||
unset($subscriberData['subscription_status']);
|
||||
|
||||
(new \FluentCrm\App\Services\Funnel\FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $order->id, // optional
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
public function isProcessable($funnel, $order, $subscriberData)
|
||||
{
|
||||
$conditions = Arr::get($funnel, 'conditions', []);
|
||||
$isProcessable = $this->checkConditions($conditions, $order, $subscriberData);
|
||||
|
||||
if(!$isProcessable){
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
|
||||
// check run_only_one
|
||||
if ($subscriber) {
|
||||
$funnelSub = FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id);
|
||||
if ($funnelSub) {
|
||||
$multipleRun = Arr::get($conditions, 'run_multiple') == 'yes';
|
||||
if ($multipleRun) {
|
||||
if ($funnelSub->source_ref_id == $order->id) {
|
||||
return false;
|
||||
}
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
}
|
||||
return $multipleRun;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function checkConditions($conditions, $order, $subscriber)
|
||||
{
|
||||
$selectedProductIds = Arr::get($conditions, 'product_ids', []);
|
||||
|
||||
if (empty($selectedProductIds)) {
|
||||
return true; // No specific products, process all
|
||||
}
|
||||
|
||||
$orderItems = Arr::get($order, 'order_items', []);
|
||||
// Post IDs of ordered products are the product IDs in FluentCart
|
||||
|
||||
$orderedProductIds = [];
|
||||
foreach ($orderItems as $item) {
|
||||
$productId = $item->post_id;
|
||||
if ($productId) {
|
||||
$orderedProductIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
$productMatch = !empty(array_intersect($selectedProductIds, $orderedProductIds));
|
||||
|
||||
return $productMatch; // Return true if any of the selected products match the ordered products
|
||||
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\CartHelper;
|
||||
|
||||
class SubscriptionExpiredTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluent_cart/subscription_expired_validity';
|
||||
$this->priority = 20;
|
||||
$this->actionArgNum = 1;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'ribbon' => 'subscription',
|
||||
'category' => __('FluentCart', 'fluent-crm'),
|
||||
'label' => __('Subscription Expired / End of Access Validity', 'fluent-crm'),
|
||||
'description' => __('This will start when a subscription expires', 'fluent-crm'),
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><g id="surface1"><path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.398438 0 L 21.601562 0 C 22.925781 0 24 1.074219 24 2.398438 L 24 21.601562 C 24 22.925781 22.925781 24 21.601562 24 L 2.398438 24 C 1.074219 24 0 22.925781 0 21.601562 L 0 2.398438 C 0 1.074219 1.074219 0 2.398438 0 Z M 2.398438 0 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 10.925781 16.476562 L 3.769531 16.476562 L 4.894531 13.878906 C 5.222656 13.117188 5.972656 12.625 6.804688 12.625 L 15.328125 12.625 L 14.746094 13.964844 C 14.085938 15.488281 12.585938 16.476562 10.925781 16.476562 Z M 10.925781 16.476562 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 16.851562 11.394531 L 6.789062 11.394531 L 7.367188 10.054688 C 8.027344 8.53125 9.53125 7.542969 11.191406 7.542969 L 19.886719 7.542969 L 18.761719 10.140625 C 18.433594 10.902344 17.683594 11.394531 16.851562 11.394531 Z M 16.851562 11.394531 "/></g></svg>'
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'subscription_status' => 'subscribed',
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
$statuses = fluentcrm_subscriber_editable_statuses(true);
|
||||
return [
|
||||
'title' => __('Subscription Expired / End of Access Validity', 'fluent-crm'),
|
||||
'sub_title' => __('This will start when a subscription expires', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'subscription_status' => [
|
||||
'type' => 'select',
|
||||
'options' => $statuses,
|
||||
'is_multiple' => false,
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm'),
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>' . __('An Automated double-optin email will be sent for new subscribers', 'fluent-crm') . '</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
|
||||
'product_ids' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Target Products (Subscription Only)', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_subscription_products',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select the products you want to include in the automation.', 'fluent-crm'),
|
||||
'inline_help' => __('You can select multiple products. If you want to run for all products, then leave it empty', 'fluent-crm'),
|
||||
],
|
||||
|
||||
'run_multiple' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Restart the Automation Multiple times for a contact for this event. (Only enable if you want to restart automation for the same contact)', 'fluent-crm'),
|
||||
'inline_help' => __('If enabled, it will restart the automation for a contact if the contact is already in the automation. Otherwise, it will skip if it already exists', 'fluent-crm'),
|
||||
],
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'product_ids' => [],
|
||||
'run_multiple' => 'no'
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
$subscriptionData = $originalArgs[0];
|
||||
|
||||
$subscription = $subscriptionData['subscription'];
|
||||
$order = $subscriptionData['order'];
|
||||
$customer = $subscriptionData['customer'];
|
||||
// $oldStatus = $subscriptionData['old_status'];
|
||||
// $newStatus = $subscription->status;
|
||||
|
||||
|
||||
$subscriberData = CartHelper::prepareSubscriberData($customer);
|
||||
|
||||
if (!is_email($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $order, $subscriberData);
|
||||
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
|
||||
|
||||
$subscriberData['status'] = $subscriberData['subscription_status'];
|
||||
unset($subscriberData['subscription_status']);
|
||||
|
||||
(new \FluentCrm\App\Services\Funnel\FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $order->id, // optional
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
public function isProcessable($funnel, $order, $subscriberData)
|
||||
{
|
||||
$conditions = Arr::get($funnel, 'conditions', []);
|
||||
$isProcessable = $this->checkConditions($conditions, $order, $subscriberData);
|
||||
|
||||
if(!$isProcessable){
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
|
||||
// check run_only_one
|
||||
if ($subscriber) {
|
||||
$funnelSub = FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id);
|
||||
if ($funnelSub) {
|
||||
$multipleRun = Arr::get($conditions, 'run_multiple') == 'yes';
|
||||
if ($multipleRun) {
|
||||
if ($funnelSub->source_ref_id == $order->id) {
|
||||
return false;
|
||||
}
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
}
|
||||
return $multipleRun;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function checkConditions($conditions, $order, $subscriber)
|
||||
{
|
||||
$selectedProductIds = Arr::get($conditions, 'product_ids', []);
|
||||
|
||||
if (empty($selectedProductIds)) {
|
||||
return true; // No specific products, process all
|
||||
}
|
||||
|
||||
$orderItems = Arr::get($order, 'order_items', []);
|
||||
// Post IDs of ordered products are the product IDs in FluentCart
|
||||
|
||||
$orderedProductIds = [];
|
||||
foreach ($orderItems as $item) {
|
||||
$productId = $item->post_id;
|
||||
if ($productId) {
|
||||
$orderedProductIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
$productMatch = !empty(array_intersect($selectedProductIds, $orderedProductIds));
|
||||
|
||||
return $productMatch; // Return true if any of the selected products match the ordered products
|
||||
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentCart\Triggers;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\FluentCart\CartHelper;
|
||||
|
||||
class SubscriptionRenewedTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluent_cart/subscription_renewed';
|
||||
$this->priority = 20;
|
||||
$this->actionArgNum = 1;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'ribbon' => 'subscription',
|
||||
'category' => __('FluentCart', 'fluent-crm'),
|
||||
'label' => __('Subscription Renewed', 'fluent-crm'),
|
||||
'description' => __('This will start when a subscription is renewed', 'fluent-crm'),
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><g id="surface1"><path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.398438 0 L 21.601562 0 C 22.925781 0 24 1.074219 24 2.398438 L 24 21.601562 C 24 22.925781 22.925781 24 21.601562 24 L 2.398438 24 C 1.074219 24 0 22.925781 0 21.601562 L 0 2.398438 C 0 1.074219 1.074219 0 2.398438 0 Z M 2.398438 0 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 10.925781 16.476562 L 3.769531 16.476562 L 4.894531 13.878906 C 5.222656 13.117188 5.972656 12.625 6.804688 12.625 L 15.328125 12.625 L 14.746094 13.964844 C 14.085938 15.488281 12.585938 16.476562 10.925781 16.476562 Z M 10.925781 16.476562 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 16.851562 11.394531 L 6.789062 11.394531 L 7.367188 10.054688 C 8.027344 8.53125 9.53125 7.542969 11.191406 7.542969 L 19.886719 7.542969 L 18.761719 10.140625 C 18.433594 10.902344 17.683594 11.394531 16.851562 11.394531 Z M 16.851562 11.394531 "/></g></svg>'
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'subscription_status' => 'subscribed',
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
$statuses = fluentcrm_subscriber_editable_statuses(true);
|
||||
return [
|
||||
'title' => __('Subscription Renewed', 'fluent-crm'),
|
||||
'sub_title' => __('This will start when a subscription is renewed', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'subscription_status' => [
|
||||
'type' => 'select',
|
||||
'options' => $statuses,
|
||||
'is_multiple' => false,
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm'),
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>' . __('An Automated double-optin email will be sent for new subscribers', 'fluent-crm') . '</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
|
||||
'product_ids' => [
|
||||
'type' => 'rest_selector',
|
||||
'label' => __('Target Products (Subscription Only)', 'fluent-crm'),
|
||||
'option_key' => 'fluent_cart_subscription_products',
|
||||
'is_multiple' => true,
|
||||
'help' => __('Select the products you want to include in the automation.', 'fluent-crm'),
|
||||
'inline_help' => __('You can select multiple products. If you want to run for all products, then leave it empty', 'fluent-crm'),
|
||||
],
|
||||
|
||||
'run_multiple' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Restart the Automation Multiple times for a contact for this event. (Only enable if you want to restart automation for the same contact)', 'fluent-crm'),
|
||||
'inline_help' => __('If enabled, it will restart the automation for a contact if the contact is already in the automation. Otherwise, it will skip if it already exists', 'fluent-crm'),
|
||||
],
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'product_ids' => [],
|
||||
'run_multiple' => 'no'
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
$subscriptionData = $originalArgs[0];
|
||||
|
||||
$subscription = $subscriptionData['subscription'];
|
||||
$order = $subscriptionData['order'];
|
||||
$customer = $subscriptionData['customer'];
|
||||
|
||||
|
||||
$subscriberData = CartHelper::prepareSubscriberData($customer);
|
||||
|
||||
if (!is_email($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $order, $subscriberData);
|
||||
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
|
||||
|
||||
$subscriberData['status'] = $subscriberData['subscription_status'];
|
||||
unset($subscriberData['subscription_status']);
|
||||
|
||||
(new \FluentCrm\App\Services\Funnel\FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $order->id, // optional
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
public function isProcessable($funnel, $order, $subscriberData)
|
||||
{
|
||||
$conditions = Arr::get($funnel, 'conditions', []);
|
||||
$isProcessable = $this->checkConditions($conditions, $order, $subscriberData);
|
||||
|
||||
if(!$isProcessable){
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
|
||||
// check run_only_one
|
||||
if ($subscriber) {
|
||||
$funnelSub = FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id);
|
||||
if ($funnelSub) {
|
||||
$multipleRun = Arr::get($conditions, 'run_multiple') == 'yes';
|
||||
if ($multipleRun) {
|
||||
if ($funnelSub->source_ref_id == $order->id) {
|
||||
return false;
|
||||
}
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
}
|
||||
return $multipleRun;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function checkConditions($conditions, $order, $subscriber)
|
||||
{
|
||||
$selectedProductIds = Arr::get($conditions, 'product_ids', []);
|
||||
|
||||
if (empty($selectedProductIds)) {
|
||||
return true; // No specific products, process all
|
||||
}
|
||||
|
||||
$orderItems = Arr::get($order, 'order_items', []);
|
||||
// Post IDs of ordered products are the product IDs in FluentCart
|
||||
|
||||
$orderedProductIds = [];
|
||||
foreach ($orderItems as $item) {
|
||||
$productId = $item->post_id;
|
||||
if ($productId) {
|
||||
$orderedProductIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
$productMatch = !empty(array_intersect($selectedProductIds, $orderedProductIds));
|
||||
|
||||
return $productMatch; // Return true if any of the selected products match the ordered products
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+659
@@ -0,0 +1,659 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentForm;
|
||||
|
||||
use FluentCrm\App\Models\CustomContactField;
|
||||
use FluentCrm\App\Models\Lists;
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Models\Tag;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentForm\App\Http\Controllers\IntegrationManagerController;
|
||||
use FluentForm\App\Modules\Form\FormFieldsParser;
|
||||
use FluentForm\App\Services\FormBuilder\ShortCodeParser;
|
||||
use FluentForm\App\Services\Integrations\GlobalNotificationService;
|
||||
use FluentForm\Framework\Helpers\ArrayHelper;
|
||||
|
||||
class Bootstrap extends IntegrationManagerController
|
||||
{
|
||||
public $hasGlobalMenu = false;
|
||||
|
||||
public $disableGlobalSettings = 'yes';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(
|
||||
null,
|
||||
__('FluentCRM', 'fluent-crm'),
|
||||
'fluentcrm',
|
||||
'_fluentform_fluentcrm_settings',
|
||||
'fluentcrm_feeds',
|
||||
10
|
||||
);
|
||||
|
||||
$this->logo = FLUENTCRM_PLUGIN_URL . 'assets/images/fluentcrm-logo.svg';
|
||||
|
||||
$this->description = __('Connect FluentCRM with WP Fluent Forms and subscribe a contact when a form is submitted.', 'fluent-crm');
|
||||
|
||||
$this->registerAdminHooks();
|
||||
|
||||
add_filter('fluentform/notifying_async_fluentcrm', '__return_false');
|
||||
|
||||
$this->registerPaymentEvents();
|
||||
|
||||
}
|
||||
|
||||
public function pushIntegration($integrations, $formId)
|
||||
{
|
||||
$integrations[$this->integrationKey] = [
|
||||
'title' => $this->title . ' Integration',
|
||||
'logo' => $this->logo,
|
||||
'is_active' => $this->isConfigured(),
|
||||
'configure_title' => __('Configuration required!', 'fluent-crm'),
|
||||
'global_configure_url' => '#',
|
||||
'configure_message' => __('FluentCRM is not configured yet! Please configure your FluentCRM API first', 'fluent-crm'),
|
||||
'configure_button_text' => __('Set FluentCRM', 'fluent-crm')
|
||||
];
|
||||
return $integrations;
|
||||
}
|
||||
|
||||
public function getIntegrationDefaults($settings, $formId)
|
||||
{
|
||||
return [
|
||||
'name' => '',
|
||||
'first_name' => '',
|
||||
'last_name' => '',
|
||||
'full_name' => '',
|
||||
'email' => '',
|
||||
'other_fields' => [
|
||||
[
|
||||
'item_value' => '',
|
||||
'label' => ''
|
||||
]
|
||||
],
|
||||
'list_id' => '',
|
||||
'tag_ids' => [],
|
||||
'tag_ids_selection_type' => 'simple',
|
||||
'tag_routers' => [],
|
||||
'skip_if_exists' => false,
|
||||
'double_opt_in' => false,
|
||||
'force_subscribe' => false,
|
||||
'skip_primary_data' => false,
|
||||
'conditionals' => [
|
||||
'conditions' => [],
|
||||
'status' => false,
|
||||
'type' => 'all'
|
||||
],
|
||||
'run_events_only' => [],
|
||||
'remove_tags' => [],
|
||||
'enabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($settings, $formId)
|
||||
{
|
||||
$form = fluentFormApi('forms')->find($formId);
|
||||
$paymentFields = FormFieldsParser::getPaymentFields($form, ['element']);
|
||||
|
||||
$fieldOptions = [];
|
||||
|
||||
foreach (Subscriber::mappables() as $key => $column) {
|
||||
$fieldOptions[$key] = $column;
|
||||
}
|
||||
|
||||
foreach ((new CustomContactField)->getGlobalFields()['fields'] as $field) {
|
||||
$fieldOptions[$field['slug']] = $field['label'];
|
||||
}
|
||||
|
||||
$fieldOptions['avatar'] = 'Profile Photo';
|
||||
|
||||
unset($fieldOptions['email']);
|
||||
unset($fieldOptions['first_name']);
|
||||
unset($fieldOptions['last_name']);
|
||||
|
||||
$fields = [
|
||||
[
|
||||
'key' => 'name',
|
||||
'label' => __('Feed Name', 'fluent-crm'),
|
||||
'required' => true,
|
||||
'placeholder' => __('Your Feed Name', 'fluent-crm'),
|
||||
'component' => 'text'
|
||||
],
|
||||
[
|
||||
'key' => 'list_id',
|
||||
'label' => __('FluentCRM List', 'fluent-crm'),
|
||||
'placeholder' => __('Select FluentCRM List', 'fluent-crm'),
|
||||
'tips' => __('Select the FluentCRM List you would like to add your contacts to.', 'fluent-crm'),
|
||||
'component' => 'select',
|
||||
'required' => false,
|
||||
'options' => $this->getLists(),
|
||||
],
|
||||
[
|
||||
'key' => 'CustomFields',
|
||||
'require_list' => false,
|
||||
'label' => __('Primary Fields', 'fluent-crm'),
|
||||
'tips' => __('Associate your FluentCRM merge tags to the appropriate Fluent Form fields by selecting the appropriate form field from the list.', 'fluent-crm'),
|
||||
'component' => 'map_fields',
|
||||
'field_label_remote' => __('FluentCRM Field', 'fluent-crm'),
|
||||
'field_label_local' => __('Form Field', 'fluent-crm'),
|
||||
'primary_fileds' => [
|
||||
[
|
||||
'key' => 'email',
|
||||
'label' => __('Email Address', 'fluent-crm'),
|
||||
'required' => true,
|
||||
'input_options' => 'emails'
|
||||
],
|
||||
[
|
||||
'key' => 'first_name',
|
||||
'label' => __('First Name', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'key' => 'last_name',
|
||||
'label' => __('Last Name', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'key' => 'full_name',
|
||||
'label' => __('Full Name', 'fluent-crm'),
|
||||
'help_text' => __('If First Name & Last Name is not available full name will be used to get first name and last name', 'fluent-crm')
|
||||
]
|
||||
]
|
||||
],
|
||||
[
|
||||
'key' => 'other_fields',
|
||||
'require_list' => false,
|
||||
'label' => __('Other Fields', 'fluent-crm'),
|
||||
'tips' => __('Select which Fluent Form fields pair with their<br /> respective FluentCRM fields.', 'fluent-crm'),
|
||||
'component' => 'dropdown_many_fields',
|
||||
'field_label_remote' => __('FluentCRM Field', 'fluent-crm'),
|
||||
'field_label_local' => __('Form Field', 'fluent-crm'),
|
||||
'options' => $fieldOptions
|
||||
],
|
||||
[
|
||||
'key' => 'tag_ids',
|
||||
'require_list' => false,
|
||||
'label' => __('Contact Tags', 'fluent-crm'),
|
||||
'placeholder' => __('Select Tags', 'fluent-crm'),
|
||||
'component' => 'selection_routing',
|
||||
'simple_component' => 'select',
|
||||
'routing_input_type' => 'select',
|
||||
'routing_key' => 'tag_ids_selection_type',
|
||||
'settings_key' => 'tag_routers',
|
||||
'is_multiple' => true,
|
||||
'labels' => [
|
||||
'choice_label' => __('Enable Dynamic Tag Selection', 'fluent-crm'),
|
||||
'input_label' => '',
|
||||
'input_placeholder' => __('Set Tag', 'fluent-crm')
|
||||
],
|
||||
'options' => $this->getTags()
|
||||
],
|
||||
[
|
||||
'key' => 'skip_if_exists',
|
||||
'require_list' => false,
|
||||
'checkbox_label' => __('Skip if contact already exists in FluentCRM', 'fluent-crm'),
|
||||
'component' => 'checkbox-single'
|
||||
],
|
||||
[
|
||||
'key' => 'skip_primary_data',
|
||||
'require_list' => false,
|
||||
'checkbox_label' => __('Skip name update if an existing contact has old data (per primary field)', 'fluent-crm'),
|
||||
'component' => 'checkbox-single'
|
||||
],
|
||||
[
|
||||
'key' => 'double_opt_in',
|
||||
'require_list' => false,
|
||||
'checkbox_label' => __('Enable Double opt-in for new contacts', 'fluent-crm'),
|
||||
'component' => 'checkbox-single'
|
||||
],
|
||||
[
|
||||
'key' => 'force_subscribe',
|
||||
'require_list' => false,
|
||||
'checkbox_label' => __('Enable Force Subscribe if contact is not in subscribed status (Existing contact only)', 'fluent-crm'),
|
||||
'component' => 'checkbox-single',
|
||||
'inline_tip' => __('If you enable this, the contact will be forcefully subscribed regardless of the contact\'s current status', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'require_list' => false,
|
||||
'key' => 'conditionals',
|
||||
'label' => __('Conditional Logics', 'fluent-crm'),
|
||||
'tips' => __('Allow FluentCRM integration conditionally based on your submission values', 'fluent-crm'),
|
||||
'component' => 'conditional_block'
|
||||
]
|
||||
];
|
||||
|
||||
if ($paymentFields) {
|
||||
$hasSubscriptionFields = !!FormFieldsParser::getInputsByElementTypes($form, ['subscription_payment_component']);
|
||||
|
||||
$options = [
|
||||
'fluentform/payment_refunded' => __('On Payment Refund', 'fluent-crm')
|
||||
];
|
||||
|
||||
if ($hasSubscriptionFields) {
|
||||
$options = [
|
||||
'fluentform/subscription_payment_active' => __('On Subscription Active', 'fluent-crm'),
|
||||
'fluentform/subscription_payment_canceled' => __('On Subscription Cancel', 'fluent-crm'),
|
||||
'fluentform/payment_refunded' => __('On Payment Refund', 'fluent-crm')
|
||||
];
|
||||
}
|
||||
|
||||
$fields[] = [
|
||||
'require_list' => false,
|
||||
'key' => 'run_events_only',
|
||||
'label' => __('Run only on events', 'fluent-crm'),
|
||||
'component' => 'checkbox-multiple-text',
|
||||
'options' => $options,
|
||||
'tips' => __('If you check any of the events, then this feed will only run for the selected events', 'fluent-crm')
|
||||
];
|
||||
}
|
||||
|
||||
$fields[] = [
|
||||
'require_list' => false,
|
||||
'key' => 'remove_tags',
|
||||
'label' => __('Remove Contact Tags', 'fluent-crm'),
|
||||
'placeholder' => __('Select Tags (remove from contact)', 'fluent-crm'),
|
||||
'tips' => __('(Optional) The selected tags will be removed from the contact (if it exists)', 'fluent-crm'),
|
||||
'component' => 'select',
|
||||
'is_multiple' => true,
|
||||
'required' => false,
|
||||
'options' => $this->getTags(),
|
||||
];
|
||||
|
||||
$fields[] = [
|
||||
'require_list' => false,
|
||||
'key' => 'enabled',
|
||||
'label' => __('Status', 'fluent-crm'),
|
||||
'component' => 'checkbox-single',
|
||||
'checkbox_label' => __('Enable This feed', 'fluent-crm')
|
||||
];
|
||||
|
||||
return [
|
||||
'fields' => $fields,
|
||||
'button_require_list' => false,
|
||||
'integration_title' => $this->title
|
||||
];
|
||||
}
|
||||
|
||||
public function getMergeFields($list, $listId, $formId)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function getLists()
|
||||
{
|
||||
$lists = Lists::orderBy('title', 'ASC')->get();
|
||||
$formattedLists = [];
|
||||
foreach ($lists as $list) {
|
||||
$formattedLists[$list->id] = $list->title;
|
||||
}
|
||||
return $formattedLists;
|
||||
}
|
||||
|
||||
protected function getTags()
|
||||
{
|
||||
$tags = Tag::orderBy('title', 'ASC')->get();
|
||||
$formattedTags = [];
|
||||
foreach ($tags as $tag) {
|
||||
$formattedTags[strval($tag->id)] = $tag->title;
|
||||
}
|
||||
return $formattedTags;
|
||||
}
|
||||
|
||||
/*
|
||||
* Form Submission Hooks Here
|
||||
*/
|
||||
public function notify($feed, $formData, $entry, $form)
|
||||
{
|
||||
// check if only on payment event
|
||||
if (Arr::get($feed, 'settings.run_events_only')) {
|
||||
// We have running events selected. So we may not run this feed.
|
||||
$paymentFields = FormFieldsParser::getPaymentFields($form, ['element']);
|
||||
if ($paymentFields) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->runFeed($feed, $formData, $entry, $form);
|
||||
}
|
||||
|
||||
|
||||
private function runFeed($feed, $formData, $entry, $form)
|
||||
{
|
||||
$data = $feed['processedValues'];
|
||||
$contact = Arr::only($data, ['first_name', 'last_name', 'email']);
|
||||
|
||||
if (!is_email($contact['email'])) {
|
||||
$contact['email'] = ArrayHelper::get($formData, $contact['email']);
|
||||
}
|
||||
|
||||
if (!$contact['first_name'] && !$contact['last_name']) {
|
||||
$fullName = Arr::get($data, 'full_name');
|
||||
if ($fullName) {
|
||||
$nameArray = explode(' ', $fullName);
|
||||
if (count($nameArray) > 1) {
|
||||
$contact['last_name'] = array_pop($nameArray);
|
||||
$contact['first_name'] = implode(' ', $nameArray);
|
||||
} else {
|
||||
$contact['first_name'] = $fullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Arr::get($data, 'other_fields') as $field) {
|
||||
if ($field['item_value']) {
|
||||
$contact[$field['label']] = str_replace('<br />', ' ', $field['item_value']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($entry->ip) {
|
||||
$contact['ip'] = $entry->ip;
|
||||
}
|
||||
|
||||
if (!is_email($contact['email'])) {
|
||||
$this->addLog(
|
||||
$feed['settings']['name'],
|
||||
'failed',
|
||||
__('FluentCRM API called skipped because no valid email available', 'fluent-crm'),
|
||||
$form->id,
|
||||
$entry->id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($contact['country'])) {
|
||||
$country = FunnelHelper::getCountryShortName($contact['country']);
|
||||
if ($country) {
|
||||
$contact['country'] = $country;
|
||||
} else {
|
||||
unset($contact['country']);
|
||||
}
|
||||
}
|
||||
|
||||
$subscriber = Subscriber::where('email', $contact['email'])->first();
|
||||
|
||||
if ($subscriber && Arr::isTrue($data, 'skip_if_exists')) {
|
||||
$this->addLog(
|
||||
$feed['settings']['name'],
|
||||
'info',
|
||||
__('Contact creation has been skipped because the contact already exists in the database', 'fluent-crm'),
|
||||
$form->id,
|
||||
$entry->id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (!empty($contact['avatar'])) {
|
||||
// validate the avatar photo
|
||||
$validUrl = '';
|
||||
if (filter_var($contact['avatar'], FILTER_VALIDATE_URL) !== FALSE) {
|
||||
$url = $contact['avatar'];
|
||||
$dots = explode('.', $url);
|
||||
$ext = strtolower(end($dots));
|
||||
|
||||
if (in_array($ext, ['png', 'jpg', 'jpeg', 'webp', 'gif'])) {
|
||||
$validUrl = $contact['avatar'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$validUrl) {
|
||||
unset($contact['avatar']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($subscriber) {
|
||||
if ($subscriber->ip && isset($contact['ip'])) {
|
||||
unset($contact['ip']);
|
||||
}
|
||||
|
||||
if (Arr::isTrue($data, 'skip_primary_data')) {
|
||||
if ($subscriber->first_name) {
|
||||
unset($contact['first_name']);
|
||||
unset($contact['last_name']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$user = get_user_by('email', $contact['email']);
|
||||
if ($user) {
|
||||
$contact['user_id'] = $user->ID;
|
||||
}
|
||||
|
||||
$tags = $this->getSelectedTagIds($data, $formData, 'tag_ids');
|
||||
if ($tags) {
|
||||
$contact['tags'] = $tags;
|
||||
}
|
||||
|
||||
if (!$subscriber) {
|
||||
if (empty($contact['source'])) {
|
||||
$contact['source'] = 'FluentForms';
|
||||
}
|
||||
|
||||
if (Arr::isTrue($data, 'double_opt_in')) {
|
||||
$contact['status'] = 'pending';
|
||||
} else {
|
||||
$contact['status'] = 'subscribed';
|
||||
}
|
||||
|
||||
if ($listId = Arr::get($data, 'list_id')) {
|
||||
$contact['lists'] = [$listId];
|
||||
}
|
||||
|
||||
$subscriber = FluentCrmApi('contacts')->createOrUpdate($contact, false, false);
|
||||
|
||||
if (!$subscriber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($entry->status == 'confirmed' && $subscriber->status != 'subscribed') {
|
||||
$subscriber = $subscriber->updateStatus('subscribed');
|
||||
}
|
||||
|
||||
if ($subscriber->status == 'pending') {
|
||||
$subscriber->sendDoubleOptinEmail();
|
||||
}
|
||||
|
||||
$this->addLog(
|
||||
$feed['settings']['name'],
|
||||
'success',
|
||||
__('Contact has been created in FluentCRM. Contact ID: ', 'fluent-crm') . $subscriber->id,
|
||||
$form->id,
|
||||
$entry->id
|
||||
);
|
||||
|
||||
do_action('fluent_crm/contact_added_by_fluentform', $subscriber, $entry, $form, $feed);
|
||||
|
||||
} else {
|
||||
|
||||
if ($listId = Arr::get($data, 'list_id')) {
|
||||
$contact['lists'] = [$listId];
|
||||
}
|
||||
|
||||
$hasDouBleOptIn = Arr::isTrue($data, 'double_opt_in');
|
||||
|
||||
$forceSubscribed = !$hasDouBleOptIn && ($subscriber->status != 'subscribed');
|
||||
|
||||
if (!$forceSubscribed) {
|
||||
$forceSubscribed = Arr::isTrue($data, 'force_subscribe');
|
||||
}
|
||||
|
||||
if ($forceSubscribed) {
|
||||
$contact['status'] = 'subscribed';
|
||||
}
|
||||
|
||||
$originalFields = FluentCrmApi('contacts')->getContact($contact['email'])->getOriginal();
|
||||
|
||||
$currentCustomValues = $subscriber->custom_fields();
|
||||
|
||||
$subscriber = FluentCrmApi('contacts')->createOrUpdate($contact, $forceSubscribed, false);
|
||||
|
||||
if (!$subscriber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($entry->status == 'confirmed' && $subscriber->status != 'subscribed') {
|
||||
$subscriber = $subscriber->updateStatus('subscribed');
|
||||
}
|
||||
|
||||
if ($hasDouBleOptIn && ($subscriber->status == 'pending' || $subscriber->status == 'unsubscribed')) {
|
||||
$subscriber->sendDoubleOptinEmail();
|
||||
}
|
||||
|
||||
do_action('fluent_crm/contact_updated_by_fluentform', $subscriber, $entry, $form, $feed);
|
||||
|
||||
if ($removeTags = Arr::get($feed, 'settings.remove_tags', [])) {
|
||||
$subscriber->detachTags($removeTags);
|
||||
}
|
||||
|
||||
$dirtyFields = array_merge($feed['processedValues'], ['dirty_custom_fields' => $currentCustomValues]);
|
||||
do_action('fluent_crm/contact_updated_with_changes', $subscriber, $dirtyFields, $originalFields, ['source' => 'fluentform', 'formId' => $form->id]);
|
||||
|
||||
$this->addLog(
|
||||
$feed['settings']['name'],
|
||||
'success',
|
||||
__('Contact has been updated in FluentCRM. Contact ID: ', 'fluent-crm') . $subscriber->id,
|
||||
$form->id,
|
||||
$entry->id
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function isConfigured()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isEnabled()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function addLog($title, $status, $description, $formId, $entryId)
|
||||
{
|
||||
do_action('ff_log_data', [
|
||||
'title' => $title,
|
||||
'status' => $status,
|
||||
'description' => $description,
|
||||
'parent_source_id' => $formId,
|
||||
'source_id' => $entryId,
|
||||
'component' => $this->integrationKey,
|
||||
'source_type' => 'submission_item'
|
||||
]);
|
||||
}
|
||||
|
||||
/*
|
||||
* We will remove this in future
|
||||
*/
|
||||
protected function getSelectedTagIds($data, $inputData, $simpleKey = 'tag_ids', $routingId = 'tag_ids_selection_type', $routersKey = 'tag_routers')
|
||||
{
|
||||
$routing = ArrayHelper::get($data, $routingId, 'simple');
|
||||
if (!$routing || $routing == 'simple') {
|
||||
return ArrayHelper::get($data, $simpleKey, []);
|
||||
}
|
||||
|
||||
$routers = ArrayHelper::get($data, $routersKey);
|
||||
if (empty($routers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->evaluateRoutings($routers, $inputData);
|
||||
}
|
||||
|
||||
/*
|
||||
* We will remove this in future
|
||||
*/
|
||||
protected function evaluateRoutings($routings, $inputData)
|
||||
{
|
||||
$validInputs = [];
|
||||
foreach ($routings as $routing) {
|
||||
$inputValue = ArrayHelper::get($routing, 'input_value');
|
||||
if (!$inputValue) {
|
||||
continue;
|
||||
}
|
||||
$condition = [
|
||||
'conditionals' => [
|
||||
'status' => true,
|
||||
'is_test' => true,
|
||||
'type' => 'any',
|
||||
'conditions' => [
|
||||
$routing
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
if (\FluentForm\App\Services\ConditionAssesor::evaluate($condition, $inputData)) {
|
||||
$validInputs[] = $inputValue;
|
||||
}
|
||||
}
|
||||
|
||||
return $validInputs;
|
||||
}
|
||||
|
||||
private function registerPaymentEvents()
|
||||
{
|
||||
add_action('fluentform/subscription_payment_active', function ($subscription, $submission) {
|
||||
$this->handlePaymentEvent($submission, 'fluentform/subscription_payment_active');
|
||||
}, 10, 2);
|
||||
add_action('fluentform/subscription_payment_canceled', function ($subscription, $submission) {
|
||||
$this->handlePaymentEvent($submission, 'fluentform/subscription_payment_canceled');
|
||||
}, 10, 2);
|
||||
|
||||
add_action('fluentform/payment_refunded', function ($refund, $transaction, $submission) {
|
||||
$this->handlePaymentEvent($submission, 'fluentform/payment_refunded');
|
||||
}, 10, 3);
|
||||
}
|
||||
|
||||
private function handlePaymentEvent($submission, $event)
|
||||
{
|
||||
// Get Fluent Forms Feeds
|
||||
$feeds = fluentCrmDb()->table('fluentform_form_meta')
|
||||
->where('form_id', $submission->form_id)
|
||||
->where('meta_key', 'fluentcrm_feeds')
|
||||
->orderBy('id', 'ASC')
|
||||
->get();
|
||||
|
||||
if ($feeds->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (!is_array($submission->response)) {
|
||||
$formData = json_decode($submission->response, true);
|
||||
} else {
|
||||
$formData = $submission->response;
|
||||
}
|
||||
|
||||
$form = fluentFormApi('forms')->find($submission->form_id);
|
||||
|
||||
$notificationService = new GlobalNotificationService();
|
||||
|
||||
foreach ($feeds as $feed) {
|
||||
$parsedValue = json_decode($feed->value, true);
|
||||
if ($parsedValue && ArrayHelper::isTrue($parsedValue, 'enabled')) {
|
||||
|
||||
$runEvents = ArrayHelper::get($parsedValue, 'run_events_only', []);
|
||||
|
||||
// check if this is our event or not
|
||||
if (!$runEvents || !in_array($event, $runEvents)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Now check if conditions matched or not
|
||||
$isConditionMatched = $notificationService->checkCondition($parsedValue, $formData, $submission->id);
|
||||
if ($isConditionMatched) {
|
||||
$item = [
|
||||
'id' => $feed->id,
|
||||
'meta_key' => $feed->meta_key,
|
||||
'settings' => $parsedValue
|
||||
];
|
||||
|
||||
$processedValues = $item['settings'];
|
||||
unset($processedValues['conditionals']);
|
||||
|
||||
$item['processedValues'] = ShortCodeParser::parse($processedValues, $submission->id, $formData, $form, false, $feed->meta_key);
|
||||
|
||||
$this->runFeed($item, $formData, $submission, $form);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\FluentForm;
|
||||
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentForm\App\Helpers\Helper;
|
||||
use FluentFormPro\Payments\PaymentHelper;
|
||||
|
||||
class FluentFormInit
|
||||
{
|
||||
public function init()
|
||||
{
|
||||
if (defined('FLUENTFORM_FRAMEWORK_UPGRADE')) {
|
||||
new \FluentCrm\App\Services\ExternalIntegrations\FluentForm\Bootstrap();
|
||||
}
|
||||
|
||||
add_filter('fluentform/submissions_widgets', array($this, 'pushContactWidget'), 10, 3);
|
||||
if (defined('FLUENTFORMPRO')) {
|
||||
add_filter('fluent_crm/subscriber_info_widgets', array($this, 'pushSubscriberInfoWidget'), 10, 2);
|
||||
}
|
||||
}
|
||||
|
||||
public function pushContactWidget($widgets, $resources, $submission)
|
||||
{
|
||||
$userId = $submission->user_id;
|
||||
|
||||
if (!$userId) {
|
||||
$userInputs = json_decode($submission->response, true);
|
||||
|
||||
if (!$userInputs) {
|
||||
return $widgets;
|
||||
}
|
||||
|
||||
$maybeEmail = Arr::get($userInputs, 'email');
|
||||
|
||||
if (!$maybeEmail) {
|
||||
$emailField = Helper::getFormMeta($submission->form_id, '_primary_email_field');
|
||||
if (!$emailField) {
|
||||
return $widgets;
|
||||
}
|
||||
$maybeEmail = Arr::get($userInputs, $emailField);
|
||||
}
|
||||
} else {
|
||||
$maybeEmail = $userId;
|
||||
}
|
||||
|
||||
if (!$maybeEmail) {
|
||||
return $widgets;
|
||||
}
|
||||
|
||||
$profileHtml = fluentcrm_get_crm_profile_html($maybeEmail, true);
|
||||
if (!$profileHtml) {
|
||||
return $widgets;
|
||||
}
|
||||
|
||||
$widgets['fluent_crm'] = [
|
||||
'title' => __('FluentCRM Profile', 'fluent-crm'),
|
||||
'content' => $profileHtml
|
||||
];
|
||||
return $widgets;
|
||||
}
|
||||
|
||||
public function pushSubscriberInfoWidget($widgets, $subscriber)
|
||||
{
|
||||
if(!$subscriber->email) {
|
||||
return $widgets;
|
||||
}
|
||||
|
||||
$subscriptions = fluentCrmDb()->table('fluentform_subscriptions')
|
||||
->join('fluentform_transactions', 'fluentform_subscriptions.submission_id', '=', 'fluentform_transactions.submission_id')
|
||||
->where('fluentform_transactions.payer_email', $subscriber->email)
|
||||
->select('fluentform_subscriptions.*', 'fluentform_transactions.currency')
|
||||
->orderBy('fluentform_subscriptions.created_at', 'desc')
|
||||
->get();
|
||||
|
||||
if ($subscriptions->isEmpty()) {
|
||||
return $widgets;
|
||||
}
|
||||
|
||||
$html = '<ul class="fc_full_listed fc_memberpress_subscription_lists">';
|
||||
foreach ($subscriptions as $subscription) {
|
||||
$html .= $this->generateSubscriptionHtml($subscription);
|
||||
}
|
||||
|
||||
$html .= '</ul>';
|
||||
|
||||
$widgets[] = [
|
||||
'title' => __('FluentForm Subscriptions', 'fluent-crm'),
|
||||
'content' => $html
|
||||
];
|
||||
|
||||
return $widgets;
|
||||
}
|
||||
private function generateSubscriptionHtml($subscription)
|
||||
{
|
||||
$subscription->formatted_recurring_amount = PaymentHelper::formatMoney($subscription->recurring_amount, $subscription->currency);
|
||||
|
||||
if ($subscription->status == 'active') {
|
||||
if ($subscription->bill_times) {
|
||||
/* translators: 1: total number of payments, 2: current payment count, 3: total number of payments */
|
||||
$billingText = sprintf(esc_html__('Will be cancelled after %1$d payments. (%2$d/%3$d)', 'fluent-crm'), $subscription->bill_times, $subscription->bill_count, $subscription->bill_times);
|
||||
} else {
|
||||
$billingText = __('Will be billed until cancelled', 'fluent-crm');
|
||||
}
|
||||
}
|
||||
|
||||
$formatted_date = date_i18n(get_option('date_format'), strtotime($subscription->created_at));
|
||||
$permalink = esc_url(admin_url('admin.php?page=fluent_forms&form_id='.$subscription->form_id.'&route=entries#/entries/'.$subscription->submission_id.'?sort_by=DESC¤t_page=1&pos=0&type='));
|
||||
|
||||
$html = '<li>';
|
||||
$html .= '<span class="fc_mepr_subscription_header">';
|
||||
$html .= '<span class="fc_mepr_subscription_status ' . esc_attr($subscription->status) . '">' . esc_html($subscription->status) . '</span>';
|
||||
$html .= '<span class="fc_mepr_subscription_price">' . $subscription->formatted_recurring_amount . '<small>/'.$subscription->billing_interval.'</small></span>';
|
||||
$html .= '</span>';
|
||||
$html .= '<a href="' . $permalink . '" target="_blank" class="fc_mepr_subscription_title">';
|
||||
$html .= '<b>' . esc_html($subscription->item_name) . '<span class="fc_dash_external dashicons dashicons-external"></span></b>';
|
||||
$html .= '</a>';
|
||||
$html .= '<span class="fc_date">' . __('Start Date: ', 'fluent-crm') . $formatted_date . '</span>';
|
||||
if ($subscription->status == 'active') {
|
||||
$html .= sprintf('<span class="fc_date period_date">%s%s</span>', __('Expiry Date: ', 'fluent-crm'), $billingText);
|
||||
} else {
|
||||
$html .= sprintf('<span class="fc_date period_date">%s%s</span>', __('Cancelled Date: ', 'fluent-crm'), date_i18n(get_option('date_format'), strtotime($subscription->updated_at)));
|
||||
}
|
||||
$html .= '</li>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
}
|
||||
+648
@@ -0,0 +1,648 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\MailComplaince;
|
||||
|
||||
|
||||
use FluentCrm\App\Hooks\Handlers\ExternalPages;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class Webhook
|
||||
{
|
||||
/**
|
||||
* @param $serviceName
|
||||
* @param $request \FluentCrm\Framework\Http\Request\Request
|
||||
*/
|
||||
public function handle($serviceName, $request)
|
||||
{
|
||||
$method = 'handle' . ucfirst(strtolower($serviceName));
|
||||
|
||||
if (method_exists($this, $method)) {
|
||||
return $this->{$method}($request);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $request \FluentCrm\Framework\Http\Request\Request
|
||||
*/
|
||||
private function handleMailgun($request)
|
||||
{
|
||||
$payload = $this->resolvePayload($request, []);
|
||||
|
||||
$eventData = Arr::get($payload, 'event-data', []);
|
||||
|
||||
if (!$eventData) {
|
||||
// Fallback: try reading from request params directly
|
||||
$eventData = $request->get('event-data', []);
|
||||
}
|
||||
|
||||
if (!$eventData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$event = Arr::get($eventData, 'event');
|
||||
|
||||
$catchEvents = ['failed', 'unsubscribed', 'complained'];
|
||||
|
||||
if (!in_array($event, $catchEvents)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$recipientEmail = Arr::get($eventData, 'recipient');
|
||||
if (!$recipientEmail) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$externalPages = new ExternalPages();
|
||||
|
||||
// For failed events, check severity to distinguish soft vs hard bounce
|
||||
if ($event == 'failed') {
|
||||
$severity = Arr::get($eventData, 'severity', 'permanent');
|
||||
if ($severity == 'temporary') {
|
||||
$description = Arr::get($eventData, 'delivery-status.message', '');
|
||||
if (!$description) {
|
||||
$description = Arr::get($eventData, 'delivery-status.description', '');
|
||||
}
|
||||
return $externalPages->recordSoftBounce([
|
||||
'email' => $recipientEmail,
|
||||
'reason' => __('Soft bounce was set by Mailgun webhook API.', 'fluent-crm') . ' ' . $description . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$newStatus = 'bounced';
|
||||
if ($event == 'complained') {
|
||||
$newStatus = 'complained';
|
||||
} else if ($event == 'unsubscribed') {
|
||||
$newStatus = 'unsubscribed';
|
||||
}
|
||||
|
||||
$unsubscribeData = [
|
||||
'email' => $recipientEmail,
|
||||
'reason' => $newStatus . __(' was set by Mailgun webhook API with event name: ', 'fluent-crm') . $event . __(' at ', 'fluent-crm') . current_time('mysql'),
|
||||
'status' => $newStatus
|
||||
];
|
||||
|
||||
return $externalPages->recordUnsubscribe($unsubscribeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $request \FluentCrm\Framework\Http\Request\Request
|
||||
* @return boolean
|
||||
*/
|
||||
private function handleSendgrid($request)
|
||||
{
|
||||
$events = $this->resolvePayload($request, []);
|
||||
|
||||
if (!$events || !count($events)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$externalPages = new ExternalPages();
|
||||
$processed = false;
|
||||
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
$eventName = Arr::get($event, 'event');
|
||||
$email = Arr::get($event, 'email');
|
||||
|
||||
if (!$email || !in_array($eventName, ['dropped', 'bounce', 'spamreport', 'unsubscribe'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($eventName == 'unsubscribe') {
|
||||
$externalPages->recordUnsubscribe([
|
||||
'email' => $email,
|
||||
'reason' => __('unsubscribed status was set from SendGrid Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
|
||||
'status' => 'unsubscribed'
|
||||
]);
|
||||
$processed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($eventName == 'spamreport') {
|
||||
$externalPages->recordUnsubscribe([
|
||||
'email' => $email,
|
||||
'reason' => __('complained status was set from SendGrid Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
|
||||
'status' => 'complained'
|
||||
]);
|
||||
$processed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// bounce or dropped — check type field for soft vs hard bounce
|
||||
$bounceType = Arr::get($event, 'type', 'bounce');
|
||||
$reason = Arr::get($event, 'reason', '');
|
||||
if ($bounceType == 'blocked') {
|
||||
$externalPages->recordSoftBounce([
|
||||
'email' => $email,
|
||||
'reason' => __('Soft bounce from SendGrid Webhook API. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
|
||||
]);
|
||||
} else {
|
||||
$externalPages->recordUnsubscribe([
|
||||
'email' => $email,
|
||||
'reason' => __('bounced status was set from SendGrid Webhook API. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
|
||||
'status' => 'bounced'
|
||||
]);
|
||||
}
|
||||
$processed = true;
|
||||
}
|
||||
|
||||
return $processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $request \FluentCrm\Framework\Http\Request\Request
|
||||
* @return boolean
|
||||
*/
|
||||
private function handlePepipost($request)
|
||||
{
|
||||
$events = $this->resolvePayload($request, []);
|
||||
|
||||
if (!$events || !count($events)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$externalPages = new ExternalPages();
|
||||
$processed = false;
|
||||
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
$eventName = Arr::get($event, 'EVENT');
|
||||
|
||||
if (!in_array($eventName, ['bounced', 'invalid', 'spam', 'unsubscribed'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$newStatus = 'bounced';
|
||||
if ($eventName == 'unsubscribed') {
|
||||
$newStatus = 'unsubscribed';
|
||||
} else if ($eventName == 'spam') {
|
||||
$newStatus = 'complained';
|
||||
}
|
||||
|
||||
$reason = $newStatus . __(' status was set from Pepipost Webhook API. Reason: ', 'fluent-crm') . Arr::get($event, 'BOUNCE_TYPE') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql');
|
||||
|
||||
if ($sourceResponse = Arr::get($event, 'RESPONSE')) {
|
||||
$reason = $sourceResponse;
|
||||
}
|
||||
|
||||
$email = Arr::get($event, 'EMAIL');
|
||||
if ($email) {
|
||||
$externalPages->recordUnsubscribe([
|
||||
'email' => $email,
|
||||
'reason' => $reason,
|
||||
'status' => $newStatus
|
||||
]);
|
||||
$processed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $request \FluentCrm\Framework\Http\Request\Request
|
||||
* @return boolean
|
||||
*/
|
||||
private function handleSparkpost($request)
|
||||
{
|
||||
$events = $this->resolvePayload($request, []);
|
||||
|
||||
if (!$events || !count($events)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$externalPages = new ExternalPages();
|
||||
$processed = false;
|
||||
|
||||
// SparkPost hard bounce classes: 10 (Invalid Recipient), 25 (Admin Failure),
|
||||
// 30 (Generic Bounce: No RCPT), 90 (Unsubscribe)
|
||||
$hardBounceClasses = [10, 25, 30, 90];
|
||||
|
||||
foreach ($events as $eventWrapper) {
|
||||
if (!is_array($eventWrapper)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// SparkPost wraps events in msys.message_event or msys.unsubscribe_event
|
||||
$event = Arr::get($eventWrapper, 'msys.message_event');
|
||||
if (!$event || !is_array($event)) {
|
||||
$event = Arr::get($eventWrapper, 'msys.unsubscribe_event');
|
||||
}
|
||||
if (!$event || !is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$eventName = Arr::get($event, 'type');
|
||||
if (!in_array($eventName, ['bounce', 'out_of_band', 'spam_complaint', 'link_unsubscribe', 'list_unsubscribe'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$email = Arr::get($event, 'rcpt_to');
|
||||
if (!$email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reason = Arr::get($event, 'raw_reason', '');
|
||||
if (!$reason) {
|
||||
$reason = $eventName . __(' status was set from SparkPost Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql');
|
||||
}
|
||||
|
||||
// Both bounce and out_of_band events carry bounce_class
|
||||
if (in_array($eventName, ['bounce', 'out_of_band'])) {
|
||||
$bounceClass = (int)Arr::get($event, 'bounce_class', 0);
|
||||
if (!in_array($bounceClass, $hardBounceClasses)) {
|
||||
$externalPages->recordSoftBounce([
|
||||
'email' => $email,
|
||||
'reason' => $reason
|
||||
]);
|
||||
$processed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$newStatus = 'bounced';
|
||||
if (in_array($eventName, ['link_unsubscribe', 'list_unsubscribe'])) {
|
||||
$newStatus = 'unsubscribed';
|
||||
} else if ($eventName == 'spam_complaint') {
|
||||
$newStatus = 'complained';
|
||||
}
|
||||
|
||||
$externalPages->recordUnsubscribe([
|
||||
'email' => $email,
|
||||
'reason' => $reason,
|
||||
'status' => $newStatus
|
||||
]);
|
||||
$processed = true;
|
||||
}
|
||||
|
||||
return $processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $request \FluentCrm\Framework\Http\Request\Request
|
||||
* @return boolean
|
||||
*/
|
||||
private function handlePostmark($request)
|
||||
{
|
||||
$event = $this->resolvePayload($request, []);
|
||||
|
||||
if (!$event || !is_array($event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$eventName = Arr::get($event, 'RecordType');
|
||||
if (!in_array($eventName, ['Bounce', 'SpamComplaint'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$email = Arr::get($event, 'Email');
|
||||
if (!$email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reason = Arr::get($event, 'Description', '');
|
||||
if (!$reason) {
|
||||
$reason = $eventName . __(' status was set from Postmark Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql');
|
||||
}
|
||||
|
||||
$externalPages = new ExternalPages();
|
||||
|
||||
if ($eventName == 'SpamComplaint') {
|
||||
return $externalPages->recordUnsubscribe([
|
||||
'email' => $email,
|
||||
'reason' => $reason,
|
||||
'status' => 'complained'
|
||||
]);
|
||||
}
|
||||
|
||||
// For Bounce events, check Type to distinguish soft vs hard
|
||||
$bounceType = Arr::get($event, 'Type', '');
|
||||
if ($bounceType == 'SoftBounce' || $bounceType == 'Transient') {
|
||||
return $externalPages->recordSoftBounce([
|
||||
'email' => $email,
|
||||
'reason' => $reason
|
||||
]);
|
||||
}
|
||||
|
||||
return $externalPages->recordUnsubscribe([
|
||||
'email' => $email,
|
||||
'reason' => $reason,
|
||||
'status' => 'bounced'
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleElasticemail($request)
|
||||
{
|
||||
$status = strtolower($request->get('status'));
|
||||
|
||||
$processStatuses = [
|
||||
'error',
|
||||
'abusereport',
|
||||
'unsubscribed'
|
||||
];
|
||||
|
||||
if (!in_array($status, $processStatuses)) {
|
||||
return [
|
||||
'message' => 'unknown_status'
|
||||
];
|
||||
}
|
||||
|
||||
$email = $request->get('to');
|
||||
if (!$email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$externalPages = new ExternalPages();
|
||||
|
||||
$softBounceCategories = [
|
||||
'AccountProblem',
|
||||
'Throttled',
|
||||
'SPFProblem',
|
||||
'Timeout',
|
||||
'ConnectionProblem',
|
||||
'GreyListed',
|
||||
'WhitelistingProblem',
|
||||
'CodeError'
|
||||
];
|
||||
|
||||
$category = $request->get('category', 'unknown');
|
||||
|
||||
if ($status == 'error') {
|
||||
if (in_array($category, $softBounceCategories)) {
|
||||
return $externalPages->recordSoftBounce([
|
||||
'email' => $email,
|
||||
'reason' => __('Soft bounce from ElasticEmail Webhook API. Category: ', 'fluent-crm') . $category . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
|
||||
]);
|
||||
}
|
||||
|
||||
return $externalPages->recordUnsubscribe([
|
||||
'email' => $email,
|
||||
'reason' => __('bounced status was set from ElasticEmail Webhook API. Category: ', 'fluent-crm') . $category . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
|
||||
'status' => 'bounced'
|
||||
]);
|
||||
}
|
||||
|
||||
if ($status == 'abusereport') {
|
||||
return $externalPages->recordUnsubscribe([
|
||||
'email' => $email,
|
||||
'reason' => __('complained status was set from ElasticEmail Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
|
||||
'status' => 'complained'
|
||||
]);
|
||||
}
|
||||
|
||||
// unsubscribed
|
||||
return $externalPages->recordUnsubscribe([
|
||||
'email' => $email,
|
||||
'reason' => __('unsubscribed status was set from ElasticEmail Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
|
||||
'status' => 'unsubscribed'
|
||||
]);
|
||||
}
|
||||
|
||||
private function handlePostalserver($request)
|
||||
{
|
||||
$event = strtolower($request->get('event'));
|
||||
|
||||
$processStatuses = [
|
||||
'messagebounced',
|
||||
'messagedeliveryfailed',
|
||||
'messagedelayed'
|
||||
];
|
||||
|
||||
if (!in_array($event, $processStatuses)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $request->get('payload');
|
||||
|
||||
if (!$payload || !is_array($payload)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$externalPages = new ExternalPages();
|
||||
|
||||
if ($event == 'messagedeliveryfailed') {
|
||||
$payloadStatus = Arr::get($payload, 'status');
|
||||
$toEmail = Arr::get($payload, 'message.to');
|
||||
$reason = Arr::get($payload, 'details', 'Unknown Reason');
|
||||
|
||||
if (!$toEmail || !is_email($toEmail)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// SoftFail → record as soft bounce
|
||||
if ($payloadStatus != 'HardFail') {
|
||||
return $externalPages->recordSoftBounce([
|
||||
'email' => $toEmail,
|
||||
'reason' => __('Soft bounce from PostalServer. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
|
||||
]);
|
||||
}
|
||||
|
||||
return $externalPages->recordUnsubscribe([
|
||||
'email' => $toEmail,
|
||||
'reason' => $reason,
|
||||
'status' => 'bounced'
|
||||
]);
|
||||
}
|
||||
|
||||
if ($event == 'messagedelayed') {
|
||||
$toEmail = Arr::get($payload, 'message.to');
|
||||
$reason = Arr::get($payload, 'details', 'Unknown Reason');
|
||||
|
||||
if (!$toEmail || !is_email($toEmail)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $externalPages->recordSoftBounce([
|
||||
'email' => $toEmail,
|
||||
'reason' => __('Soft bounce (delayed) from PostalServer. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
|
||||
]);
|
||||
}
|
||||
|
||||
// messagebounced — use original_message.to (not bounce.to which is the return-path)
|
||||
$toEmail = Arr::get($payload, 'original_message.to');
|
||||
if (!$toEmail || !is_email($toEmail)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reason = __('Bounce notification received from PostalServer.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql');
|
||||
|
||||
return $externalPages->recordUnsubscribe([
|
||||
'email' => $toEmail,
|
||||
'reason' => $reason,
|
||||
'status' => 'bounced'
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleSmtp2go($request)
|
||||
{
|
||||
$event = strtolower($request->get('event'));
|
||||
|
||||
$processStatuses = [
|
||||
'bounce',
|
||||
'spam',
|
||||
'unsubscribe'
|
||||
];
|
||||
|
||||
if (!in_array($event, $processStatuses)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$toEmail = $request->get('rcpt');
|
||||
if (!$toEmail || !is_email($toEmail)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reason = sanitize_textarea_field($request->get('message', 'Unknown Reason'));
|
||||
$externalPages = new ExternalPages();
|
||||
|
||||
if ($event == 'bounce') {
|
||||
$bounceType = $request->get('bounce');
|
||||
if ($bounceType == 'soft') {
|
||||
return $externalPages->recordSoftBounce([
|
||||
'email' => $toEmail,
|
||||
'reason' => __('Soft bounce from SMTP2GO Webhook API. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$newStatus = 'bounced';
|
||||
if ($event == 'unsubscribe') {
|
||||
$newStatus = 'unsubscribed';
|
||||
} else if ($event == 'spam') {
|
||||
$newStatus = 'complained';
|
||||
}
|
||||
|
||||
return $externalPages->recordUnsubscribe([
|
||||
'email' => $toEmail,
|
||||
'reason' => $reason,
|
||||
'status' => $newStatus
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $request \FluentCrm\Framework\Http\Request\Request
|
||||
* @return boolean
|
||||
*/
|
||||
private function handleBrevo($request)
|
||||
{
|
||||
$event = $this->resolvePayload($request, []);
|
||||
|
||||
if (!$event || !count($event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$eventName = Arr::get($event, 'event');
|
||||
if (!in_array($eventName, ['soft_bounce', 'hard_bounce', 'invalid', 'invalid_email', 'spam', 'error', 'blocked', 'deferred', 'unsubscribe', 'unsubscribed'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$email = Arr::get($event, 'email');
|
||||
if (!$email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$externalPages = new ExternalPages();
|
||||
$reason = Arr::get($event, 'reason', '');
|
||||
|
||||
// Soft bounces and deferred deliveries should be tracked separately
|
||||
if (in_array($eventName, ['soft_bounce', 'deferred'])) {
|
||||
$reasonPrefix = $eventName === 'deferred'
|
||||
? __('Deferred delivery from Brevo Webhook API. Reason: ', 'fluent-crm')
|
||||
: __('Soft bounce from Brevo Webhook API. Reason: ', 'fluent-crm');
|
||||
return $externalPages->recordSoftBounce([
|
||||
'email' => $email,
|
||||
'reason' => $reasonPrefix . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
|
||||
]);
|
||||
}
|
||||
|
||||
$newStatus = 'bounced';
|
||||
if (in_array($eventName, ['unsubscribe', 'unsubscribed'])) {
|
||||
$newStatus = 'unsubscribed';
|
||||
} else if ($eventName == 'spam') {
|
||||
$newStatus = 'complained';
|
||||
}
|
||||
// hard_bounce, invalid, invalid_email, error, blocked → bounced
|
||||
|
||||
return $externalPages->recordUnsubscribe([
|
||||
'email' => $email,
|
||||
'reason' => $newStatus . __(' status was set from Brevo Webhook API. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
|
||||
'status' => $newStatus
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleTosend($request)
|
||||
{
|
||||
$event = $this->resolvePayload($request, []);
|
||||
|
||||
if (!$event || !is_array($event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$eventType = Arr::get($event, 'type');
|
||||
if (!in_array($eventType, ['bounce', 'complaint'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$email = Arr::get($event, 'email');
|
||||
if (!$email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$externalPages = new ExternalPages();
|
||||
|
||||
if ($eventType == 'complaint') {
|
||||
return $externalPages->recordUnsubscribe([
|
||||
'email' => $email,
|
||||
'reason' => __('Complaint received from ToSend Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
|
||||
'status' => 'complained'
|
||||
]);
|
||||
}
|
||||
|
||||
// Bounce event — check is_hard_bounce flag
|
||||
$reason = Arr::get($event, 'reason', 'Unknown');
|
||||
$isHardBounce = Arr::get($event, 'is_hard_bounce', false);
|
||||
|
||||
// Sender-fault bounces (our content/message, not the recipient's mailbox) —
|
||||
// do NOT penalise the subscriber. Prefer the explicit `sender_fault` flag;
|
||||
// fall back to `bounce_sub_type` for older payloads.
|
||||
$senderFaultSubTypes = ['MessageTooLarge', 'ContentRejected', 'AttachmentRejected'];
|
||||
$isSenderFault = (bool) Arr::get($event, 'sender_fault', false)
|
||||
|| (!$isHardBounce && in_array(Arr::get($event, 'bounce_sub_type'), $senderFaultSubTypes, true));
|
||||
|
||||
if ($isSenderFault) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$isHardBounce) {
|
||||
return $externalPages->recordSoftBounce([
|
||||
'email' => $email,
|
||||
'reason' => __('Soft bounce from ToSend Webhook API. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
|
||||
]);
|
||||
}
|
||||
|
||||
return $externalPages->recordUnsubscribe([
|
||||
'email' => $email,
|
||||
'reason' => __('Hard bounce from ToSend Webhook API. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
|
||||
'status' => 'bounced'
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolvePayload($request, $default = [])
|
||||
{
|
||||
$contentPayload = Helper::parseArrayOrJson($request->getContent(), []);
|
||||
|
||||
if ($contentPayload) {
|
||||
return $contentPayload;
|
||||
}
|
||||
|
||||
return Helper::parseArrayOrJson($request->get(), $default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations;
|
||||
|
||||
class Maintenance
|
||||
{
|
||||
public function maybeProcessData()
|
||||
{
|
||||
if (!$this->isAllowed() || !$this->timeMatched()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$response = wp_remote_post($this->getApiUrl(), [
|
||||
'body' => [
|
||||
'payload' => $this->getData()
|
||||
],
|
||||
'sslverify' => false,
|
||||
'cookies' => []
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
update_option('_fluent_last_m_run', time());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getData()
|
||||
{
|
||||
global $wp_version;
|
||||
return [
|
||||
'plugin_version' => FLUENTCRM_PLUGIN_VERSION,
|
||||
'php_version' => (defined('PHP_VERSION')) ? PHP_VERSION : phpversion(),
|
||||
'wp_version' => $wp_version,
|
||||
'plugins' => (array)get_option('active_plugins'),
|
||||
'site_lang' => get_bloginfo('language'),
|
||||
'site_url' => site_url('/'),
|
||||
'theme' => wp_get_theme()->get('Name'),
|
||||
'admin_email' => get_bloginfo('admin_email'),
|
||||
'site_title' => get_bloginfo('name')
|
||||
];
|
||||
}
|
||||
|
||||
private function isAllowed()
|
||||
{
|
||||
/**
|
||||
* Filter to allow sharing essential data in FluentCRM.
|
||||
*
|
||||
* This filter allows you to control whether essential data sharing is enabled in FluentCRM.
|
||||
*
|
||||
* @since 2.7.0
|
||||
*
|
||||
* @param bool Whether to allow sharing essential data. Default is false.
|
||||
*/
|
||||
return apply_filters('fluent_crm/allow_share_essential', fluentcrm_get_option('_fluentcrm_share_essential', 'no') == 'yes');
|
||||
}
|
||||
|
||||
private function timeMatched()
|
||||
{
|
||||
$prevValue = get_option('_fluent_last_m_run');
|
||||
if (!$prevValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (time() - $prevValue) > 518400; // 6 days match
|
||||
}
|
||||
|
||||
private function getApiUrl()
|
||||
{
|
||||
return 'https://apiv2.wpmanageninja.com/plugin-maintenance';
|
||||
}
|
||||
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\ExternalIntegrations\Oxygen;
|
||||
|
||||
use FluentCrm\App\Models\Tag;
|
||||
|
||||
class ConditionBuilder
|
||||
{
|
||||
public function init()
|
||||
{
|
||||
$tags = Tag::get();
|
||||
$formattedTags = [];
|
||||
foreach ($tags as $tag) {
|
||||
$formattedTags[] = $tag->slug;
|
||||
}
|
||||
|
||||
oxygen_vsb_register_condition(
|
||||
'FluentCRM - Contact Tag',
|
||||
array(
|
||||
'options' => $formattedTags,
|
||||
'custom' => false
|
||||
),
|
||||
array('exist', 'not exist'),
|
||||
'fcrmOxyCheckTagCondition',
|
||||
'FluentCRM'
|
||||
);
|
||||
|
||||
oxygen_vsb_register_condition(
|
||||
'FluentCRM - Contact Status',
|
||||
array(
|
||||
'options' => ['subscribed', 'pending', 'unsubscribed'],
|
||||
'custom' => false
|
||||
),
|
||||
array('=', '!='),
|
||||
'fcrmOxyCheckStatusCondition',
|
||||
'FluentCRM'
|
||||
);
|
||||
|
||||
oxygen_vsb_register_condition(
|
||||
'FluentCRM - Contact Exist',
|
||||
array(
|
||||
'options' => ['yes', 'no'],
|
||||
'custom' => false
|
||||
),
|
||||
array('='),
|
||||
'fcrmOxyCheckContactExistCondition',
|
||||
'FluentCRM'
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
(new \FluentCrm\App\Services\ExternalIntegrations\Oxygen\ConditionBuilder())->init();
|
||||
|
||||
function fcrmOxyCheckTagCondition($value, $operator)
|
||||
{
|
||||
$contactApi = FluentCrmApi('contacts');
|
||||
$contact = $contactApi->getCurrentContact(true, true);
|
||||
|
||||
if(!$contact) {
|
||||
if($operator == 'exist') {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$contactTags = $contact->tags;
|
||||
|
||||
if($operator == 'exist') {
|
||||
foreach ($contactTags as $tag) {
|
||||
if($tag->slug == $value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($contactTags as $tag) {
|
||||
if($tag->slug == $value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function fcrmOxyCheckStatusCondition($value, $operator)
|
||||
{
|
||||
$contactApi = FluentCrmApi('contacts');
|
||||
$contact = $contactApi->getCurrentContact(true, true);
|
||||
|
||||
if(!$contact) {
|
||||
if($operator == '=') {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if($operator == '=') {
|
||||
return $contact->status == $value;
|
||||
}
|
||||
|
||||
return $contact->status != $value;
|
||||
|
||||
}
|
||||
|
||||
function fcrmOxyCheckContactExistCondition($value, $operator)
|
||||
{
|
||||
$contactApi = FluentCrmApi('contacts');
|
||||
$contact = $contactApi->getCurrentContact(true, true);
|
||||
|
||||
if($value == 'yes') {
|
||||
return !!$contact;
|
||||
}
|
||||
|
||||
return !$contact;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Actions;
|
||||
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Services\Funnel\BaseAction;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
|
||||
class ApplyCompanyAction extends BaseAction
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->actionName = 'add_contact_to_company';
|
||||
$this->priority = 20;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getBlock()
|
||||
{
|
||||
return [
|
||||
'category' => __('CRM', 'fluent-crm'),
|
||||
'title' => __('Apply Company', 'fluent-crm'),
|
||||
'description' => __('Add contact to the selected company', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-apply_list',//fluentCrmMix('images/funnel_icons/apply_list.svg'),
|
||||
'settings' => [
|
||||
'company' => null
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getBlockFields()
|
||||
{
|
||||
return [
|
||||
'title' => __('Apply Company to the contact', 'fluent-crm'),
|
||||
'sub_title' => __('Select which list will be added to the contact', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'company' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'companies',
|
||||
'label' => __('Select Company', 'fluent-crm'),
|
||||
'placeholder' => __('Select Company', 'fluent-crm')
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($subscriber, $sequence, $funnelSubscriberId, $funnelMetric)
|
||||
{
|
||||
if (empty($sequence->settings['company'])) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
$company = $sequence->settings['company'];
|
||||
|
||||
$renewedSubscriber = Subscriber::where('id', $subscriber->id)->first();
|
||||
if (!$renewedSubscriber) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
$renewedSubscriber->attachCompanies([$company]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Actions;
|
||||
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Services\Funnel\BaseAction;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
|
||||
class ApplyListAction extends BaseAction
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->actionName = 'add_contact_to_list';
|
||||
$this->priority = 20;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getBlock()
|
||||
{
|
||||
return [
|
||||
'category' => __('CRM', 'fluent-crm'),
|
||||
'title' => __('Apply List', 'fluent-crm'),
|
||||
'description' => __('Add contact to the selected lists', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-apply_list',//fluentCrmMix('images/funnel_icons/apply_list.svg'),
|
||||
'settings' => [
|
||||
'lists' => []
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getBlockFields()
|
||||
{
|
||||
return [
|
||||
'title' => __('Apply List to the contact', 'fluent-crm'),
|
||||
'sub_title' => __('Select which list will be added to the contact', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'lists' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'lists',
|
||||
'is_multiple' => true,
|
||||
'label' => __('Select Lists', 'fluent-crm'),
|
||||
'placeholder' => __('Select List', 'fluent-crm'),
|
||||
'creatable' => true
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($subscriber, $sequence, $funnelSubscriberId, $funnelMetric)
|
||||
{
|
||||
if (empty($sequence->settings['lists']) || !is_array($sequence->settings['lists'])) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
$lists = $sequence->settings['lists'];
|
||||
|
||||
$renewedSubscriber = Subscriber::where('id', $subscriber->id)->first();
|
||||
if (!$renewedSubscriber) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
$renewedSubscriber->attachLists($lists);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Actions;
|
||||
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Services\Funnel\BaseAction;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
|
||||
class ApplyTagAction extends BaseAction
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->actionName = 'add_contact_to_tag';
|
||||
$this->priority = 21;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getBlock()
|
||||
{
|
||||
return [
|
||||
'category' => __('CRM', 'fluent-crm'),
|
||||
'title' => __('Apply Tag', 'fluent-crm'),
|
||||
'description' => __('Add this contact to the selected Tags', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-apply_tag',//fluentCrmMix('images/funnel_icons/apply_tag.svg'),
|
||||
'settings' => [
|
||||
'tags' => []
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getBlockFields()
|
||||
{
|
||||
return [
|
||||
'title' => __('Apply Tag to the contact', 'fluent-crm'),
|
||||
'sub_title' => __('Select which tag will be added to the contact', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'tags' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'tags',
|
||||
'is_multiple' => true,
|
||||
'creatable' => true,
|
||||
'label' => __('Select Tags', 'fluent-crm'),
|
||||
'placeholder' => __('Select Tag', 'fluent-crm')
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($subscriber, $sequence, $funnelSubscriberId, $funnelMetric)
|
||||
{
|
||||
if (empty($sequence->settings['tags']) || !is_array($sequence->settings['tags'])) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
$tags = $sequence->settings['tags'];
|
||||
$renewedSubscriber = Subscriber::where('id', $subscriber->id)->first();
|
||||
if (!$renewedSubscriber) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
$renewedSubscriber->attachTags($tags);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Actions;
|
||||
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Services\Funnel\BaseAction;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
|
||||
class DetachCompanyAction extends BaseAction
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->actionName = 'detach_contact_from_company';
|
||||
$this->priority = 22;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getBlock()
|
||||
{
|
||||
return [
|
||||
'category' => __('CRM', 'fluent-crm'),
|
||||
'title' => __('Remove From Company', 'fluent-crm'),
|
||||
'description' => __('Remove this contact from the selected company', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-removed_list',//fluentCrmMix('images/funnel_icons/list_remove.svg'),
|
||||
'settings' => [
|
||||
'company' => null
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getBlockFields()
|
||||
{
|
||||
return [
|
||||
'title' => __('Remove Contact from the Selected Company', 'fluent-crm'),
|
||||
'sub_title' => __('Select Company that you want to remove from targeted Contact', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'company' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'companies',
|
||||
'label' => __('Select Company', 'fluent-crm'),
|
||||
'placeholder' => __('Select Company', 'fluent-crm'),
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($subscriber, $sequence, $funnelSubscriberId, $funnelMetric)
|
||||
{
|
||||
if (empty($sequence->settings['company'])) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
$company = $sequence->settings['company'];
|
||||
|
||||
$renewedSubscriber = Subscriber::where('id', $subscriber->id)->first();
|
||||
if (!$renewedSubscriber) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
$renewedSubscriber->detachCompanies([$company]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Actions;
|
||||
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Services\Funnel\BaseAction;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
|
||||
class DetachListAction extends BaseAction
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->actionName = 'detach_contact_from_list';
|
||||
$this->priority = 22;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getBlock()
|
||||
{
|
||||
return [
|
||||
'category' => __('CRM', 'fluent-crm'),
|
||||
'title' => __('Remove From List', 'fluent-crm'),
|
||||
'description' => __('Remove this contact from the selected lists', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-removed_list',//fluentCrmMix('images/funnel_icons/list_remove.svg'),
|
||||
'settings' => [
|
||||
'lists' => []
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getBlockFields()
|
||||
{
|
||||
return [
|
||||
'title' => __('Remove Contact from the Selected Lists', 'fluent-crm'),
|
||||
'sub_title' => __('Select Lists that you want to remove from targeted Contact', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'lists' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'lists',
|
||||
'is_multiple' => true,
|
||||
'label' => __('Select Lists', 'fluent-crm'),
|
||||
'placeholder' => __('Select List', 'fluent-crm'),
|
||||
'creatable' => true
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($subscriber, $sequence, $funnelSubscriberId, $funnelMetric)
|
||||
{
|
||||
if (empty($sequence->settings['lists']) || !is_array($sequence->settings['lists'])) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
$lists = $sequence->settings['lists'];
|
||||
|
||||
$renewedSubscriber = Subscriber::where('id', $subscriber->id)->first();
|
||||
if (!$renewedSubscriber) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
$renewedSubscriber->detachLists($lists);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Actions;
|
||||
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Services\Funnel\BaseAction;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
|
||||
class DetachTagAction extends BaseAction
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->actionName = 'detach_contact_from_tag';
|
||||
$this->priority = 23;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getBlock()
|
||||
{
|
||||
return [
|
||||
'category' => __('CRM', 'fluent-crm'),
|
||||
'title' => __('Remove From Tag', 'fluent-crm'),
|
||||
'description' => __('Remove this contact from the selected Tags', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-tag_removed',//fluentCrmMix('images/funnel_icons/tag_remove.svg'),
|
||||
'settings' => [
|
||||
'tags' => []
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getBlockFields()
|
||||
{
|
||||
return [
|
||||
'title' => __('Remove Contact from the Selected Tags', 'fluent-crm'),
|
||||
'sub_title' => __('Select Tags that you want to remove from targeted Contact', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'tags' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'tags',
|
||||
'is_multiple' => true,
|
||||
'label' => __('Select Tags', 'fluent-crm'),
|
||||
'placeholder' => __('Select Tag', 'fluent-crm'),
|
||||
'creatable' => true
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($subscriber, $sequence, $funnelSubscriberId, $funnelMetric)
|
||||
{
|
||||
if (empty($sequence->settings['tags']) || !is_array($sequence->settings['tags'])) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
$tags = $sequence->settings['tags'];
|
||||
|
||||
$renewedSubscriber = Subscriber::where('id', $subscriber->id)->first();
|
||||
if (!$renewedSubscriber) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
$renewedSubscriber->detachTags($tags);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Actions;
|
||||
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
use FluentCrm\App\Models\CampaignUrlMetric;
|
||||
use FluentCrm\App\Models\FunnelCampaign;
|
||||
use FluentCrm\App\Services\Funnel\BaseAction;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class SendEmailAction extends BaseAction
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->actionName = 'send_custom_email';
|
||||
$this->priority = 10;
|
||||
parent::__construct();
|
||||
add_filter('fluentcrm_funnel_sequence_saving_' . $this->actionName, array($this, 'savingAction'), 10, 2);
|
||||
add_filter('fluentcrm_funnel_sequence_deleting_' . $this->actionName, array($this, 'deleteAction'), 10, 2);
|
||||
add_filter('fluentcrm_funnel_sequence_filtered_' . $this->actionName, array($this, 'gettingAction'), 10, 2);
|
||||
}
|
||||
|
||||
public function getBlock()
|
||||
{
|
||||
return [
|
||||
'category' => __('Email', 'fluent-crm'),
|
||||
'title' => __('Send Custom Email', 'fluent-crm'),
|
||||
'description' => __('Send a custom Email to your subscriber or custom email address', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-send_campaign',//fluentCrmMix('images/funnel_icons/custom_email.svg'),
|
||||
'settings' => [
|
||||
'reference_campaign' => '',
|
||||
'send_email_to_type' => 'contact',
|
||||
'send_email_custom' => '',
|
||||
'campaign' => FunnelCampaign::getMock(),
|
||||
'is_scheduled' => 'no',
|
||||
'scheduled_at' => '',
|
||||
'skip_if_overdue' => 'no',
|
||||
'mailer_settings' => [
|
||||
'from_name' => '',
|
||||
'from_email' => '',
|
||||
'reply_to_name' => '',
|
||||
'reply_to_email' => '',
|
||||
'is_custom' => 'no'
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getBlockFields()
|
||||
{
|
||||
return [
|
||||
'title' => __('Send Custom Email', 'fluent-crm'),
|
||||
'sub_title' => __('Please provide email details that you want to send', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'send_email_to_type' => [
|
||||
'type' => 'radio',
|
||||
'wrapper_class' => 'fc_half_field',
|
||||
'label' => __('Send Email to', 'fluent-crm'),
|
||||
'options' => [
|
||||
[
|
||||
'id' => 'contact',
|
||||
'title' => __('Send To the contact', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'custom',
|
||||
'title' => __('Send to Custom Email Address', 'fluent-crm')
|
||||
]
|
||||
]
|
||||
],
|
||||
'send_email_custom' => [
|
||||
'wrapper_class' => 'fcrm_send_custom_email_form_wrap',
|
||||
'type' => 'input-text',
|
||||
'label' => __('Send To Email Addresses (If Custom)', 'fluent-crm'),
|
||||
'placeholder' => __('Custom Email Addresses', 'fluent-crm'),
|
||||
'inline_help' => __('Use comma separated values for multiple', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'send_email_to_type',
|
||||
'operator' => '=',
|
||||
'value' => 'custom'
|
||||
]
|
||||
],
|
||||
'campaign' => [
|
||||
'label' => '',
|
||||
'wrapper_class' => 'fc_email_writer',
|
||||
'type' => 'email_campaign_composer'
|
||||
],
|
||||
'is_scheduled' => [
|
||||
'type' => 'yes_no_check',
|
||||
'check_label' => __('Schedule this email to a specific date', 'fluent-crm'),
|
||||
'wrapper_class' => 'fc_no_pad_l',
|
||||
],
|
||||
'skip_if_overdue' => [
|
||||
'type' => 'yes_no_check',
|
||||
'wrapper_class' => 'fcrm_child_field',
|
||||
'check_label' => __('Skip sending email if date is overdued', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'is_scheduled',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'scheduled_at' => [
|
||||
'label' => __('Schedule Date and Time', 'fluent-crm'),
|
||||
'type' => 'date_time',
|
||||
'wrapper_class' => 'fcrm_child_field fcrm_schedule_date_time fc_no_marg_b',
|
||||
'placeholder' => __('Select Date and Time', 'fluent-crm'),
|
||||
'inline_help' => __('If schedule date is past in the runtime then email will be sent immediately', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'is_scheduled',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
],
|
||||
'mailer_settings' => [
|
||||
'type' => 'custom_sender_config',
|
||||
'check_label' => __('Set Custom From Name and Email', 'fluent-crm')
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function savingAction($sequence, $funnel)
|
||||
{
|
||||
$funnelCampaign = Arr::get($sequence, 'settings.campaign', []);
|
||||
$funnelCampaignId = Arr::get($funnelCampaign, 'id');
|
||||
|
||||
$isStripped = Arr::get($sequence, 'is_stripped');
|
||||
if ($isStripped && $funnelCampaignId) {
|
||||
$refCampaign = FunnelCampaign::find($funnelCampaignId);
|
||||
if (!$refCampaign) {
|
||||
return $sequence;
|
||||
}
|
||||
$data = Arr::only($refCampaign->toArray(), array_keys(FunnelCampaign::getMock()));
|
||||
} else {
|
||||
$data = Arr::only($funnelCampaign, array_keys(FunnelCampaign::getMock()));
|
||||
}
|
||||
|
||||
$sequenceId = Arr::get($sequence, 'id');
|
||||
$data['settings']['mailer_settings'] = Arr::get($sequence, 'settings.mailer_settings', []);
|
||||
|
||||
if ($funnelCampaignId && $funnel->id == Arr::get($data, 'parent_id')) {
|
||||
// We have this campaign
|
||||
$data['settings'] = \maybe_serialize($data['settings']);
|
||||
$data['type'] = 'funnel_email_campaign';
|
||||
$data['title'] = $funnel->title . ' (' . $funnel->id . ' - ' . $sequenceId . ')';
|
||||
FunnelCampaign::where('id', $funnelCampaignId)->update($data);
|
||||
} else {
|
||||
$data['parent_id'] = $funnel->id;
|
||||
$data['type'] = 'funnel_email_campaign';
|
||||
$data['title'] = $funnel->title . ' (' . $funnel->id . ' - ' . $sequenceId . ')';
|
||||
$campaign = FunnelCampaign::create($data);
|
||||
$funnelCampaignId = $campaign->id;
|
||||
|
||||
if ($data['design_template'] == 'visual_builder' && !empty($funnelCampaign['_visual_builder_design'])) {
|
||||
fluentcrm_update_campaign_meta($funnelCampaignId, '_visual_builder_design', $funnelCampaign['_visual_builder_design']);
|
||||
}
|
||||
}
|
||||
|
||||
$sequence['settings']['reference_campaign'] = $funnelCampaignId;
|
||||
$sequence['settings']['campaign'] = [];
|
||||
return $sequence;
|
||||
}
|
||||
|
||||
public function gettingAction($sequence, $funnel)
|
||||
{
|
||||
$refCampaignData = FunnelCampaign::getMock();
|
||||
if ($refCampaignId = Arr::get($sequence, 'settings.reference_campaign')) {
|
||||
$refCampaign = FunnelCampaign::find($refCampaignId);
|
||||
if ($refCampaign) {
|
||||
$refCampaignData = Arr::only($refCampaign->toArray(), array_keys(FunnelCampaign::getMock()));
|
||||
|
||||
if ($refCampaignData['design_template'] == 'visual_builder') {
|
||||
$refCampaignData['_visual_builder_design'] = fluentcrm_get_campaign_meta($refCampaignData['id'], '_visual_builder_design', true);
|
||||
} else {
|
||||
$refCampaignData['__fcrm_block_type'] = 'email_body_in_funnel';
|
||||
}
|
||||
$sequence['settings']['campaign'] = $refCampaignData;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($sequence['settings']['mailer_settings'])) {
|
||||
$sequence['settings']['mailer_settings'] = [
|
||||
'from_name' => '',
|
||||
'from_email' => '',
|
||||
'reply_to_name' => '',
|
||||
'reply_to_email' => '',
|
||||
'is_custom' => 'no'
|
||||
];
|
||||
}
|
||||
|
||||
return $sequence;
|
||||
}
|
||||
|
||||
public function deleteAction($sequence, $funnel)
|
||||
{
|
||||
$refCampaign = Arr::get($sequence->settings, 'reference_campaign');
|
||||
if ($refCampaign) {
|
||||
FunnelCampaign::where('id', $refCampaign)->delete();
|
||||
CampaignEmail::where('campaign_id', $refCampaign)->delete();
|
||||
CampaignUrlMetric::where('campaign_id', $refCampaign)->delete();
|
||||
fluentcrm_delete_campaign_meta($refCampaign, '');
|
||||
}
|
||||
}
|
||||
|
||||
public function handle($subscriber, $sequence, $funnelSubscriberId, $funnelMetric)
|
||||
{
|
||||
$settings = $sequence->settings;
|
||||
$refCampaign = Arr::get($settings, 'reference_campaign');
|
||||
|
||||
if (!$refCampaign) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($funnelMetric) {
|
||||
// We are making sure, this action will run only once per funnel subscriber
|
||||
if (did_action('fluent_crm/did_run_funnel_email_action_' . $funnelMetric->id)) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
do_action('fluent_crm/did_run_funnel_email_action_' . $funnelMetric->id);
|
||||
}
|
||||
|
||||
$campaign = FunnelCampaign::find($refCampaign);
|
||||
if (!$campaign) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
$scheduledAt = current_time('mysql');
|
||||
if (Arr::get($settings, 'is_scheduled') == 'yes') {
|
||||
$providedDate = Arr::get($settings, 'scheduled_at');
|
||||
|
||||
if ($providedDate && Arr::get($settings, 'skip_if_overdue') == 'yes') {
|
||||
if (strtotime($scheduledAt) > strtotime($providedDate)) {
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($providedDate && strtotime($providedDate) > strtotime($scheduledAt)) {
|
||||
$scheduledAt = $providedDate;
|
||||
}
|
||||
}
|
||||
|
||||
$args = [
|
||||
'status' => 'scheduled',
|
||||
'scheduled_at' => $scheduledAt,
|
||||
'email_type' => 'funnel_email_campaign',
|
||||
'note' => __('Email Sent From Funnel: ', 'fluent-crm') . $campaign->title
|
||||
];
|
||||
|
||||
if (Arr::get($settings, 'send_email_to_type') == 'contact') {
|
||||
$campaign->processAndSubscribe($subscriber, [
|
||||
'funnel_subscriber_id' => $funnelSubscriberId
|
||||
], $args);
|
||||
|
||||
do_action('fluentcrm_process_contact_jobs', $subscriber);
|
||||
} else if ($customAddresses = Arr::get($settings, 'send_email_custom')) {
|
||||
/**
|
||||
* Filters and parses custom email addresses for a campaign email text.
|
||||
*
|
||||
* This code applies the 'fluent_crm/parse_campaign_email_text' filter to process
|
||||
* the custom email addresses for a given subscriber.
|
||||
*
|
||||
* @param string $customAddresses The custom email addresses string to be parsed.
|
||||
* @param object $subscriber The subscriber object containing subscriber details.
|
||||
*
|
||||
* @return string The filtered and parsed custom email addresses string.
|
||||
*/
|
||||
|
||||
$customAddresses = apply_filters('fluent_crm/parse_campaign_email_text', $customAddresses, $subscriber);
|
||||
$customAddresses = array_filter(array_map('sanitize_email', explode(',', $customAddresses)));
|
||||
$campaign->sendToCustomAddresses($customAddresses, $args, $subscriber);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Actions;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseAction;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class WaitTimeAction extends BaseAction
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->actionName = 'fluentcrm_wait_times';
|
||||
$this->priority = 10;
|
||||
parent::__construct();
|
||||
|
||||
add_filter('fluentcrm_funnel_sequence_filtered_' . $this->actionName, array($this, 'gettingAction'), 10, 2);
|
||||
}
|
||||
|
||||
public function getBlock()
|
||||
{
|
||||
return [
|
||||
'category' => __('CRM', 'fluent-crm'),
|
||||
'title' => __('Wait X Days/Hours', 'fluent-crm'),
|
||||
'description' => __('Wait defined timespan before execute the next action', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-wait_time',
|
||||
'settings' => [
|
||||
'wait_type' => 'unit_wait',
|
||||
'wait_time_amount' => '',
|
||||
'wait_time_unit' => 'days',
|
||||
'is_timestamp_wait' => '',
|
||||
'wait_date_time' => '',
|
||||
'to_day' => [],
|
||||
'to_day_time' => ''
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function gettingAction($sequence, $funnel)
|
||||
{
|
||||
if (empty($sequence['settings']['wait_type'])) {
|
||||
if (Arr::get($sequence, 'settings.is_timestamp_wait') == 'yes') {
|
||||
$sequence['settings']['wait_type'] = 'timestamp_wait';
|
||||
} else {
|
||||
$sequence['settings']['wait_type'] = 'unit_wait';
|
||||
}
|
||||
|
||||
$sequence['settings']['to_day'] = [];
|
||||
$sequence['settings']['to_day_time'] = '';
|
||||
}
|
||||
|
||||
if (!empty($sequence['settings']['to_day'])) {
|
||||
$sequence['settings']['to_day'] = array_map(function ($day) {
|
||||
return substr($day, 0, 3);
|
||||
}, $sequence['settings']['to_day']);
|
||||
}
|
||||
|
||||
return $sequence;
|
||||
}
|
||||
|
||||
public function getBlockFields()
|
||||
{
|
||||
|
||||
$customFields = FluentCrmApi('contacts')->getCustomFields(['date_time', 'date'], true);
|
||||
|
||||
// add date of birth field at the beginning
|
||||
array_unshift($customFields, [
|
||||
'id' => '__date_of_birth__',
|
||||
'title' => __('Contact\'s Next Date of Birth', 'fluent-crm')
|
||||
]);
|
||||
|
||||
return [
|
||||
'title' => __('Wait X Days/Hours', 'fluent-crm'),
|
||||
'sub_title' => __('Wait defined timespan before execute the next action', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'wait_type' => [
|
||||
'type' => 'radio_buttons',
|
||||
'label' => __('Waiting Type', 'fluent-crm'),
|
||||
'options' => [
|
||||
[
|
||||
'id' => 'unit_wait',
|
||||
'title' => __('Wait by period', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'timestamp_wait',
|
||||
'title' => __('Wait Until Date', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'to_day',
|
||||
'title' => __('Wait by Weekday', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'by_custom_field',
|
||||
'title' => __('Wait by Custom Field', 'fluent-crm')
|
||||
]
|
||||
]
|
||||
],
|
||||
'wait_time_amount' => [
|
||||
'label' => __('Wait Time', 'fluent-crm'),
|
||||
'type' => 'input-number',
|
||||
'wrapper_class' => 'fc_2col_inline pad-r-20',
|
||||
'dependency' => [
|
||||
'depends_on' => 'wait_type',
|
||||
'value' => 'unit_wait',
|
||||
'operator' => '=',
|
||||
],
|
||||
],
|
||||
'wait_time_unit' => [
|
||||
'label' => __('Wait Time Unit', 'fluent-crm'),
|
||||
'type' => 'select',
|
||||
'wrapper_class' => 'fc_2col_inline',
|
||||
'options' => [
|
||||
[
|
||||
'id' => 'months',
|
||||
'title' => __('Months', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'days',
|
||||
'title' => __('Days', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'hours',
|
||||
'title' => __('Hours', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'minutes',
|
||||
'title' => __('Minutes', 'fluent-crm')
|
||||
]
|
||||
],
|
||||
'dependency' => [
|
||||
'depends_on' => 'wait_type',
|
||||
'value' => 'unit_wait',
|
||||
'operator' => '=',
|
||||
],
|
||||
],
|
||||
'wait_date_time' => [
|
||||
'label' => __('Specify Date and Time', 'fluent-crm'),
|
||||
'type' => 'date_time',
|
||||
'placeholder' => __('Select Date & Time', 'fluent-crm'),
|
||||
'inline_help' => __('Please input date and time and this step will be executed after that time (TimeZone will be as per your WordPress Date Time Zone)', 'fluent-crm'),
|
||||
'dependency' => [
|
||||
'depends_on' => 'wait_type',
|
||||
'value' => 'timestamp_wait',
|
||||
'operator' => '=',
|
||||
]
|
||||
],
|
||||
'to_day' => [
|
||||
'type' => 'checkboxes',
|
||||
'label' => __('Wait until next day(s) of the week', 'fluent-crm'),
|
||||
'wrapper_class' => 'fc_2col_inline pad-r-20',
|
||||
'options' => [
|
||||
[
|
||||
'id' => 'Mon',
|
||||
'title' => __('Mon', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'Tue',
|
||||
'title' => __('Tue', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'Wed',
|
||||
'title' => __('Wed', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'Thu',
|
||||
'title' => __('Thu', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'Fri',
|
||||
'title' => __('Fri', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'Sat',
|
||||
'title' => __('Sat', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'Sun',
|
||||
'title' => __('Sun', 'fluent-crm')
|
||||
]
|
||||
],
|
||||
'dependency' => [
|
||||
'depends_on' => 'wait_type',
|
||||
'value' => 'to_day',
|
||||
'operator' => '=',
|
||||
]
|
||||
],
|
||||
'to_day_time' => [
|
||||
'label' => __('Time of the day', 'fluent-crm'),
|
||||
'type' => 'time_selector',
|
||||
'placeholder' => __('Select Time', 'fluent-crm'),
|
||||
'wrapper_class' => 'fc_2col_inline',
|
||||
'picker_options' => [
|
||||
'start' => '00:00',
|
||||
'step' => '00:10',
|
||||
'end' => '23:59'
|
||||
],
|
||||
'dependency' => [
|
||||
'depends_on' => 'wait_type',
|
||||
'value' => 'to_day',
|
||||
'operator' => '=',
|
||||
]
|
||||
],
|
||||
'by_custom_field' => [
|
||||
'label' => __('Select Contact\'s Custom Field', 'fluent-crm'),
|
||||
'type' => 'select',
|
||||
'inline_help' => __('If no value is found in the contact\'s custom field or past date then it will wait only 1 minute by default', 'fluent-crm'),
|
||||
'options' => $customFields,
|
||||
'dependency' => [
|
||||
'depends_on' => 'wait_type',
|
||||
'value' => 'by_custom_field',
|
||||
'operator' => '=',
|
||||
]
|
||||
],
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($subscriber, $sequence, $funnelSubscriberId, $funnelMetric)
|
||||
{
|
||||
// No-op: delay is handled by the processor via sequence delay settings
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel;
|
||||
|
||||
abstract class BaseAction
|
||||
{
|
||||
protected $actionName;
|
||||
|
||||
protected $funnel;
|
||||
protected $priority = 10;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->register();
|
||||
}
|
||||
|
||||
public function register()
|
||||
{
|
||||
add_filter('fluentcrm_funnel_blocks', array($this, 'pushBlock'), $this->priority, 2);
|
||||
add_filter('fluentcrm_funnel_block_fields', array($this, 'pushBlockFields'), $this->priority, 2);
|
||||
add_action('fluentcrm_funnel_sequence_handle_' . $this->actionName, array($this, 'handle'), 10, 4);
|
||||
}
|
||||
|
||||
public function pushBlock($blocks, $funnel)
|
||||
{
|
||||
$this->funnel = $funnel;
|
||||
|
||||
$block = $this->getBlock();
|
||||
if($block) {
|
||||
$block['type'] = 'action';
|
||||
$blocks[$this->actionName] = $block;
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
public function pushBlockFields($fields, $funnel)
|
||||
{
|
||||
$this->funnel = $funnel;
|
||||
|
||||
$fields[$this->actionName] = $this->getBlockFields();
|
||||
return $fields;
|
||||
}
|
||||
|
||||
abstract public function getBlock();
|
||||
|
||||
abstract public function getBlockFields();
|
||||
|
||||
abstract public function handle($subscriber, $sequence, $funnelSubscriberId, $funnelMetric);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel;
|
||||
|
||||
abstract class BaseBenchMark
|
||||
{
|
||||
protected $triggerName;
|
||||
|
||||
protected $actionArgNum = 1;
|
||||
|
||||
protected $priority = 10;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->register();
|
||||
}
|
||||
|
||||
public function register()
|
||||
{
|
||||
add_filter('fluentcrm_funnel_blocks', array($this, 'addBenchmark'), $this->priority, 1);
|
||||
|
||||
add_filter('fluentcrm_funnel_block_fields', array($this, 'pushBlockFields'), $this->priority, 2);
|
||||
|
||||
add_action('fluentcrm_funnel_benchmark_start_' . $this->triggerName, array($this, 'handle'), $this->priority, 2);
|
||||
|
||||
|
||||
add_filter('fluent_crm/benchmark_already_asserted_'.$this->triggerName, [$this, 'assertCurrentGoalState'], 10, 3);
|
||||
|
||||
add_filter('fluentcrm_funnel_arg_num_' . $this->triggerName, function ($num) {
|
||||
if ($num >= $this->actionArgNum) {
|
||||
return $num;
|
||||
}
|
||||
return $this->actionArgNum;
|
||||
});
|
||||
|
||||
/**
|
||||
* Filter the funnel sequence before saving.
|
||||
*
|
||||
* The dynamic portion of the hook name, `$this->triggerName`, refers to the name of the trigger.
|
||||
*
|
||||
* @since 2.6.0
|
||||
*
|
||||
* @param array $sequence The sequence data to be filtered.
|
||||
*
|
||||
* @return array The filtered sequence data.
|
||||
*/
|
||||
add_filter('fluentcrm_funnel_sequence_saving_' . $this->triggerName, function ($sequence) {
|
||||
$sequence['type'] = 'benchmark';
|
||||
return $sequence;
|
||||
});
|
||||
}
|
||||
|
||||
public function addBenchmark($benchMarks)
|
||||
{
|
||||
$benchMark = $this->getBlock();
|
||||
if ($benchMark) {
|
||||
$benchMark['type'] = 'benchmark';
|
||||
$benchMarks[$this->triggerName] = $benchMark;
|
||||
}
|
||||
|
||||
return $benchMarks;
|
||||
}
|
||||
|
||||
public function pushBlockFields($fields, $funnel)
|
||||
{
|
||||
$fields[$this->triggerName] = $this->getBlockFields($funnel);
|
||||
return $fields;
|
||||
}
|
||||
|
||||
public function getConditionDefaults($benchMark)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getConditionFields($benchMark)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function benchmarkTypeField()
|
||||
{
|
||||
return [
|
||||
'label' => __('Benchmark type', 'fluent-crm'),
|
||||
'type' => 'radio',
|
||||
'options' => [
|
||||
[
|
||||
'id' => 'optional',
|
||||
'title' => __('[Optional Point] This is an optional trigger point', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'required',
|
||||
'title' => __('[Essential Point] Select IF this step is required for processing further actions', 'fluent-crm')
|
||||
]
|
||||
],
|
||||
'inline_help' => __('If you select [Optional Point] it will work as an Optional Trigger otherwise, it will wait for full-fill this action', 'fluent-crm')
|
||||
];
|
||||
}
|
||||
|
||||
public function canEnterField()
|
||||
{
|
||||
return [
|
||||
'type' => 'yes_no_check',
|
||||
'check_label' => __('Contacts can enter directly to this sequence point. If you enable this then any contact meet with goal will enter in this goal point.', 'fluent-crm')
|
||||
];
|
||||
}
|
||||
|
||||
public function assertCurrentGoalState($asserted, $benchmark, $funnelSubscriber)
|
||||
{
|
||||
return $asserted;
|
||||
}
|
||||
|
||||
abstract public function getBlock();
|
||||
|
||||
abstract public function getBlockFields($funnel);
|
||||
|
||||
abstract public function handle($benchMark, $originalArgs);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel;
|
||||
|
||||
abstract class BaseTrigger
|
||||
{
|
||||
|
||||
protected $triggerName;
|
||||
|
||||
protected $actionArgNum = 1;
|
||||
|
||||
protected $priority = 10;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->register();
|
||||
}
|
||||
|
||||
public function register()
|
||||
{
|
||||
add_filter('fluentcrm_funnel_triggers', array($this, 'addTrigger'), $this->priority, 1);
|
||||
|
||||
add_filter('fluentcrm_funnel_editor_details_' . $this->triggerName, array($this, 'prepareEditorDetails'), 10, 1);
|
||||
|
||||
add_action('fluentcrm_funnel_start_' . $this->triggerName, array($this, 'handle'), 10, 2);
|
||||
|
||||
add_filter('fluentcrm_funnel_arg_num_' . $this->triggerName, function ($num) {
|
||||
if ($num >= $this->actionArgNum) {
|
||||
return $num;
|
||||
}
|
||||
return $this->actionArgNum;
|
||||
});
|
||||
}
|
||||
|
||||
public function addTrigger($triggers)
|
||||
{
|
||||
$trigger = $this->getTrigger();
|
||||
|
||||
if (!$trigger) {
|
||||
return $triggers;
|
||||
}
|
||||
|
||||
$triggers[$this->triggerName] = $trigger;
|
||||
return $triggers;
|
||||
}
|
||||
|
||||
public function prepareEditorDetails($funnel)
|
||||
{
|
||||
$funnel->settings = wp_parse_args($funnel->settings, $this->getFunnelSettingsDefaults());
|
||||
|
||||
$settingsFields = $this->getSettingsFields($funnel);
|
||||
|
||||
$settingsFields['fields']['__force_run_actions'] = [
|
||||
'label' => '',
|
||||
'type' => 'yes_no_check',
|
||||
'check_label' => __('Run the automation actions even if the contact status is not subscribed', 'fluent-crm'),
|
||||
'true_label' => 'yes',
|
||||
'false_label' => 'no'
|
||||
];
|
||||
|
||||
$settingsFields['fields']['__force_run_actions_info'] = [
|
||||
'type' => 'html',
|
||||
'info' => '<em>' . __('The actions will run even the contact\'s status is not in subscribed status.', 'fluent-crm') . '</em>',
|
||||
'dependency' => [
|
||||
'depends_on' => '__force_run_actions',
|
||||
'operator' => '=',
|
||||
'value' => 'yes'
|
||||
]
|
||||
];
|
||||
|
||||
$funnel->settingsFields = $settingsFields;
|
||||
|
||||
$funnel->conditions = wp_parse_args($funnel->conditions, $this->getFunnelConditionDefaults($funnel));
|
||||
|
||||
$conditionFields = $this->getConditionFields($funnel);
|
||||
|
||||
$funnel->conditionFields = $conditionFields;
|
||||
return $funnel;
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
abstract public function getTrigger();
|
||||
|
||||
abstract public function getFunnelSettingsDefaults();
|
||||
|
||||
abstract public function getSettingsFields($funnel);
|
||||
|
||||
abstract public function handle($funnel, $originalArgs);
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Benchmarks;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseBenchMark;
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class ListAppliedBenchmark extends BaseBenchMark
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluentcrm_contact_added_to_lists';
|
||||
$this->actionArgNum = 2;
|
||||
$this->priority = 20;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getBlock()
|
||||
{
|
||||
return [
|
||||
'title' => __('List Applied', 'fluent-crm'),
|
||||
'description' => __('This will run when selected lists have been applied to a contact', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-list_applied_2',//fluentCrmMix('images/funnel_icons/list_applied.svg'),
|
||||
'settings' => [
|
||||
'lists' => [],
|
||||
'select_type' => 'any',
|
||||
'type' => 'optional',
|
||||
'can_enter' => 'yes'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getDefaultSettings()
|
||||
{
|
||||
return [
|
||||
'lists' => [],
|
||||
'select_type' => 'any',
|
||||
'type' => 'optional',
|
||||
'can_enter' => 'yes'
|
||||
];
|
||||
}
|
||||
|
||||
public function getBlockFields($funnel)
|
||||
{
|
||||
return [
|
||||
'title' => __('List Applied', 'fluent-crm'),
|
||||
'sub_title' => __('This will run when selected lists have been applied to a contact', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'lists' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'lists',
|
||||
'is_multiple' => true,
|
||||
'label' => __('Select Lists', 'fluent-crm'),
|
||||
'placeholder' => __('Select List', 'fluent-crm'),
|
||||
'creatable' => true
|
||||
],
|
||||
'select_type' => [
|
||||
'label' => __('Run When', 'fluent-crm'),
|
||||
'type' => 'radio',
|
||||
'options' => [
|
||||
[
|
||||
'id' => 'any',
|
||||
'title' => __('contact added in any of the selected Lists', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'all',
|
||||
'title' => __('contact added in all of the selected lists', 'fluent-crm')
|
||||
]
|
||||
],
|
||||
'dependency' => [
|
||||
'depends_on' => 'lists',
|
||||
'operator' => '!=',
|
||||
'value' => []
|
||||
]
|
||||
],
|
||||
'type' => $this->benchmarkTypeField(),
|
||||
'can_enter' => $this->canEnterField()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($benchMark, $originalArgs)
|
||||
{
|
||||
$listIds = $originalArgs[0];
|
||||
$subscriber = $originalArgs[1];
|
||||
$settings = $benchMark->settings;
|
||||
|
||||
$benchmarkLists = Arr::get($settings, 'lists', []);
|
||||
|
||||
// Gate on the lists actually applied in this event. Without this a contact
|
||||
// who already belongs to the benchmark list would advance past the benchmark
|
||||
// on any unrelated list-add event.
|
||||
if (!$benchmarkLists || !array_intersect((array) $listIds, $benchmarkLists)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberLists = $subscriber->lists->pluck('id')->toArray();
|
||||
|
||||
if (!$this->isListMatched($subscriberLists, $benchmarkLists, $settings)) {
|
||||
return; // not matched based on condition
|
||||
}
|
||||
|
||||
$funnelProcessor = new FunnelProcessor();
|
||||
$funnelProcessor->startFunnelFromSequencePoint($benchMark, $subscriber);
|
||||
}
|
||||
|
||||
private function isListMatched($subscriberLists, $benchmarkLists, $settings)
|
||||
{
|
||||
$matchType = Arr::get($settings, 'select_type');
|
||||
|
||||
if (empty($benchmarkLists)) {
|
||||
return false;
|
||||
}
|
||||
// find common elements between the two arrays
|
||||
$intersection = array_intersect($benchmarkLists, $subscriberLists);
|
||||
|
||||
if ($matchType === 'any') {
|
||||
// At least one funnel list id is available.
|
||||
$isMatched = !empty($intersection);
|
||||
} else {
|
||||
// All of the funnel list ids are present.
|
||||
$isMatched = count($intersection) === count($settings['lists']);
|
||||
}
|
||||
|
||||
return $isMatched;
|
||||
}
|
||||
|
||||
public function assertCurrentGoalState($asserted, $benchmark, $funnelSubscriber)
|
||||
{
|
||||
if (!$funnelSubscriber || !$funnelSubscriber->subscriber) {
|
||||
return $asserted;
|
||||
}
|
||||
|
||||
$subscriberLists = $funnelSubscriber->subscriber->lists->pluck('id')->toArray();
|
||||
$benchamrkLists = Arr::get($benchmark->settings, 'lists', []);
|
||||
|
||||
|
||||
$benchmarkSettings= $benchmark->settings;
|
||||
|
||||
return $this->isListMatched($subscriberLists, $benchamrkLists, $benchmarkSettings);
|
||||
}
|
||||
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Benchmarks;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseBenchMark;
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class RemoveFromListBenchmark extends BaseBenchMark
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluentcrm_contact_removed_from_lists';
|
||||
$this->actionArgNum = 2;
|
||||
$this->priority = 40;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getBlock()
|
||||
{
|
||||
return [
|
||||
'title' => __('List Removed', 'fluent-crm'),
|
||||
'description' => __('This will run when selected lists have been removed from a contact', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-list_removed',//fluentCrmMix('images/funnel_icons/list_removed.svg'),
|
||||
'settings' => [
|
||||
'lists' => [],
|
||||
'select_type' => 'any',
|
||||
'type' => 'optional',
|
||||
'can_enter' => 'yes'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getDefaultSettings()
|
||||
{
|
||||
return [
|
||||
'lists' => [],
|
||||
'select_type' => 'any',
|
||||
'type' => 'optional',
|
||||
'can_enter' => 'yes'
|
||||
];
|
||||
}
|
||||
|
||||
public function getBlockFields($funnel)
|
||||
{
|
||||
return [
|
||||
'title' => __('List Removed From Contact', 'fluent-crm'),
|
||||
'sub_title' => __('This will run when selected lists have been removed from a contact', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'lists' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'lists',
|
||||
'is_multiple' => true,
|
||||
'label' => __('Select Lists', 'fluent-crm'),
|
||||
'placeholder' => __('Select List', 'fluent-crm'),
|
||||
'creatable' => true
|
||||
],
|
||||
'select_type' => [
|
||||
'label' => __('Run When', 'fluent-crm'),
|
||||
'type' => 'radio',
|
||||
'options' => [
|
||||
[
|
||||
'id' => 'any',
|
||||
'title' => __('contact removed from any of the selected Lists', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'all',
|
||||
'title' => __('contact removed from all of the selected lists', 'fluent-crm')
|
||||
]
|
||||
],
|
||||
'dependency' => [
|
||||
'depends_on' => 'lists',
|
||||
'operator' => '!=',
|
||||
'value' => []
|
||||
]
|
||||
],
|
||||
'type' => $this->benchmarkTypeField(),
|
||||
'can_enter' => $this->canEnterField()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($benchMark, $originalArgs)
|
||||
{
|
||||
$listIds = $originalArgs[0];
|
||||
$subscriber = $originalArgs[1];
|
||||
$settings = $benchMark->settings;
|
||||
|
||||
if (!$this->isListMatched($listIds, $subscriber, $settings)) {
|
||||
return; // not matched based on condition
|
||||
}
|
||||
|
||||
$funnelProcessor = new FunnelProcessor();
|
||||
$funnelProcessor->startFunnelFromSequencePoint($benchMark, $subscriber);
|
||||
}
|
||||
|
||||
private function isListMatched($listIds, $subscriber, $settings)
|
||||
{
|
||||
$benchmarkLists = Arr::get($settings, 'lists', []);
|
||||
if (empty($benchmarkLists)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$isMatched = array_intersect($benchmarkLists, $listIds);
|
||||
if (!$isMatched) {
|
||||
return false; // not in our scope
|
||||
}
|
||||
|
||||
$matchType = Arr::get($settings, 'select_type');
|
||||
|
||||
if ($matchType == 'all') {
|
||||
// Check that ALL benchmark lists have been removed (none remain on subscriber)
|
||||
$attachedListIds = $subscriber->lists->pluck('id')->toArray();
|
||||
return empty(array_intersect($benchmarkLists, $attachedListIds));
|
||||
}
|
||||
|
||||
return $isMatched;
|
||||
}
|
||||
|
||||
public function assertCurrentGoalState($asserted, $benchmark, $funnelSubscriber)
|
||||
{
|
||||
if (!$funnelSubscriber || !$funnelSubscriber->subscriber) {
|
||||
return $asserted;
|
||||
}
|
||||
|
||||
$subscriber = $funnelSubscriber->subscriber;
|
||||
$settings = $benchmark->settings;
|
||||
$benchmarkLists = Arr::get($settings, 'lists', []);
|
||||
|
||||
if (empty($benchmarkLists)) {
|
||||
return $asserted;
|
||||
}
|
||||
|
||||
$attachedListIds = $subscriber->lists->pluck('id')->toArray();
|
||||
$matchType = Arr::get($settings, 'select_type');
|
||||
|
||||
if ($matchType == 'all') {
|
||||
return empty(array_intersect($benchmarkLists, $attachedListIds));
|
||||
}
|
||||
|
||||
// 'any' — at least one benchmark list is NOT in subscriber's lists
|
||||
$removed = array_diff($benchmarkLists, $attachedListIds);
|
||||
return !empty($removed);
|
||||
}
|
||||
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Benchmarks;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseBenchMark;
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class RemoveFromTagBenchmark extends BaseBenchMark
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluentcrm_contact_removed_from_tags';
|
||||
$this->actionArgNum = 2;
|
||||
$this->priority = 20;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getBlock()
|
||||
{
|
||||
return [
|
||||
'title' => __('Tag Removed', 'fluent-crm'),
|
||||
'description' => __('This will run when selected Tags have been removed from a contact', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-tag_removed',//fluentCrmMix('images/funnel_icons/tag_removed.svg'),
|
||||
'settings' => [
|
||||
'tags' => [],
|
||||
'select_type' => 'any',
|
||||
'type' => 'optional',
|
||||
'can_enter' => 'yes'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getDefaultSettings()
|
||||
{
|
||||
return [
|
||||
'tags' => [],
|
||||
'select_type' => 'any',
|
||||
'type' => 'optional',
|
||||
'can_enter' => 'yes'
|
||||
];
|
||||
}
|
||||
|
||||
public function getBlockFields($funnel)
|
||||
{
|
||||
return [
|
||||
'title' => __('Tag Removed From Contact', 'fluent-crm'),
|
||||
'sub_title' => __('This will run when selected Tags have been removed from a contact', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'tags' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'tags',
|
||||
'is_multiple' => true,
|
||||
'label' => __('Select Tags', 'fluent-crm'),
|
||||
'placeholder' => __('Select a Tag', 'fluent-crm'),
|
||||
'creatable' => true
|
||||
],
|
||||
'select_type' => [
|
||||
'label' => __('Run When', 'fluent-crm'),
|
||||
'type' => 'radio',
|
||||
'options' => [
|
||||
[
|
||||
'id' => 'any',
|
||||
'title' => __('Run if any selected tag removed from a contact', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'all',
|
||||
'title' => __('Need all selected tags removed from the contact', 'fluent-crm')
|
||||
]
|
||||
],
|
||||
'dependency' => [
|
||||
'depends_on' => 'tags',
|
||||
'operator' => '!=',
|
||||
'value' => []
|
||||
]
|
||||
],
|
||||
'type' => $this->benchmarkTypeField(),
|
||||
'can_enter' => $this->canEnterField()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($benchMark, $originalArgs)
|
||||
{
|
||||
$listIds = $originalArgs[0];
|
||||
$subscriber = $originalArgs[1];
|
||||
$settings = $benchMark->settings;
|
||||
|
||||
if (!$this->isTagMatched($listIds, $subscriber, $settings)) {
|
||||
return; // not matched based on condition
|
||||
}
|
||||
|
||||
$funnelProcessor = new FunnelProcessor();
|
||||
$funnelProcessor->startFunnelFromSequencePoint($benchMark, $subscriber);
|
||||
}
|
||||
|
||||
private function isTagMatched($tagIds, $subscriber, $settings)
|
||||
{
|
||||
$benchmarkTags = Arr::get($settings, 'tags', []);
|
||||
if (empty($benchmarkTags)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$isMatched = array_intersect($benchmarkTags, $tagIds);
|
||||
if (!$isMatched) {
|
||||
return false; // not in our scope
|
||||
}
|
||||
|
||||
$matchType = Arr::get($settings, 'select_type');
|
||||
|
||||
if ($matchType == 'all') {
|
||||
// Check that ALL benchmark tags have been removed (none remain on subscriber)
|
||||
$attachedTagIds = $subscriber->tags->pluck('id')->toArray();
|
||||
return empty(array_intersect($benchmarkTags, $attachedTagIds));
|
||||
}
|
||||
|
||||
return $isMatched;
|
||||
}
|
||||
|
||||
public function assertCurrentGoalState($asserted, $benchmark, $funnelSubscriber)
|
||||
{
|
||||
if (!$funnelSubscriber || !$funnelSubscriber->subscriber) {
|
||||
return $asserted;
|
||||
}
|
||||
|
||||
$subscriber = $funnelSubscriber->subscriber;
|
||||
$settings = $benchmark->settings;
|
||||
$benchmarkTags = Arr::get($settings, 'tags', []);
|
||||
|
||||
if (empty($benchmarkTags)) {
|
||||
return $asserted;
|
||||
}
|
||||
|
||||
$attachedTagIds = $subscriber->tags->pluck('id')->toArray();
|
||||
$matchType = Arr::get($settings, 'select_type');
|
||||
|
||||
if ($matchType == 'all') {
|
||||
return empty(array_intersect($benchmarkTags, $attachedTagIds));
|
||||
}
|
||||
|
||||
// 'any' — at least one benchmark tag is NOT in subscriber's tags
|
||||
$removed = array_diff($benchmarkTags, $attachedTagIds);
|
||||
return !empty($removed);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Benchmarks;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseBenchMark;
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class TagAppliedBenchmark extends BaseBenchMark
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'fluentcrm_contact_added_to_tags';
|
||||
$this->actionArgNum = 2;
|
||||
$this->priority = 20;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getBlock()
|
||||
{
|
||||
return [
|
||||
'title' => __('Tag Applied', 'fluent-crm'),
|
||||
'description' => __('This will run when selected Tags have been applied to a contact', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-tag_applied',//fluentCrmMix('images/funnel_icons/tag-applied.svg'),
|
||||
'settings' => [
|
||||
'tags' => [],
|
||||
'select_type' => 'any',
|
||||
'type' => 'optional',
|
||||
'can_enter' => 'yes'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getDefaultSettings()
|
||||
{
|
||||
return [
|
||||
'tags' => [],
|
||||
'select_type' => 'any',
|
||||
'type' => 'optional',
|
||||
'can_enter' => 'yes'
|
||||
];
|
||||
}
|
||||
|
||||
public function getBlockFields($funnel)
|
||||
{
|
||||
return [
|
||||
'title' => __('Tag Applied', 'fluent-crm'),
|
||||
'sub_title' => __('This will run when selected Tags have been applied to a contact', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'tags' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'tags',
|
||||
'creatable' => true,
|
||||
'is_multiple' => true,
|
||||
'label' => __('Select Tags', 'fluent-crm'),
|
||||
'placeholder' => __('Select Tags', 'fluent-crm'),
|
||||
],
|
||||
'select_type' => [
|
||||
'label' => __('Run When', 'fluent-crm'),
|
||||
'type' => 'radio',
|
||||
'options' => [
|
||||
[
|
||||
'id' => 'any',
|
||||
'title' => __('contact added in any of the selected Tags', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'all',
|
||||
'title' => __('contact added in all of the selected Tags', 'fluent-crm')
|
||||
]
|
||||
],
|
||||
'dependency' => [
|
||||
'depends_on' => 'tags',
|
||||
'operator' => '!=',
|
||||
'value' => []
|
||||
]
|
||||
],
|
||||
'type' => $this->benchmarkTypeField(),
|
||||
'can_enter' => $this->canEnterField()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($benchMark, $originalArgs)
|
||||
{
|
||||
$tagIds = $originalArgs[0];
|
||||
$subscriber = $originalArgs[1];
|
||||
$settings = $benchMark->settings;
|
||||
|
||||
$benchmarkTags = Arr::get($settings, 'tags', []);
|
||||
|
||||
// Gate on the tags actually applied in this event. Without this a contact
|
||||
// who already holds the benchmark tag would advance past the benchmark
|
||||
// on any unrelated tag-add event.
|
||||
if (!$benchmarkTags || !array_intersect((array) $tagIds, $benchmarkTags)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberTags = $subscriber->tags->pluck('id')->toArray();
|
||||
|
||||
if (!$this->isTagMatched($subscriberTags, $benchmarkTags, $settings)) {
|
||||
return; // not matched based on condition
|
||||
}
|
||||
|
||||
$funnelProcessor = new FunnelProcessor();
|
||||
$funnelProcessor->startFunnelFromSequencePoint($benchMark, $subscriber);
|
||||
}
|
||||
|
||||
private function isTagMatched($subscriberTags, $benchmarkTags, $settings)
|
||||
{
|
||||
$matchType = Arr::get($settings, 'select_type');
|
||||
|
||||
if (empty($benchmarkTags)) {
|
||||
return false;
|
||||
}
|
||||
// find common elements between the two arrays
|
||||
$intersection = array_intersect($benchmarkTags, $subscriberTags);
|
||||
|
||||
if ($matchType === 'any') {
|
||||
// At least one funnel list id is available.
|
||||
$isMatched = !empty($intersection);
|
||||
} else {
|
||||
// All of the funnel list ids are present.
|
||||
$isMatched = count($intersection) === count($settings['tags']);
|
||||
}
|
||||
|
||||
return $isMatched;
|
||||
}
|
||||
|
||||
|
||||
public function assertCurrentGoalState($asserted, $benchmark, $funnelSubscriber)
|
||||
{
|
||||
if (!$funnelSubscriber || !$funnelSubscriber->subscriber) {
|
||||
return $asserted;
|
||||
}
|
||||
|
||||
$subscriberTags = $funnelSubscriber->subscriber->tags->pluck('id')->toArray();
|
||||
$benchmarkTags = Arr::get($benchmark->settings, 'tags', []);
|
||||
|
||||
$benchmarkSettings= $benchmark->settings;
|
||||
|
||||
return $this->isTagMatched($subscriberTags, $benchmarkTags, $benchmarkSettings);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel;
|
||||
|
||||
use FluentCrm\App\Hooks\Handlers\FunnelHandler;
|
||||
use FluentCrm\App\Models\Funnel;
|
||||
use FluentCrm\App\Models\FunnelMetric;
|
||||
use FluentCrm\App\Models\FunnelSequence;
|
||||
use FluentCrm\App\Models\FunnelSubscriber;
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class FunnelHelper
|
||||
{
|
||||
public static function changeFunnelSubSequenceStatus($funnelSubId, $sequenceId, $status = 'complete')
|
||||
{
|
||||
return FunnelSubscriber::where('id', $funnelSubId)
|
||||
->update([
|
||||
'last_sequence_status' => $status,
|
||||
'last_sequence_id' => $sequenceId,
|
||||
'last_executed_time' => current_time('mysql')
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getUpdateOptions()
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => 'update',
|
||||
'title' => __('Update if Exist', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'skip_all_if_exist',
|
||||
'title' => __('Skip this automation if contact already exist', 'fluent-crm')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public static function prepareUserData($user)
|
||||
{
|
||||
$subscriber = Helper::getWPMapUserInfo($user);
|
||||
$subscriber['source'] = 'web';
|
||||
return $subscriber;
|
||||
}
|
||||
|
||||
public static function getSubscriber($emailOrUserId)
|
||||
{
|
||||
$column = 'email';
|
||||
if (is_int($emailOrUserId)) {
|
||||
$column = 'user_id';
|
||||
}
|
||||
|
||||
return Subscriber::where($column, $emailOrUserId)->first();
|
||||
}
|
||||
|
||||
public static function createOrUpdateContact($data)
|
||||
{
|
||||
return FluentCrmApi('contacts')->createOrUpdate($data);
|
||||
}
|
||||
|
||||
public static function getUserRoles($keyed = false)
|
||||
{
|
||||
if (!function_exists('get_editable_roles')) {
|
||||
require_once(ABSPATH . '/wp-admin/includes/user.php');
|
||||
}
|
||||
|
||||
$roles = \get_editable_roles();
|
||||
$formattedRoles = [];
|
||||
foreach ($roles as $roleKey => $role) {
|
||||
if ($keyed) {
|
||||
$formattedRoles[$roleKey] = $role['name'];
|
||||
} else {
|
||||
$formattedRoles[] = [
|
||||
'id' => $roleKey,
|
||||
'title' => $role['name']
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
return $formattedRoles;
|
||||
}
|
||||
|
||||
public static function ifAlreadyInFunnel($funnelId, $subscriberId)
|
||||
{
|
||||
return FunnelSubscriber::where('funnel_id', $funnelId)
|
||||
->where('subscriber_id', $subscriberId)
|
||||
->first();
|
||||
}
|
||||
|
||||
public static function maybeExplodeFullName($data)
|
||||
{
|
||||
if (empty($data['first_name']) && empty($data['last_name'])) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
if (empty($data['first_name']) || !empty($data['last_name'])) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$fullNameArray = explode(' ', $data['first_name']);
|
||||
$data['first_name'] = array_shift($fullNameArray);
|
||||
if ($fullNameArray) {
|
||||
$data['last_name'] = implode(' ', $fullNameArray);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public static function syncTags($subscriber, $tags = [])
|
||||
{
|
||||
if ($tags) {
|
||||
$subscriber->attachTags($tags);
|
||||
}
|
||||
}
|
||||
|
||||
public static function syncLists($subscriber, $lists = [])
|
||||
{
|
||||
// Syncing
|
||||
if ($lists) {
|
||||
$subscriber->attachLists($lists);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getPrimaryContactFieldMaps()
|
||||
{
|
||||
return [
|
||||
'first_name' => [
|
||||
'type' => 'value_options',
|
||||
'label' => __('First Name', 'fluent-crm')
|
||||
],
|
||||
'last_name' => [
|
||||
'type' => 'value_options',
|
||||
'label' => __('Last Name', 'fluent-crm')
|
||||
],
|
||||
'email' => [
|
||||
'type' => 'value_options',
|
||||
'label' => __('Email', 'fluent-crm')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public static function getSecondaryContactFieldMaps()
|
||||
{
|
||||
$mainFields = [
|
||||
'prefix' => [
|
||||
'type' => 'value_options',
|
||||
'label' => __('Name Prefix', 'fluent-crm')
|
||||
],
|
||||
'address_line_1' => [
|
||||
'type' => 'value_options',
|
||||
'label' => __('Address Line 1', 'fluent-crm')
|
||||
],
|
||||
'address_line_2' => [
|
||||
'type' => 'value_options',
|
||||
'label' => __('Address Line 2', 'fluent-crm')
|
||||
],
|
||||
'postal_code' => [
|
||||
'type' => 'value_options',
|
||||
'label' => __('Postal Code', 'fluent-crm')
|
||||
],
|
||||
'city' => [
|
||||
'type' => 'value_options',
|
||||
'label' => __('City', 'fluent-crm')
|
||||
],
|
||||
'state' => [
|
||||
'type' => 'value_options',
|
||||
'label' => __('State', 'fluent-crm')
|
||||
],
|
||||
'country' => [
|
||||
'type' => 'value_options',
|
||||
'label' => __('country', 'fluent-crm')
|
||||
],
|
||||
'phone' => [
|
||||
'type' => 'value_options',
|
||||
'label' => __('Phone', 'fluent-crm')
|
||||
]
|
||||
];
|
||||
|
||||
$customFields = fluentcrm_get_option('contact_custom_fields', []);
|
||||
if ($customFields) {
|
||||
foreach ($customFields as $item) {
|
||||
$mainFields['custom.' . $item['slug']] = [
|
||||
'type' => 'value_options',
|
||||
'label' => $item['label']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $mainFields;
|
||||
|
||||
}
|
||||
|
||||
public static function removeSubscribersFromFunnel($funnelId, $subscriberIds)
|
||||
{
|
||||
FunnelSubscriber::where('funnel_id', $funnelId)
|
||||
->whereIn('subscriber_id', $subscriberIds)
|
||||
->delete();
|
||||
|
||||
FunnelMetric::where('funnel_id', $funnelId)
|
||||
->whereIn('subscriber_id', $subscriberIds)
|
||||
->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function saveFunnelSequence($funnelId, $data)
|
||||
{
|
||||
$funnelSettings = \json_decode(Arr::get($data, 'funnel_settings'), true);
|
||||
$funnelConditions = \json_decode(Arr::get($data, 'conditions', '[]'), true);
|
||||
|
||||
$funnel = Funnel::findOrFail($funnelId);
|
||||
|
||||
if (is_array($funnelSettings)) {
|
||||
$funnel->settings = $funnelSettings;
|
||||
}
|
||||
|
||||
if ($funnelTitle = Arr::get($data, 'funnel_title')) {
|
||||
$funnel->title = sanitize_text_field($funnelTitle);
|
||||
}
|
||||
|
||||
if (is_array($funnelConditions)) {
|
||||
$funnel->conditions = $funnelConditions;
|
||||
}
|
||||
$funnel->status = Arr::get($data, 'status');
|
||||
// Always write updated_at so it reflects when sequences were last saved
|
||||
$funnel->updated_at = current_time('mysql');
|
||||
$funnel->save();
|
||||
|
||||
if ($funnelDescription = Arr::get($data, 'funnel_description')) {
|
||||
$funnel->updateMeta('description', $funnelDescription);
|
||||
} else {
|
||||
$funnel->deleteMeta('description');
|
||||
}
|
||||
|
||||
$sequences = \json_decode(Arr::get($data, 'sequences', '[]'), true) ?? [];
|
||||
|
||||
if (!is_array($sequences)) {
|
||||
return $funnel;
|
||||
}
|
||||
|
||||
$sequenceIds = [];
|
||||
$cDelay = 0;
|
||||
$delay = 0;
|
||||
|
||||
$indexCount = 0;
|
||||
|
||||
foreach ($sequences as $index => $sequence) {
|
||||
// it's creatable
|
||||
$sequence['funnel_id'] = $funnel->id;
|
||||
$sequence['status'] = 'published';
|
||||
$sequence['conditions'] = [];
|
||||
$sequence['sequence'] = $indexCount + 1;
|
||||
$sequence['c_delay'] = $cDelay;
|
||||
$sequence['delay'] = $delay;
|
||||
$delay = 0;
|
||||
|
||||
$actionName = $sequence['action_name'];
|
||||
|
||||
if ($actionName == 'fluentcrm_wait_times') {
|
||||
$delay = self::getDelayInSecond($sequence['settings']);
|
||||
$cDelay += $delay;
|
||||
}
|
||||
|
||||
$sequence = apply_filters('fluentcrm_funnel_sequence_saving_' . $sequence['action_name'], $sequence, $funnel);
|
||||
|
||||
if (Arr::get($sequence, 'type') == 'benchmark') {
|
||||
$delay = $sequence['delay'];
|
||||
}
|
||||
|
||||
$sequence['id'] = self::createOrUpdateSequence($sequence);
|
||||
$sequenceIds[] = $sequence['id'];
|
||||
|
||||
// We have to handle the children if it's conditional block
|
||||
if ($sequence['type'] == 'conditional') {
|
||||
$childIds = self::saveChildSequences($sequence, $funnel);
|
||||
$indexCount += count($childIds);
|
||||
$sequenceIds = array_unique(array_merge($sequenceIds, $childIds));
|
||||
}
|
||||
|
||||
$indexCount += 1;
|
||||
}
|
||||
|
||||
if ($sequenceIds) {
|
||||
$deletingSequences = FunnelSequence::whereNotIn('id', $sequenceIds)
|
||||
->where('funnel_id', $funnel->id)
|
||||
->get();
|
||||
} else {
|
||||
$deletingSequences = FunnelSequence::where('funnel_id', $funnel->id)->get();
|
||||
}
|
||||
|
||||
if ($deletingSequences->count()) {
|
||||
foreach ($deletingSequences as $deletingSequence) {
|
||||
do_action('fluentcrm_funnel_sequence_deleting_' . $deletingSequence->action_name, $deletingSequence, $funnel);
|
||||
$deletingSequence->delete();
|
||||
}
|
||||
|
||||
// Unstick subscribers waiting on deleted benchmark sequences
|
||||
FunnelSubscriber::where('funnel_id', $funnel->id)
|
||||
->where('status', 'waiting')
|
||||
->whereNotIn('next_sequence_id', $sequenceIds)
|
||||
->update([
|
||||
'status' => 'active',
|
||||
'next_execution_time' => current_time('mysql')
|
||||
]);
|
||||
}
|
||||
|
||||
// Sync next_sequence integer after renumbering — active/waiting subscribers
|
||||
// may have stale values from before the re-save
|
||||
global $wpdb;
|
||||
$subscribersTable = $wpdb->prefix . 'fc_funnel_subscribers';
|
||||
$sequencesTable = $wpdb->prefix . 'fc_funnel_sequences';
|
||||
|
||||
$wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$subscribersTable} fs
|
||||
JOIN {$sequencesTable} seq ON fs.next_sequence_id = seq.id
|
||||
SET fs.next_sequence = seq.sequence
|
||||
WHERE fs.funnel_id = %d
|
||||
AND fs.status IN ('active', 'waiting')
|
||||
AND (fs.next_sequence IS NULL OR fs.next_sequence != seq.sequence)",
|
||||
$funnel->id
|
||||
));
|
||||
|
||||
(new FunnelHandler())->resetFunnelIndexes();
|
||||
|
||||
return $funnel;
|
||||
}
|
||||
|
||||
private static function createOrUpdateSequence($sequence)
|
||||
{
|
||||
if (empty($sequence['id'])) {
|
||||
$sequence['created_by'] = get_current_user_id();
|
||||
$createdSequence = FunnelSequence::create($sequence);
|
||||
do_action('fluent_crm/sequence_created_' . $createdSequence->action_name, $createdSequence);
|
||||
return $createdSequence->id;
|
||||
}
|
||||
|
||||
$sequence['updated_at'] = current_time('mysql');
|
||||
$sequence['settings'] = \maybe_serialize($sequence['settings']);
|
||||
$sequence['conditions'] = \maybe_serialize($sequence['conditions']);
|
||||
|
||||
$sequenceId = $sequence['id'];
|
||||
$data = Arr::only($sequence, (new FunnelSequence())->getFillable());
|
||||
|
||||
FunnelSequence::where('id', $sequenceId)->update($data);
|
||||
|
||||
return $sequenceId;
|
||||
}
|
||||
|
||||
public static function getDelayInSecond($settings)
|
||||
{
|
||||
$waitType = Arr::get($settings, 'wait_type');
|
||||
|
||||
if (!$waitType && Arr::get($settings, 'is_timestamp_wait') == 'yes') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($waitType == 'timestamp_wait' || $waitType == 'to_day') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$unit = Arr::get($settings, 'wait_time_unit');
|
||||
$time = (int) Arr::get($settings, 'wait_time_amount');
|
||||
|
||||
if ($unit == 'months') {
|
||||
// Months are not a fixed number of seconds, so anchor to the
|
||||
// current time to get a calendar-accurate offset (e.g. +2 months).
|
||||
$now = current_time('timestamp');
|
||||
$delay = strtotime('+' . $time . ' months', $now) - $now;
|
||||
} else {
|
||||
$converter = 86400; // default day
|
||||
if ($unit == 'hours') {
|
||||
$converter = 3600; // hour
|
||||
} else if ($unit == 'minutes') {
|
||||
$converter = 60;
|
||||
}
|
||||
$delay = $time * $converter;
|
||||
}
|
||||
|
||||
if (!$delay || $delay < 1) {
|
||||
$delay = 1;
|
||||
}
|
||||
|
||||
return $delay;
|
||||
}
|
||||
|
||||
public static function getCurrentDelayInSeconds($settings, $sequence = null, $funnelSubId = null)
|
||||
{
|
||||
$waitType = Arr::get($settings, 'wait_type');
|
||||
|
||||
/*
|
||||
* For Specific Date and Time
|
||||
*/
|
||||
if ((!$waitType && Arr::get($settings, 'is_timestamp_wait') == 'yes') || $waitType == 'timestamp_wait') {
|
||||
$timeStamp = current_time('timestamp');
|
||||
$waitTimes = strtotime(Arr::get($settings, 'wait_date_time'), $timeStamp) - $timeStamp;
|
||||
if ($waitTimes < 1) {
|
||||
$waitTimes = 0;
|
||||
}
|
||||
return apply_filters('fluent_crm/funnel_seq_delay_in_seconds', $waitTimes, $settings, $sequence, $funnelSubId);
|
||||
}
|
||||
|
||||
if ($waitType && $waitType == 'to_day') {
|
||||
$nextDays = Arr::get($settings, 'to_day', []);
|
||||
$timeStampNow = current_time('timestamp');
|
||||
|
||||
$nextDays = array_map(function ($dayName) {
|
||||
return substr($dayName, 0, 3);
|
||||
}, $nextDays);
|
||||
|
||||
if (empty($nextDays)) { // if no day is selected
|
||||
$nextDays = [gmdate('D', $timeStampNow), gmdate('D', strtotime('+1 day', $timeStampNow))];
|
||||
}
|
||||
|
||||
$nextTime = Arr::get($settings, 'to_day_time');
|
||||
if (!$nextTime) {
|
||||
$nextTime = gmdate('H:i', $timeStampNow);
|
||||
}
|
||||
|
||||
$date = self::getEarliestDay($nextDays, $nextTime);
|
||||
|
||||
$seconds = strtotime($date) - current_time('timestamp');
|
||||
$waitTimes = ($seconds < 1) ? 0 : $seconds;
|
||||
return apply_filters('fluent_crm/funnel_seq_delay_in_seconds', $waitTimes, $settings, $sequence, $funnelSubId);
|
||||
}
|
||||
|
||||
|
||||
if ($waitType == 'by_custom_field') {
|
||||
if (!$funnelSubId) {
|
||||
return apply_filters('fluent_crm/funnel_seq_delay_in_seconds', 60, $settings, $sequence, $funnelSubId);
|
||||
}
|
||||
|
||||
$funnelSub = FunnelSubscriber::where('id', $funnelSubId)->first();
|
||||
|
||||
if (!$funnelSub || !$funnelSub->subscriber) {
|
||||
return apply_filters('fluent_crm/funnel_seq_delay_in_seconds', 60, $settings, $sequence, $funnelSubId);
|
||||
}
|
||||
|
||||
$customFieldKey = Arr::get($settings, 'by_custom_field', '');
|
||||
|
||||
if (!$customFieldKey) {
|
||||
return apply_filters('fluent_crm/funnel_seq_delay_in_seconds', 60, $settings, $sequence, $funnelSubId);
|
||||
}
|
||||
|
||||
$dateTime = null;
|
||||
|
||||
if ($customFieldKey == '__date_of_birth__') {
|
||||
$dateTime = $funnelSub->subscriber->date_of_birth;
|
||||
if ($dateTime && !self::isValidYmd($dateTime)) {
|
||||
$dateTime = null;
|
||||
}
|
||||
if ($dateTime) {
|
||||
// should be this current year's date
|
||||
$dateTime = gmdate('Y') . '-' . gmdate('m-d', strtotime($dateTime));
|
||||
|
||||
// if the date is passed, then next year
|
||||
if (strtotime($dateTime) < current_time('timestamp')) {
|
||||
$dateTime = (gmdate('Y') + 1) . '-' . gmdate('m-d', strtotime($dateTime));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$meta = $funnelSub->subscriber->custom_field_meta()->where('key', $customFieldKey)->first();
|
||||
if ($meta) {
|
||||
$dateTime = $meta->value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dateTime) {
|
||||
return apply_filters('fluent_crm/funnel_seq_delay_in_seconds', 60, $settings, $sequence, $funnelSubId);
|
||||
}
|
||||
|
||||
$timeStamp = strtotime($dateTime);
|
||||
|
||||
$waitTimes = $timeStamp - current_time('timestamp');
|
||||
|
||||
if ($waitTimes < 1) {
|
||||
$waitTimes = 60;
|
||||
}
|
||||
|
||||
return apply_filters('fluent_crm/funnel_seq_delay_in_seconds', $waitTimes, $settings, $sequence, $funnelSubId);
|
||||
}
|
||||
|
||||
$unit = Arr::get($settings, 'wait_time_unit');
|
||||
$time = (int) Arr::get($settings, 'wait_time_amount');
|
||||
|
||||
if ($unit == 'months') {
|
||||
// Months are not a fixed number of seconds, so anchor to the
|
||||
// current time to get a calendar-accurate offset (e.g. +2 months).
|
||||
$now = current_time('timestamp');
|
||||
$waitTimes = strtotime('+' . $time . ' months', $now) - $now;
|
||||
} else {
|
||||
$converter = 86400; // default day
|
||||
if ($unit == 'hours') {
|
||||
$converter = 3600; // hour
|
||||
} else if ($unit == 'minutes') {
|
||||
$converter = 60;
|
||||
}
|
||||
$waitTimes = $time * $converter;
|
||||
}
|
||||
|
||||
if (!$waitTimes || $waitTimes < 1) {
|
||||
$waitTimes = 1;
|
||||
}
|
||||
|
||||
return apply_filters('fluent_crm/funnel_seq_delay_in_seconds', $waitTimes, $settings, $sequence, $funnelSubId);
|
||||
}
|
||||
|
||||
/*
|
||||
* Get the next earliest day provided to $days array as ['Mon', Wed, 'Fri']
|
||||
* @param array $days
|
||||
* @return string $earliest
|
||||
*/
|
||||
private static function getEarliestDay($days, $time = '')
|
||||
{
|
||||
$timestamp = current_time('timestamp');
|
||||
$timeStampsArray = [];
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$timeStampsArray[] = $timestamp + ($i * 86400);
|
||||
}
|
||||
|
||||
$earliest = gmdate('Y-m-d ' . $time . ':s', $timestamp);
|
||||
|
||||
foreach ($timeStampsArray as $timeStampVal) {
|
||||
if (in_array(gmdate('D', $timeStampVal), $days)) {
|
||||
$earliest = gmdate('Y-m-d ' . $time . ':s', $timeStampVal);
|
||||
if (strtotime($earliest) - $timestamp > -60) {
|
||||
return $earliest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $earliest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid calendar date in Y-m-d format (avoids strtotime normalizing invalid dates).
|
||||
*
|
||||
* @param string $ymd Date string (e.g. 2024-02-31).
|
||||
* @return bool
|
||||
*/
|
||||
private static function isValidYmd($ymd)
|
||||
{
|
||||
if (!is_string($ymd) || !preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $ymd, $parts)) {
|
||||
return false;
|
||||
}
|
||||
return checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1]);
|
||||
}
|
||||
|
||||
private static function saveChildSequences($sequence, $funnel)
|
||||
{
|
||||
$sequenceIds = [];
|
||||
$indexCount = $sequence['sequence'];
|
||||
$childCats = Arr::get($sequence, 'children');
|
||||
foreach ($childCats as $category => $blocks) {
|
||||
$childDelay = 0;
|
||||
$childCDelay = 0;
|
||||
foreach ($blocks as $childIndex => $childSequence) {
|
||||
$childSequence['funnel_id'] = $funnel->id;
|
||||
$childSequence['status'] = 'published';
|
||||
$childSequence['parent_id'] = $sequence['id'];
|
||||
$childSequence['condition_type'] = $category;
|
||||
$childSequence['conditions'] = [];
|
||||
$childSequence['sequence'] = $indexCount + 1;
|
||||
$childSequence['c_delay'] = $sequence['c_delay'] + $childCDelay;
|
||||
$childSequence['delay'] = $sequence['delay'] + $childDelay;
|
||||
|
||||
$childDelay = 0;
|
||||
|
||||
/*
|
||||
* For Delay Calculation
|
||||
*/
|
||||
$actionName = $childSequence['action_name'];
|
||||
if ($actionName == 'fluentcrm_wait_times') {
|
||||
$childDelay = self::getDelayInSecond($childSequence['settings']);
|
||||
$childCDelay += $childDelay;
|
||||
}
|
||||
|
||||
$childSequence = apply_filters('fluentcrm_funnel_sequence_saving_' . $childSequence['action_name'], $childSequence, $funnel);
|
||||
$sequenceIds[] = self::createOrUpdateSequence($childSequence);
|
||||
$indexCount += 1;
|
||||
}
|
||||
}
|
||||
return $sequenceIds;
|
||||
}
|
||||
|
||||
public static function getFunnelSequences($funnel, $isFiltered = false)
|
||||
{
|
||||
$sequences = FunnelSequence::where('funnel_id', $funnel->id)
|
||||
->orderBy('sequence', 'ASC')
|
||||
->get();
|
||||
|
||||
if (!$isFiltered) {
|
||||
return $sequences;
|
||||
}
|
||||
|
||||
$formattedSequences = [];
|
||||
foreach ($sequences as $sequence) {
|
||||
$sequenceArray = $sequence->toArray();
|
||||
$formattedSequences[] = apply_filters('fluentcrm_funnel_sequence_filtered_' . $sequence->action_name, $sequenceArray, $funnel);
|
||||
}
|
||||
|
||||
return $formattedSequences;
|
||||
}
|
||||
|
||||
public static function maybeMigrateConditions($funnelId)
|
||||
{
|
||||
$conditionSequences = FunnelSequence::where('funnel_id', $funnelId)
|
||||
->where('type', 'conditional')
|
||||
->where('action_name', '!=', 'funnel_condition')
|
||||
->get();
|
||||
|
||||
foreach ($conditionSequences as $conditionSequence) {
|
||||
self::migrateConditionSequence($conditionSequence);
|
||||
}
|
||||
|
||||
return !$conditionSequences->isEmpty();
|
||||
}
|
||||
|
||||
public static function migrateConditionSequence($sequence, $dryRun = false)
|
||||
{
|
||||
$conditionalBlocks = [
|
||||
'funnel_condition',
|
||||
'funnel_ab_testing'
|
||||
];
|
||||
|
||||
if ($sequence->type != 'conditional' || in_array($sequence->action_name, $conditionalBlocks)) {
|
||||
return $sequence;
|
||||
}
|
||||
|
||||
$simpleMaps = [
|
||||
'fcrm_has_contact_tag' => [
|
||||
'source' => ['segment', 'tags'],
|
||||
'operator' => 'in',
|
||||
'value_access' => 'tags',
|
||||
'default_value' => [],
|
||||
],
|
||||
'fcrm_has_contact_list' => [
|
||||
'source' => ['segment', 'lists'],
|
||||
'operator' => 'in',
|
||||
'value_access' => 'lists',
|
||||
'default_value' => [],
|
||||
],
|
||||
'fcrm_has_user_role' => [
|
||||
'source' => ['segment', 'user_role'],
|
||||
'operator' => 'in',
|
||||
'value_access' => 'roles',
|
||||
'default_value' => [],
|
||||
],
|
||||
'fcrm_woo_is_purchased' => [
|
||||
'source' => ['woo', 'purchased_items'],
|
||||
'operator' => 'in',
|
||||
'value_access' => 'product_ids',
|
||||
'default_value' => [],
|
||||
],
|
||||
'fcrm_wishlist_is_in_level' => [
|
||||
'source' => ['wishlist', 'in_membership'],
|
||||
'operator' => 'in',
|
||||
'value_access' => 'level_ids',
|
||||
'default_value' => [],
|
||||
],
|
||||
'fcrm_tutor_is_in_course' => [
|
||||
'source' => ['tutorlms', 'is_in_course'],
|
||||
'operator' => 'in',
|
||||
'value_access' => 'course_ids',
|
||||
'default_value' => [],
|
||||
],
|
||||
'fcrm_pmpro_is_in_membership' => [
|
||||
'source' => ['pmpro', 'in_membership'],
|
||||
'operator' => 'in',
|
||||
'value_access' => 'level_ids',
|
||||
'default_value' => [],
|
||||
],
|
||||
'fcrm_lifter_is_in_course' => [
|
||||
'source' => ['lifterlms', 'purchased_items'],
|
||||
'operator' => 'in',
|
||||
'value_access' => 'course_ids',
|
||||
'default_value' => [],
|
||||
],
|
||||
'fcrm_lifter_is_in_membership' => [
|
||||
'source' => ['lifterlms', 'purchased_groups'],
|
||||
'operator' => 'in',
|
||||
'value_access' => 'course_ids',
|
||||
'default_value' => [],
|
||||
],
|
||||
'fcrm_learndhash_is_in_course' => [
|
||||
'source' => ['learndash', 'purchased_items'],
|
||||
'operator' => 'in',
|
||||
'value_access' => 'course_ids',
|
||||
'default_value' => [],
|
||||
],
|
||||
'fcrm_learndhash_is_in_group' => [
|
||||
'source' => ['learndash', 'purchased_groups'],
|
||||
'operator' => 'in',
|
||||
'value_access' => 'group_ids',
|
||||
'default_value' => [],
|
||||
],
|
||||
'fcrm_edd_is_purchased' => [
|
||||
'source' => ['edd', 'purchased_items'],
|
||||
'operator' => 'in',
|
||||
'value_access' => 'product_ids',
|
||||
'default_value' => [],
|
||||
],
|
||||
'fcrm_rcp_is_in_membership' => [
|
||||
'source' => ['rcp', 'in_membership'],
|
||||
'operator' => 'in',
|
||||
'value_access' => 'level_ids',
|
||||
'default_value' => [],
|
||||
]
|
||||
];
|
||||
|
||||
$operatorMaps = [
|
||||
'match_all' => 'in_all',
|
||||
'match_none_of' => 'not_in_all',
|
||||
'contains' => 'contains',
|
||||
'doNotContains' => 'not_contains',
|
||||
'startsWith' => 'startsWith',
|
||||
'endsWith' => 'endsWith'
|
||||
];
|
||||
|
||||
$conditionName = $sequence->action_name;
|
||||
|
||||
$oldSettings = $sequence->settings;
|
||||
|
||||
if (isset($simpleMaps[$conditionName])) {
|
||||
$map = $simpleMaps[$conditionName];
|
||||
$sequence->action_name = 'funnel_condition';
|
||||
$sequence->settings = [
|
||||
'conditions' => [
|
||||
[
|
||||
[
|
||||
'source' => $map['source'],
|
||||
'operator' => $map['operator'],
|
||||
'value' => Arr::get($sequence->settings, $map['value_access'], $map['default_value'])
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
if (!$dryRun) {
|
||||
$sequence->save();
|
||||
}
|
||||
} else if ($conditionName == 'fcrm_check_user_prop') {
|
||||
$sequence->action_name = 'funnel_condition';
|
||||
$conditionGroups = $oldSettings['condition_groups'];
|
||||
|
||||
$formattedConditions = [[]];
|
||||
|
||||
if (isset($conditionGroups[0])) {
|
||||
$conditions = $conditionGroups[0]['conditions'];
|
||||
$conditionType = $conditionGroups[0]['match_type'];
|
||||
|
||||
if ($conditionType != 'match_all') {
|
||||
$formattedConditions = [];
|
||||
}
|
||||
|
||||
foreach ($conditions as $condition) {
|
||||
$dataKey = $condition['data_key'];
|
||||
$operator = $condition['operator'];
|
||||
$dataValue = $condition['data_value'];
|
||||
if (!$dataKey || !$operator) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($operatorMaps[$operator])) {
|
||||
$operator = $operatorMaps[$operator];
|
||||
}
|
||||
|
||||
if ($dataKey == 'contact_type' || $dataKey == 'country') {
|
||||
if ($operator == '=') {
|
||||
$operator = 'in';
|
||||
} else {
|
||||
$operator = 'not_in';
|
||||
}
|
||||
if ($dataKey == 'country') {
|
||||
$dataValue = (array)$dataValue;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$provider = 'subscriber';
|
||||
|
||||
if (strpos($dataKey, 'custom.') === 0) {
|
||||
$provider = 'custom_fields';
|
||||
$dataKey = str_replace('custom.', '', $dataKey);
|
||||
}
|
||||
|
||||
$item = [
|
||||
'source' => [
|
||||
$provider,
|
||||
$dataKey
|
||||
],
|
||||
'operator' => $operator,
|
||||
'value' => $dataValue
|
||||
];
|
||||
|
||||
if ($conditionType == 'match_all') {
|
||||
$formattedConditions[0][] = $item;
|
||||
} else {
|
||||
$formattedConditions[] = [$item];
|
||||
}
|
||||
}
|
||||
}
|
||||
$sequence->settings = [
|
||||
'conditions' => $formattedConditions
|
||||
];
|
||||
if (!$dryRun) {
|
||||
$sequence->save();
|
||||
}
|
||||
} else if ($conditionName == 'fcrm_woo_conditions') {
|
||||
$sequence->action_name = 'funnel_condition';
|
||||
$conditionGroups = $sequence->settings['conditional_groups'];
|
||||
|
||||
$formattedConditions = [[]];
|
||||
|
||||
if (isset($conditionGroups[0])) {
|
||||
$conditions = $conditionGroups[0]['conditions'];
|
||||
|
||||
$keyMaps = [
|
||||
'order_total_value' => [
|
||||
'woo_order',
|
||||
'total_value'
|
||||
],
|
||||
'order_product_ids' => [
|
||||
'woo_order',
|
||||
'product_ids'
|
||||
],
|
||||
'order_cat_purchased' => [
|
||||
'woo_order',
|
||||
'cat_purchased'
|
||||
],
|
||||
'order_billing_country' => [
|
||||
'woo_order',
|
||||
'billing_country'
|
||||
],
|
||||
'order_shipping_method' => [
|
||||
'woo_order',
|
||||
'shipping_method'
|
||||
],
|
||||
'order_payment_gateway' => [
|
||||
'woo_order',
|
||||
'payment_gateway'
|
||||
],
|
||||
|
||||
'customer_total_spend' => [
|
||||
'woo',
|
||||
'total_order_value'
|
||||
],
|
||||
'customer_order_count' => [
|
||||
'woo',
|
||||
'total_spend'
|
||||
],
|
||||
'customer_guest_user' => [
|
||||
'woo',
|
||||
'guest_user'
|
||||
],
|
||||
'customer_billing_country' => [
|
||||
'woo',
|
||||
'billing_country'
|
||||
],
|
||||
'customer_cat_purchased' => [
|
||||
'woo',
|
||||
'purchased_categories'
|
||||
],
|
||||
'customer_purchased_products' => [
|
||||
'woo',
|
||||
'purchased_items'
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($conditions as $condition) {
|
||||
$dataKey = $condition['data_key'];
|
||||
$operator = $condition['operator'];
|
||||
$dataValue = $condition['data_value'];
|
||||
|
||||
if (!$dataKey || !$operator || !isset($keyMaps[$dataKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($operatorMaps[$operator])) {
|
||||
$operator = $operatorMaps[$operator];
|
||||
}
|
||||
|
||||
if ($dataKey == 'order_billing_country' || $dataKey == 'customer_billing_country') {
|
||||
if ($operator != '=') {
|
||||
$operator = 'not_in';
|
||||
} else {
|
||||
$operator = 'in';
|
||||
}
|
||||
|
||||
$dataValue = (array)$dataValue;
|
||||
}
|
||||
|
||||
$item = [
|
||||
'source' => $keyMaps[$dataKey],
|
||||
'operator' => $operator,
|
||||
'value' => $dataValue
|
||||
];
|
||||
$formattedConditions[0][] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
$sequence->settings = [
|
||||
'conditions' => $formattedConditions
|
||||
];
|
||||
|
||||
if (!$dryRun) {
|
||||
$sequence->save();
|
||||
}
|
||||
}
|
||||
|
||||
return $sequence;
|
||||
}
|
||||
|
||||
|
||||
public static function createWpUserFromSubscriber($subscriber, $sendWelcomeEmail = false, $password = '', $role = '', $metaData = [])
|
||||
{
|
||||
if ($userId = $subscriber->getWpUserId()) {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
if (!$password) {
|
||||
$password = wp_generate_password(8);
|
||||
}
|
||||
|
||||
$userId = wp_create_user(sanitize_user($subscriber->email), $password, $subscriber->email);
|
||||
if (is_wp_error($userId)) {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
if (!$role) {
|
||||
// get default user role of WordPress
|
||||
$role = get_option('default_role');
|
||||
if (!$role) {
|
||||
$role = 'subscriber';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$user = new \WP_User($userId);
|
||||
$user->set_role($role);
|
||||
|
||||
$metaData['first_name'] = $subscriber->first_name;
|
||||
$metaData['last_name'] = $subscriber->last_name;
|
||||
|
||||
$userMetas = array_filter($metaData);
|
||||
|
||||
foreach ($userMetas as $metaKey => $metaValue) {
|
||||
update_user_meta($userId, $metaKey, $metaValue);
|
||||
}
|
||||
|
||||
if ($sendWelcomeEmail) {
|
||||
wp_send_new_user_notifications($userId, 'user');
|
||||
}
|
||||
|
||||
$subscriber->user_id = $userId;
|
||||
$subscriber->save();
|
||||
return $userId;
|
||||
}
|
||||
|
||||
|
||||
public static function getCountryShortName($countryName)
|
||||
{
|
||||
if (!function_exists('getFluentFormCountryList')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$countries = getFluentFormCountryList();
|
||||
if (isset($countries[strtoupper($countryName)])) {
|
||||
return $countryName;
|
||||
}
|
||||
|
||||
$countries = array_flip($countries);
|
||||
if (isset($countries[$countryName])) {
|
||||
return $countries[$countryName];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getFunnelSubscriberStatus($defaultStatus, $funnel, $subscriber)
|
||||
{
|
||||
if ($defaultStatus == 'active' || $defaultStatus == 'waiting') {
|
||||
return $defaultStatus;
|
||||
}
|
||||
|
||||
$processableStatuses = ['subscribed', 'transactional'];
|
||||
if (in_array($subscriber->status, $processableStatuses, true)) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
if (Arr::get($funnel->settings, '__force_run_actions') == 'yes') {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
return $defaultStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel;
|
||||
|
||||
use FluentCrm\App\Models\Funnel;
|
||||
use FluentCrm\App\Models\FunnelMetric;
|
||||
use FluentCrm\App\Models\FunnelSequence;
|
||||
use FluentCrm\App\Models\FunnelSubscriber;
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class FunnelProcessor
|
||||
{
|
||||
private $subscribersCache = [];
|
||||
|
||||
private $sequenceFunnelCache = [];
|
||||
|
||||
private $funnelCache = [];
|
||||
|
||||
public function getSubscriber($id)
|
||||
{
|
||||
if (isset($this->subscribersCache[$id])) {
|
||||
return $this->subscribersCache[$id];
|
||||
}
|
||||
$subscriber = Subscriber::find($id);
|
||||
$this->subscribersCache[$id] = $subscriber;
|
||||
return $this->subscribersCache[$id];
|
||||
}
|
||||
|
||||
public function setSubscriber($id)
|
||||
{
|
||||
$subscriber = Subscriber::find($id);
|
||||
$this->subscribersCache[$id] = $subscriber;
|
||||
return $this->subscribersCache[$id];
|
||||
}
|
||||
|
||||
public function getSequence($id)
|
||||
{
|
||||
if (isset($this->sequenceFunnelCache[$id])) {
|
||||
return $this->sequenceFunnelCache[$id];
|
||||
}
|
||||
|
||||
$funnelAction = FunnelSequence::find($id);
|
||||
$this->sequenceFunnelCache[$id] = $funnelAction;
|
||||
return $this->sequenceFunnelCache[$id];
|
||||
}
|
||||
|
||||
public function getFunnel($id)
|
||||
{
|
||||
if (isset($this->funnelCache[$id])) {
|
||||
return $this->funnelCache[$id];
|
||||
}
|
||||
$funnel = Funnel::find($id);
|
||||
$this->funnelCache[$id] = $funnel;
|
||||
return $this->funnelCache[$id];
|
||||
}
|
||||
|
||||
public function setFunnel($id)
|
||||
{
|
||||
$funnel = Funnel::find($id);
|
||||
$this->funnelCache[$id] = $funnel;
|
||||
return $this->funnelCache[$id];
|
||||
}
|
||||
|
||||
public function startFunnelSequence($funnel, $subscriberData, $funnelSubArgs = [], $subscriber = false)
|
||||
{
|
||||
if (isset($subscriberData['email'])) {
|
||||
$subscriber = Subscriber::where('email', $subscriberData['email'])->first();
|
||||
}
|
||||
|
||||
if (!$subscriber) {
|
||||
// it's new so let's create new subscriber
|
||||
$subscriber = FunnelHelper::createOrUpdateContact($subscriberData);
|
||||
|
||||
if (!$subscriber) {
|
||||
return false;
|
||||
}
|
||||
} elseif (Arr::get($subscriberData, 'status') === 'pending' && $subscriber->status !== 'subscribed') {
|
||||
// update status only if it's not subscribed already
|
||||
$subscriber->status = 'pending';
|
||||
$subscriber->save();
|
||||
}
|
||||
|
||||
if ($subscriber->status == 'pending') {
|
||||
$subscriber->sendDoubleOptinEmail();
|
||||
}
|
||||
|
||||
$args = [
|
||||
'status' => ($subscriber->status == 'pending' || $subscriber->status == 'unsubscribed') ? 'pending' : 'draft'
|
||||
];
|
||||
|
||||
if ($funnelSubArgs) {
|
||||
$args = wp_parse_args($args, $funnelSubArgs);
|
||||
}
|
||||
|
||||
$this->startSequences($subscriber, $funnel, $args);
|
||||
}
|
||||
|
||||
public function startSequences($subscriber, $funnel, $funnelSubArgs = [])
|
||||
{
|
||||
// Check if already in funnel to prevent duplicate entries
|
||||
$existing = FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id);
|
||||
if ($existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'funnel_id' => $funnel->id,
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'status' => 'draft'
|
||||
];
|
||||
|
||||
if ($funnelSubArgs) {
|
||||
$data = wp_parse_args($funnelSubArgs, $data);
|
||||
}
|
||||
|
||||
$data['status'] = FunnelHelper::getFunnelSubscriberStatus($data['status'], $funnel, $subscriber);
|
||||
|
||||
// Unique index on (funnel_id, subscriber_id) prevents duplicates at DB level
|
||||
try {
|
||||
$funnelSubscriber = FunnelSubscriber::create($data);
|
||||
} catch (\Exception $e) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sequencePoints = (new SequencePoints($funnel, $funnelSubscriber));
|
||||
|
||||
if (!$sequencePoints->hasSequences()) {
|
||||
FunnelSubscriber::where('id', $funnelSubscriber->id)->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
do_action('fluent_crm/automation_funnel_start', $funnel, $subscriber);
|
||||
|
||||
if ($funnelSubscriber->status != 'pending') {
|
||||
$this->processSequencePoints($sequencePoints, $subscriber, $funnelSubscriber);
|
||||
}
|
||||
}
|
||||
|
||||
public function processSequencePoints(SequencePoints $sequencePoints, $subscriber, $funnelSubscriber)
|
||||
{
|
||||
if (!$sequencePoints->hasSequences()) {
|
||||
$this->completeFunnelSequence($funnelSubscriber);
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute immediate sequences first (they may include conditionals
|
||||
// that set their own next state via initChildSequences)
|
||||
$hasConditional = false;
|
||||
foreach ($sequencePoints->getCurrentSequences() as $sequence) {
|
||||
if ($sequence->type == 'conditional') {
|
||||
$hasConditional = true;
|
||||
}
|
||||
$this->processSequence($subscriber, $sequence, $funnelSubscriber->id);
|
||||
if ($sequence->action_name == 'end_this_funnel') {
|
||||
$this->completeFunnelSequence($funnelSubscriber);
|
||||
$this->setFunnel($funnelSubscriber->funnel_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If a conditional was processed, initChildSequences() already
|
||||
// handled the subscriber's next state — skip status update
|
||||
if ($hasConditional) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nextSequence = $sequencePoints->getNextSequence();
|
||||
$requiredBenchMark = $sequencePoints->getRequiredBenchmark();
|
||||
|
||||
if ($nextSequence && $requiredBenchMark) {
|
||||
if ($nextSequence->sequence < $requiredBenchMark->sequence) {
|
||||
$requiredBenchMark = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($requiredBenchMark) {
|
||||
FunnelSubscriber::where('id', $funnelSubscriber->id)
|
||||
->update([
|
||||
'next_sequence' => $requiredBenchMark->sequence,
|
||||
'next_sequence_id' => $requiredBenchMark->id,
|
||||
'next_execution_time' => NULL,
|
||||
'status' => 'waiting'
|
||||
]);
|
||||
} else if (!$sequencePoints->hasNext()) {
|
||||
$this->completeFunnelSequence($funnelSubscriber);
|
||||
} else if ($nextSequence) {
|
||||
$nextDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') + $nextSequence->delay);
|
||||
|
||||
if ($nextSequence->execution_date_time) {
|
||||
$nextDateTime = $nextSequence->execution_date_time;
|
||||
}
|
||||
|
||||
FunnelSubscriber::where('id', $funnelSubscriber->id)
|
||||
->update([
|
||||
'next_sequence' => $nextSequence->sequence,
|
||||
'next_sequence_id' => $nextSequence->id,
|
||||
'next_execution_time' => $nextDateTime,
|
||||
'status' => 'active'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function processSequence($subscriber, $sequence, $funnelSubscriberId)
|
||||
{
|
||||
$funnelMetric = $this->recordFunnelMetric($subscriber, $sequence);
|
||||
|
||||
// Mark complete BEFORE execution by design: prevents double-execution if the process
|
||||
// crashes mid-action. The FunnelMetric + wasRecentlyCreated check ensures each action
|
||||
// runs at most once per subscriber per sequence. Failed actions are not retried.
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'complete');
|
||||
|
||||
if ($sequence->type == 'conditional' && $sequence->action_name != 'funnel_condition') {
|
||||
$sequence = FunnelHelper::migrateConditionSequence($sequence);
|
||||
}
|
||||
|
||||
if ($funnelMetric->wasRecentlyCreated) {
|
||||
try {
|
||||
do_action('fluentcrm_funnel_sequence_handle_' . $sequence->action_name, $subscriber, $sequence, $funnelSubscriberId, $funnelMetric, $this);
|
||||
} catch (\Throwable $e) {
|
||||
Helper::debugLog('Funnel sequence error for ' . $sequence->funnel_id . ' => Funnel Sub ID: ' . $funnelSubscriberId, $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function resumeFunnelSubscriber($funnel, $subscriber, $funnelSubscriber)
|
||||
{
|
||||
$sequencePoints = new SequencePoints($funnel, $funnelSubscriber);
|
||||
$this->processSequencePoints($sequencePoints, $subscriber, $funnelSubscriber);
|
||||
}
|
||||
|
||||
public function completeFunnelSequence($funnelSubscriber)
|
||||
{
|
||||
FunnelSubscriber::where('id', $funnelSubscriber->id)
|
||||
->update([
|
||||
'status' => 'completed'
|
||||
]);
|
||||
|
||||
$funnel = $this->getFunnel($funnelSubscriber->funnel_id);
|
||||
$subscriber = $this->getSubscriber($funnelSubscriber->subscriber_id);
|
||||
|
||||
if ($funnel && $subscriber) {
|
||||
do_action('fluent_crm/automation_funnel_completed', $funnel, $subscriber);
|
||||
}
|
||||
}
|
||||
|
||||
public function followUpSequenceActions()
|
||||
{
|
||||
update_option('_fc_last_funnel_processor_ran', time(), 'no');
|
||||
|
||||
/**
|
||||
* Apply a filter to retrieve the subscriber statuses for the funnel.
|
||||
*
|
||||
* This filter allows customization of the subscriber statuses used in the funnel processing.
|
||||
* By default, it includes only the 'active' status.
|
||||
*
|
||||
* @param array $statuses The default subscriber statuses, which is ['active'].
|
||||
* @return array Filtered subscriber statuses.
|
||||
* @since 1.0.0
|
||||
*
|
||||
*/
|
||||
$statuses = apply_filters('fluent_crm/funnel_subscriber_statuses', ['active']);
|
||||
|
||||
/* Funnel processor batch limit
|
||||
* This is the maximum number of funnel subscribers that will be processed in a single run.
|
||||
* By default, it is set to 200. FluentCRM wants to process 200 funnel subscribers each time
|
||||
* @param int $batchLimit The batch limit for the funnel processor.
|
||||
* @return int Filtered batch limit.
|
||||
* @since 3.0.0
|
||||
*/
|
||||
$batchLimit = (int)apply_filters('fluent_crm/funnel_processor_batch_limit', 200);
|
||||
if ($batchLimit < 1) {
|
||||
$batchLimit = 1;
|
||||
}
|
||||
|
||||
$jobs = FunnelSubscriber::join('fc_funnels', function ($join) {
|
||||
$join->on('fc_funnel_subscribers.funnel_id', '=', 'fc_funnels.id')
|
||||
->where('fc_funnels.status', '=', 'published')
|
||||
->where('fc_funnels.type', '=', 'funnels');
|
||||
})
|
||||
->whereIn('fc_funnel_subscribers.status', $statuses)
|
||||
->where('fc_funnel_subscribers.next_execution_time', '<=', current_time('mysql'))
|
||||
->whereNotNull('fc_funnel_subscribers.next_execution_time')
|
||||
->orderBy('fc_funnel_subscribers.next_execution_time', 'ASC')
|
||||
->limit($batchLimit)
|
||||
->select('fc_funnel_subscribers.*')
|
||||
->get();
|
||||
|
||||
$startingAt = time();
|
||||
|
||||
$completed = 0;
|
||||
|
||||
/* Funnel processor max processing seconds
|
||||
* This is the maximum number of seconds that the funnel processor will run for.
|
||||
* By default, it is set to 55 seconds.
|
||||
* This is a safety mechanism to prevent the funnel processor from running for too long
|
||||
* @since 3.0.0
|
||||
*/
|
||||
$maxProcessingSeconds = (int)apply_filters('fluent_crm/funnel_processor_max_processing_seconds', 55);
|
||||
if ($maxProcessingSeconds < 1) {
|
||||
$maxProcessingSeconds = 1;
|
||||
}
|
||||
|
||||
foreach ($jobs as $job) {
|
||||
if ((time() - $startingAt) > $maxProcessingSeconds) {
|
||||
break;
|
||||
}
|
||||
|
||||
$completed++;
|
||||
|
||||
$this->processFunnelAction($job);
|
||||
}
|
||||
|
||||
Helper::debugLog('Automation followUpSequenceActions', 'Completed Jobs Count: ' . $completed);
|
||||
}
|
||||
|
||||
public function processFunnelAction($funnelSubscriber)
|
||||
{
|
||||
$subscriber = $this->getSubscriber($funnelSubscriber->subscriber_id);
|
||||
$funnel = $this->getFunnel($funnelSubscriber->funnel_id);
|
||||
|
||||
if (!$subscriber || !$funnel) {
|
||||
FunnelSubscriber::where('id', $funnelSubscriber->id)->update([
|
||||
'status' => 'skipped'
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!in_array($subscriber->status, ['subscribed', 'transactional'])) {
|
||||
$forceRun = Arr::get($funnel->settings, '__force_run_actions') === 'yes';
|
||||
if (!$forceRun) {
|
||||
FunnelSubscriber::where('id', $funnelSubscriber->id)->update([
|
||||
'status' => 'cancelled'
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$sequencePoints = new SequencePoints($funnel, $funnelSubscriber);
|
||||
|
||||
$this->processSequencePoints($sequencePoints, $subscriber, $funnelSubscriber);
|
||||
}
|
||||
|
||||
public function startFunnelFromSequencePoint($startSequence, $subscriber, $args = [], $metricArgs = [])
|
||||
{
|
||||
if (!$subscriber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$funnelSubscriber = FunnelHelper::ifAlreadyInFunnel($startSequence->funnel_id, $subscriber->id);
|
||||
|
||||
if (!$funnelSubscriber && $startSequence->type == 'benchmark') {
|
||||
// it's new starting point for a goal type sequence
|
||||
// so if the can start is set to no then we will skip this
|
||||
if (Arr::get($startSequence->settings, 'can_enter') == 'no') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($funnelSubscriber && ($funnelSubscriber->status == 'completed' || $funnelSubscriber->status == 'cancelled')) {
|
||||
return false; // It's already completed or cancelled. We don't need to start again
|
||||
}
|
||||
|
||||
|
||||
$this->recordFunnelMetric($subscriber, $startSequence, $metricArgs);
|
||||
|
||||
if (!$funnelSubscriber) {
|
||||
|
||||
$processableStatuses = ['subscribed', 'transactional'];
|
||||
|
||||
// we have to create a funnel subscriber
|
||||
$data = [
|
||||
'funnel_id' => $startSequence->funnel_id,
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'status' => (in_array($subscriber->status, $processableStatuses, true)) ? 'active' : 'pending',
|
||||
'starting_sequence_id' => $startSequence->id,
|
||||
'last_sequence_status' => 'completed',
|
||||
'next_sequence' => $startSequence->sequence + 1,
|
||||
'last_sequence_id' => $startSequence->id,
|
||||
'last_executed_time' => current_time('mysql'),
|
||||
'source_trigger_name' => $startSequence->action_name
|
||||
];
|
||||
|
||||
if ($args) {
|
||||
$data = wp_parse_args($args, $data);
|
||||
}
|
||||
|
||||
if ($data['status'] != 'active') {
|
||||
$data['status'] = FunnelHelper::getFunnelSubscriberStatus($data['status'], $this->getFunnel($startSequence->funnel_id), $subscriber);
|
||||
}
|
||||
|
||||
// Unique index on (funnel_id, subscriber_id) prevents duplicates at DB level
|
||||
try {
|
||||
$funnelSubscriber = FunnelSubscriber::create($data);
|
||||
} catch (\Exception $e) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// We already have funnel subscriber. Now we have to update that
|
||||
$lastSequence = $funnelSubscriber->last_sequence;
|
||||
|
||||
if (!$lastSequence || ($lastSequence->sequence <= $startSequence->sequence)) {
|
||||
$nextSequence = FunnelSequence::where('sequence', '>', $startSequence->sequence)
|
||||
->where('funnel_id', $startSequence->funnel_id)
|
||||
->orderBy('sequence', 'ASC')
|
||||
->first();
|
||||
|
||||
if (!$nextSequence) {
|
||||
$this->completeFunnelSequence($funnelSubscriber);
|
||||
return;
|
||||
}
|
||||
|
||||
$funnelSubscriber->last_sequence_id = $startSequence->id;
|
||||
$funnelSubscriber->next_sequence_id = $nextSequence->id;
|
||||
$funnelSubscriber->next_sequence = $nextSequence->sequence;
|
||||
if ($funnelSubscriber->status == 'waiting') {
|
||||
$funnelSubscriber->status = 'active';
|
||||
}
|
||||
$funnelSubscriber->next_execution_time = current_time('mysql'); // we are auto advancing the funnel
|
||||
$funnelSubscriber->save();
|
||||
} else {
|
||||
// this already advanced than our target
|
||||
// We have to check if we have to fire immediately
|
||||
if ($funnelSubscriber->next_sequence - 1 == $startSequence->sequence) {
|
||||
// we are just make the time with current time if that had a timer for the target sequence
|
||||
$funnelSubscriber->next_execution_time = current_time('mysql');
|
||||
$funnelSubscriber->save();
|
||||
} else {
|
||||
return; // It will work as it is; This funnel don't need any help
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($funnelSubscriber->status == 'pending') {
|
||||
return; // We need double-optin from this user.
|
||||
}
|
||||
|
||||
$funnel = $this->getFunnel($startSequence->funnel_id);
|
||||
|
||||
$sequencePoints = new SequencePoints($funnel, $funnelSubscriber);
|
||||
|
||||
$this->processSequencePoints($sequencePoints, $subscriber, $funnelSubscriber);
|
||||
}
|
||||
|
||||
public function recordFunnelMetric($subscriber, $sequence, $metricArgs = [])
|
||||
{
|
||||
$lookupData = [
|
||||
'funnel_id' => $sequence->funnel_id,
|
||||
'sequence_id' => $sequence->id,
|
||||
'subscriber_id' => $subscriber->id
|
||||
];
|
||||
|
||||
$extraData = $metricArgs ?: [];
|
||||
|
||||
try {
|
||||
return FunnelMetric::firstOrCreate($lookupData, $extraData);
|
||||
} catch (\Exception $e) {
|
||||
// Race condition: another process inserted between the SELECT and INSERT
|
||||
$existing = FunnelMetric::where($lookupData)->first();
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function initChildSequences($parent, $isMatched, $subscriber, $funnelSubscriberId, $funnelMetric)
|
||||
{
|
||||
$conditionType = 'no';
|
||||
if ($isMatched) {
|
||||
$conditionType = 'yes';
|
||||
}
|
||||
// find the corresponding sequence
|
||||
$sequences = FunnelSequence::where('funnel_id', $parent->funnel_id)
|
||||
->where('parent_id', $parent->id)
|
||||
->orderBy('sequence', 'ASC')
|
||||
->where('condition_type', $conditionType)
|
||||
->get();
|
||||
|
||||
$waitTimes = 0;
|
||||
if (!$sequences->isEmpty()) {
|
||||
$immediateSequences = [];
|
||||
$firstSequence = $sequences[0];
|
||||
$nextSequence = false;
|
||||
|
||||
foreach ($sequences as $sequence) {
|
||||
if ($sequence->c_delay == $firstSequence->c_delay) {
|
||||
$immediateSequences[] = $sequence;
|
||||
} else {
|
||||
if (!$nextSequence) {
|
||||
$nextSequence = $sequence;
|
||||
}
|
||||
if ($sequence->c_delay < $nextSequence->c_delay) {
|
||||
$nextSequence = $sequence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($immediateSequences as $immediateSequence) {
|
||||
$this->processSequence($subscriber, $immediateSequence, $funnelSubscriberId);
|
||||
if ($immediateSequence->action_name == 'end_this_funnel') {
|
||||
$funnelSub = FunnelSubscriber::where('id', $funnelSubscriberId)->first();
|
||||
if ($funnelSub) {
|
||||
$this->completeFunnelSequence($funnelSub);
|
||||
}
|
||||
$this->setFunnel($immediateSequence->funnel_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($immediateSequence->action_name == 'fluentcrm_wait_times') {
|
||||
$waitTimes = FunnelHelper::getCurrentDelayInSeconds($immediateSequence->settings, $immediateSequence, $funnelSubscriberId);
|
||||
}
|
||||
}
|
||||
|
||||
if ($nextSequence) {
|
||||
|
||||
$waitDateTimes = gmdate('Y-m-d H:i:s', current_time('timestamp') + $nextSequence->delay);
|
||||
|
||||
if ($waitTimes) {
|
||||
$waitDateTimes = gmdate('Y-m-d H:i:s', current_time('timestamp') + $waitTimes);
|
||||
}
|
||||
|
||||
return FunnelSubscriber::where('id', $funnelSubscriberId)
|
||||
->update([
|
||||
'next_sequence' => $nextSequence->sequence,
|
||||
'next_sequence_id' => $nextSequence->id,
|
||||
'next_execution_time' => $waitDateTimes,
|
||||
'status' => 'active'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$funnelSubscriber = FunnelSubscriber::where('id', $funnelSubscriberId)->first();
|
||||
if (!$funnelSubscriber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$funnelSubscriber->last_sequence_id = $parent->id;
|
||||
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $parent->id);
|
||||
$funnel = $this->getFunnel($parent->funnel_id);
|
||||
|
||||
// we don't have next sequence so we have to loop back to the parent
|
||||
$sequencePoints = new SequencePoints($funnel, $funnelSubscriber);
|
||||
|
||||
if ($waitTimes && $currentNextSequences = $sequencePoints->getCurrentSequences()) {
|
||||
$nextSequence = $currentNextSequences[0];
|
||||
return FunnelSubscriber::where('id', $funnelSubscriberId)
|
||||
->update([
|
||||
'next_sequence' => $nextSequence->sequence,
|
||||
'next_sequence_id' => $nextSequence->id,
|
||||
'next_execution_time' => gmdate('Y-m-d H:i:s', current_time('timestamp') + $waitTimes),
|
||||
'status' => 'active'
|
||||
]);
|
||||
}
|
||||
|
||||
$this->processSequencePoints($sequencePoints, $subscriber, $funnelSubscriber);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel;
|
||||
|
||||
use FluentCrm\App\Services\Helper;
|
||||
|
||||
class ProFunnelItems
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
add_filter('fluentcrm_funnel_triggers', function ($allTriggers) {
|
||||
$triggers = $this->getProTriggers();
|
||||
return array_merge($allTriggers, $triggers);
|
||||
});
|
||||
|
||||
add_filter('fluentcrm_funnel_blocks', function ($funnelBlocks) {
|
||||
$blocks = $this->getProBlocks();
|
||||
return array_merge($funnelBlocks, $blocks);
|
||||
}, 100);
|
||||
|
||||
}
|
||||
|
||||
private function getProTriggers()
|
||||
{
|
||||
$triggers = $this->getCrmTriggers();
|
||||
if (defined('WC_PLUGIN_FILE')) {
|
||||
$triggers['woocommerce_order_status_processing'] = [
|
||||
'category' => 'WooCommerce',
|
||||
'label' => 'New Order (Processing)',
|
||||
'icon' => 'fc-icon-woo_new_order',
|
||||
'description' => 'This funnel will start once a new order will be added as processing',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['woocommerce_order_status_completed'] = [
|
||||
'category' => 'WooCommerce',
|
||||
'label' => 'Order Completed',
|
||||
'icon' => 'fc-icon-woo_order_complete',
|
||||
'description' => 'This funnel will start when an order is completed',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['woocommerce_order_status_refunded'] = [
|
||||
'category' => 'WooCommerce',
|
||||
'label' => 'Order Refunded',
|
||||
'icon' => 'fc-icon-woo_refund',
|
||||
'description' => 'This funnel will start when an order is refunded',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['woocommerce_order_status_changed'] = [
|
||||
'category' => 'WooCommerce',
|
||||
'label' => 'Order Status Changed',
|
||||
'icon' => 'fc-icon-woo',
|
||||
'description' => 'This funnel will start when an order status changes',
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('WCS_INIT_TIMESTAMP')) {
|
||||
$triggers['woocommerce_subscription_status_active'] = [
|
||||
'category' => 'WooCommerce',
|
||||
'label' => 'Subscription activated',
|
||||
'icon' => 'fc-icon-woo_order_complete',
|
||||
'description' => 'This funnel will start when a WooCommerce subscription begins or its status changes to active.',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['woocommerce_subscription_renewal_payment_complete'] = [
|
||||
'category' => 'WooCommerce',
|
||||
'label' => 'Renewal Payment Received',
|
||||
'icon' => 'fc-icon-woo_order_complete',
|
||||
'description' => 'This funnel will start when a recurring payment received for a subscription',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['woocommerce_subscription_renewal_payment_failed'] = [
|
||||
'category' => 'WooCommerce',
|
||||
'label' => 'Renewal Payment Failed',
|
||||
'icon' => 'fc-icon-woo_refund',
|
||||
'description' => 'This funnel will start when a subscription payment fails',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['woocommerce_subscription_status_cancelled'] = [
|
||||
'category' => 'WooCommerce',
|
||||
'label' => 'WooCommerce Subscription Cancelled',
|
||||
'icon' => 'fc-icon-woo_refund',
|
||||
'description' => 'This funnel will start when a WooCommerce subscription is cancelled.',
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('WLM3_PLUGIN_VERSION')) {
|
||||
$triggers['wishlistmember_add_user_levels'] = [
|
||||
'category' => 'Wishlist Member',
|
||||
'label' => 'Membership Enrolled',
|
||||
'icon' => 'fc-icon-wishlist',
|
||||
'description' => 'This funnel runs when a member is added to a membership level',
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('TUTOR_VERSION')) {
|
||||
$triggers['tutor_after_enrolled'] = [
|
||||
'category' => 'TutorLMS',
|
||||
'label' => 'Course Enrolled',
|
||||
'icon' => 'fc-icon-tutor_lms_enrollment_course',
|
||||
'description' => 'This funnel runs when a student is enrolled in a course',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['tutor_course_complete_after'] = [
|
||||
'category' => 'TutorLMS',
|
||||
'label' => 'Course Completed',
|
||||
'icon' => 'fc-icon-tutor_lms_complete_course',
|
||||
'description' => 'This funnel runs when a student completes a course',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['tutor_lesson_completed_after'] = [
|
||||
'category' => 'TutorLMS',
|
||||
'label' => 'Lesson Completed',
|
||||
'icon' => 'fc-icon-tutor_lms_complete_course',
|
||||
'description' => 'This funnel runs when a student completes a lesson',
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
if (class_exists('\Restrict_Content_Pro')) {
|
||||
$triggers['rcp_membership_post_activate'] = [
|
||||
'category' => 'Restrict Content Pro',
|
||||
'label' => 'Membership Enrolled',
|
||||
'icon' => 'fc-icon-rcp_membership_level',
|
||||
'description' => 'This funnel runs when a member is added to a membership level',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['rcp_transition_membership_status_expired'] = [
|
||||
'category' => 'Restrict Content Pro',
|
||||
'label' => 'Membership Expired',
|
||||
'icon' => 'fc-icon-rcp_membership_cancle',
|
||||
'description' => 'This funnel runs when a membership expires',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['rcp_membership_post_cancel'] = [
|
||||
'category' => 'Restrict Content Pro',
|
||||
'label' => 'Membership Cancelled',
|
||||
'icon' => 'fc-icon-rcp_membership_cancle',
|
||||
'description' => 'This funnel runs when a membership is cancelled',
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('PMPRO_VERSION')) {
|
||||
$triggers['pmpro_after_change_membership_level'] = [
|
||||
'category' => 'Paid Membership Pro',
|
||||
'label' => 'Membership Enrolled',
|
||||
'icon' => 'fc-icon-paid_membership_pro_user_level',
|
||||
'description' => 'This funnel runs when a member is added to a membership level',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['pmpro_membership_post_membership_expiry'] = [
|
||||
'category' => 'Paid Membership Pro',
|
||||
'label' => 'Membership Level Expired',
|
||||
'icon' => 'fc-icon-membership_level_ex_pmp',
|
||||
'description' => 'This funnel runs when a membership expires',
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('MEPR_PLUGIN_NAME')) {
|
||||
$triggers['mepr-account-is-active'] = [
|
||||
'category' => 'MemberPress',
|
||||
'label' => 'Membership Enrolled',
|
||||
'icon' => 'fc-icon-memberpress_membership',
|
||||
'description' => 'This funnel runs when a member is added to a membership level',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['mepr-event-transaction-expired'] = [
|
||||
'category' => 'MemberPress',
|
||||
'label' => 'Subscription expired',
|
||||
'icon' => 'el-icon-circle-close',
|
||||
'description' => 'This funnel runs when a subscription expires',
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('LLMS_PLUGIN_FILE')) {
|
||||
$triggers['llms_user_enrolled_in_course'] = [
|
||||
'category' => 'LifterLMS',
|
||||
'label' => 'Course Enrolled',
|
||||
'icon' => 'fc-icon-lifter_lms_course_enrollment',
|
||||
'description' => 'This funnel runs when a contact is enrolled in a course',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['lifterlms_course_completed'] = [
|
||||
'category' => 'LifterLMS',
|
||||
'label' => 'Course Completed',
|
||||
'icon' => 'fc-icon-lifter_lms_complete_course',
|
||||
'description' => 'This funnel runs when a student completes a course',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['llms_user_added_to_membership_level'] = [
|
||||
'category' => 'LifterLMS',
|
||||
'label' => 'Joined Membership',
|
||||
'icon' => 'fc-icon-lifter_lms_membership',
|
||||
'description' => 'This funnel runs when a student has been enrolled in a membership level',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['lifterlms_lesson_completed'] = [
|
||||
'category' => 'LifterLMS',
|
||||
'label' => 'Lesson Completed',
|
||||
'icon' => 'fc-icon-lifter_lms_complete_lession-t2',
|
||||
'description' => 'This funnel runs when a student completes a lesson',
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('LEARNDASH_VERSION')) {
|
||||
$triggers['learndash_update_course_access'] = [
|
||||
'category' => 'LearnDash',
|
||||
'label' => 'Course Enrolled',
|
||||
'icon' => 'fc-icon-learndash_enroll_course',
|
||||
'description' => 'This funnel runs when a student is enrolled in a course',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['learndash_lesson_completed'] = [
|
||||
'category' => 'LearnDash',
|
||||
'label' => 'Lesson Completed',
|
||||
'icon' => 'fc-icon-learndash_complete_lesson',
|
||||
'description' => 'This funnel runs a student completes a lesson',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['learndash_topic_completed'] = [
|
||||
'category' => 'LearnDash',
|
||||
'label' => 'Topic Completed',
|
||||
'icon' => 'fc-icon-learndash_complete_topic',
|
||||
'description' => 'This funnel runs when a student completes a lesson topic',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['learndash_course_completed'] = [
|
||||
'category' => 'LearnDash',
|
||||
'label' => 'Course Completed',
|
||||
'icon' => 'fc-icon-learndash_complete_course',
|
||||
'description' => 'This funnel runs when a student completes a course',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['ld_added_group_access'] = [
|
||||
'category' => 'LearnDash',
|
||||
'label' => 'Group Enrolled',
|
||||
'icon' => 'fc-icon-learndash_course_group',
|
||||
'description' => 'This funnel runs when a user is enrolled in a group',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['simulated_learndash_update_course_removed'] = [
|
||||
'category' => 'LearnDash',
|
||||
'label' => 'Course Left',
|
||||
'icon' => 'fc-icon-learndash_enroll_course',
|
||||
'description' => 'This funnel runs when a student leaves a course',
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
if (Helper::isEdd3()) {
|
||||
$triggers['edd_update_payment_status'] = [
|
||||
'category' => 'Easy Digital Downloads',
|
||||
'label' => 'Edd - New Order Success',
|
||||
'icon' => 'fc-icon-edd_new_order_success',
|
||||
'description' => 'This funnel will start once new order payment is successful',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
if (defined('EDD_SL_VERSION')) {
|
||||
$triggers['edd_sl_post_set_status'] = [
|
||||
'category' => 'Easy Digital Downloads',
|
||||
'label' => 'License Expired',
|
||||
'description' => 'This funnel will start when a license gets expired',
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('EDD_RECURRING_VERSION')) {
|
||||
$triggers['edd_recurring_add_subscription_payment'] = [
|
||||
'category' => 'Easy Digital Downloads',
|
||||
'label' => 'Renewal Payment Received',
|
||||
'description' => 'This funnel will start when a renewal payment is received for an active subscription',
|
||||
'icon' => 'fc-icon-edd_new_order_success',
|
||||
'disabled' => true
|
||||
];
|
||||
|
||||
$triggers['edd_subscription_status_change'] = [
|
||||
'category' => 'Easy Digital Downloads',
|
||||
'label' => 'Recurring Subscription Expired',
|
||||
'description' => 'This funnel will start when a recurring subscription gets expired',
|
||||
'icon' => 'el-icon-circle-close',
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (class_exists('\Affiliate_WP')) {
|
||||
$triggers['affwp_set_affiliate_status'] = [
|
||||
'category' => 'AffiliateWP',
|
||||
'label' => 'New Affiliate Joined',
|
||||
'icon' => 'fc-icon-trigger',
|
||||
'description' => 'This funnel will be initiated when a new affiliate gets approved/registered directly',
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('SURECART_PLUGIN_FILE')) {
|
||||
$triggers['fluent_surecart_purchase_created_wrap'] = [
|
||||
'category' => 'SureCart',
|
||||
'label' => 'SureCart - New Order Success',
|
||||
'icon' => 'el-icon-shopping-cart-full',
|
||||
'description' => 'This funnel will start when new order payment is successful',
|
||||
'disabled' => true,
|
||||
'svg' => '<svg width="16px" height="16px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 137 137" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M68.5 137C106.332 137 137 106.332 137 68.5C137 30.6685 106.332 0 68.5 0C30.6685 0 0 30.6685 0 68.5C0 106.332 30.6685 137 68.5 137ZM68.795 34.25C63.2947 34.25 55.6831 37.3955 51.7938 41.2756L41.2306 51.8141H93.8817L111.487 34.25H68.795ZM85.1174 95.7244C81.2282 99.6045 73.6165 102.75 68.1163 102.75H25.4242L43.0295 85.1859H95.6806L85.1174 95.7244ZM102.229 60.5962H32.4471L29.1508 63.8895C21.3458 70.9151 23.6606 76.4039 34.5912 76.4039H104.563L107.86 73.1106C115.589 66.1263 113.16 60.5962 102.229 60.5962Z" fill="#008156"/>
|
||||
<script xmlns=""/></svg>'
|
||||
];
|
||||
|
||||
$triggers['fluent_surecart_purchase_refund_wrap'] = [
|
||||
'category' => 'SureCart',
|
||||
'label' => 'SureCart - Order Revoked',
|
||||
'icon' => 'el-icon-sold-out',
|
||||
'description' => 'This funnel will start when order will be revoked',
|
||||
'disabled' => true,
|
||||
'svg' => '<svg width="16px" height="16px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 137 137" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M68.5 137C106.332 137 137 106.332 137 68.5C137 30.6685 106.332 0 68.5 0C30.6685 0 0 30.6685 0 68.5C0 106.332 30.6685 137 68.5 137ZM68.795 34.25C63.2947 34.25 55.6831 37.3955 51.7938 41.2756L41.2306 51.8141H93.8817L111.487 34.25H68.795ZM85.1174 95.7244C81.2282 99.6045 73.6165 102.75 68.1163 102.75H25.4242L43.0295 85.1859H95.6806L85.1174 95.7244ZM102.229 60.5962H32.4471L29.1508 63.8895C21.3458 70.9151 23.6606 76.4039 34.5912 76.4039H104.563L107.86 73.1106C115.589 66.1263 113.16 60.5962 102.229 60.5962Z" fill="#008156"/>
|
||||
<script xmlns=""/></svg>'
|
||||
];
|
||||
}
|
||||
|
||||
return $triggers;
|
||||
|
||||
}
|
||||
|
||||
private function getProBlocks()
|
||||
{
|
||||
$blocks = [
|
||||
'fcrm_has_contact_list' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'conditional',
|
||||
'title' => 'Has In Selected Lists',
|
||||
'description' => 'Check If the contact has specific lists',
|
||||
'icon' => 'fc-icon-has_list',
|
||||
],
|
||||
'fcrm_has_contact_tag' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'conditional',
|
||||
'title' => 'Has Selected tags',
|
||||
'description' => 'Check If the contact has specific tags',
|
||||
'icon' => 'fc-icon-has_list',
|
||||
],
|
||||
'fluencrm_benchmark_link_clicked' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'benchmark',
|
||||
'title' => 'Link Click',
|
||||
'description' => 'This will run once a subscriber click on this provided link',
|
||||
'icon' => 'fc-icon-link_clicked',
|
||||
],
|
||||
'send_campaign_email' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'Email',
|
||||
'title' => 'Send Campaign Email',
|
||||
'description' => 'Send an Email from your existing campaign',
|
||||
'icon' => 'fc-icon-send_campaign',
|
||||
],
|
||||
'remove_from_funnel' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'CRM',
|
||||
'title' => 'Cancel Automations',
|
||||
'description' => 'Pause/Cancel another automation for contact',
|
||||
'icon' => 'fc-icon-cancel_automation',
|
||||
],
|
||||
'remove_from_email_sequence' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'Email',
|
||||
'title' => 'Cancel Sequence Emails',
|
||||
'description' => 'Cancel Sequence Emails for the contact',
|
||||
'icon' => 'fc-icon-cancel_sequence',//fluentCrmMix('images/funnel_icons/cancel_sequence.svg')
|
||||
],
|
||||
'add_to_email_sequence' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'Email',
|
||||
'title' => 'Set Sequence Emails',
|
||||
'description' => 'Send Automated Emails based on your Sequence settings',
|
||||
'icon' => 'fc-icon-set_sequence',
|
||||
],
|
||||
'update_contact_property' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'CRM',
|
||||
'title' => 'Update Contact Property',
|
||||
'description' => 'Update custom fields or few main property of a contact',
|
||||
'icon' => 'fc-icon-wp_user_meta',
|
||||
],
|
||||
'add_contact_activity' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'CRM',
|
||||
'title' => 'Add Notes & Activity',
|
||||
'description' => 'Add Notes or Activity to the Contact Profile',
|
||||
'icon' => 'fc-icon-writing'
|
||||
],
|
||||
'outgoing_webhook' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'CRM',
|
||||
'title' => 'Outgoing Webhook',
|
||||
'description' => 'Send Data to external server via GET or POST Method',
|
||||
'icon' => 'fc-icon-webhooks',
|
||||
],
|
||||
'end_this_funnel' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'CRM',
|
||||
'title' => 'End This Funnel Here',
|
||||
'description' => 'No further action will run once a contact hit this point',
|
||||
'icon' => 'fc-icon-end_funnel',
|
||||
],
|
||||
'fcrm_check_user_prop' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'conditional',
|
||||
'title' => 'Check Contact\'s Properties',
|
||||
'description' => 'Check If the contact match specific data properties',
|
||||
'icon' => 'fc-icon-check_contact_property_conditional',
|
||||
],
|
||||
'user_registration_action' => [
|
||||
'is_pro' => true,
|
||||
'category' => 'WordPress',
|
||||
'type' => 'action',
|
||||
'title' => 'Create WordPress User',
|
||||
'description' => 'Create WP User with a role if user is not already registered with contact email',
|
||||
'icon' => 'fc-icon-create_wp_user',
|
||||
],
|
||||
'fcrm_update_user_meta' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'WordPress',
|
||||
'title' => 'Update WP User Meta',
|
||||
'description' => 'Update WordPress User Meta Data',
|
||||
'icon' => 'fc-icon-wp_user_meta',
|
||||
],
|
||||
'fcrm_change_user_role' => [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'WordPress',
|
||||
'title' => 'Change WP User Role',
|
||||
'description' => 'If user exist with the contact email then you can change user role',
|
||||
'icon' => 'fc-icon-wp_user_role',
|
||||
]
|
||||
];
|
||||
|
||||
if (Helper::isEdd3()) {
|
||||
$blocks['edd_update_payment_status_benchmark'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'conditional',
|
||||
'title' => 'New Order Success in EDD',
|
||||
'description' => 'This will run once new order will be placed as processing in EDD',
|
||||
'icon' => 'fc-icon-edd_new_order_success',
|
||||
];
|
||||
$blocks['fcrm_edd_is_purchased'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'conditional',
|
||||
'title' => 'Check if the contact purchased a specific product',
|
||||
'description' => 'Check If user purchased selected products and run sequences conditionally',
|
||||
'icon' => 'fc-icon-edd_new_order_success',
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('WC_PLUGIN_FILE')) {
|
||||
$blocks['fcrm_woo_is_purchased'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'conditional',
|
||||
'title' => 'Check if the contact purchased a specific product',
|
||||
'description' => 'Check If user purchased selected products and run sequences conditionally',
|
||||
'icon' => 'fc-icon-woo_purchased',
|
||||
];
|
||||
|
||||
$blocks['woocommerce_order_status_processing_benchmark'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'benchmark',
|
||||
'title' => 'Order Received in WooCommerce',
|
||||
'description' => 'This will run once new order has been placed as processing',
|
||||
'icon' => 'fc-icon-new_order_woo',
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('LEARNDASH_VERSION')) {
|
||||
$blocks['fcrm_learndhash_is_in_course'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'conditional',
|
||||
'title' => '[LearnDash] Check if the contact enroll a course',
|
||||
'description' => 'Conditionally check if contact enrolled or completed a course',
|
||||
'icon' => 'fc-icon-ld_in_course',
|
||||
];
|
||||
$blocks['fcrm_learndhash_is_in_group'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'conditional',
|
||||
'title' => '[LearnDash] Check if the contact is in a group',
|
||||
'description' => 'Conditionally check if contact is in a group',
|
||||
'icon' => 'fc-icon-ld_in_group',
|
||||
];
|
||||
$blocks['fcrm_learndhash_add_to_course'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'LearnDash',
|
||||
'title' => 'Enroll To Course',
|
||||
'description' => 'Enroll the contact to a specific LMS Course',
|
||||
'icon' => 'fc-icon-learndash_enroll_course',
|
||||
];
|
||||
$blocks['fcrm_learndhash_add_to_group'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'LearnDash',
|
||||
'title' => 'Enroll To Group',
|
||||
'description' => 'Enroll the contact to a specific LMS Group',
|
||||
'icon' => '',
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('LLMS_PLUGIN_FILE')) {
|
||||
$blocks['fcrm_lifter_is_in_course'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'conditional',
|
||||
'title' => '[LifterLMS] Check if the contact enroll a course',
|
||||
'description' => 'Conditionally check if contact enrolled or completed a course',
|
||||
'icon' => 'fc-icon-lifter_lms_course_enrollment',
|
||||
];
|
||||
$blocks['fcrm_lifter_is_in_membership'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'conditional',
|
||||
'title' => '[LifterLMS] Check if the contact has a Membership',
|
||||
'description' => 'Conditionally check if contact has an active membership level',
|
||||
'icon' => 'fc-icon-lifter_lms_membership',
|
||||
];
|
||||
$blocks['fcrm_lifter_add_to_course'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'LifterLMS',
|
||||
'title' => 'Enroll To Course',
|
||||
'description' => 'Enroll the contact to a specific LMS Course',
|
||||
'icon' => '',
|
||||
];
|
||||
$blocks['fcrm_lifterlms_add_to_group'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'action',
|
||||
'category' => 'LifterLMS',
|
||||
'title' => 'Enroll To Membership',
|
||||
'description' => 'Enroll the contact to a specific LMS Membership',
|
||||
'icon' => '',
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('TUTOR_VERSION')) {
|
||||
$blocks['fcrm_tutor_is_in_membership'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'conditional',
|
||||
'title' => '[TutorLMS] Check if the contact enroll a course',
|
||||
'description' => 'Conditionally check if contact enrolled or completed a course',
|
||||
'icon' => 'fc-icon-tutor_in_course',
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('SURECART_PLUGIN_FILE')) {
|
||||
$blocks['fluent_surecart_purchase_created_wrap'] = [
|
||||
'is_pro' => true,
|
||||
'type' => 'benchmark',
|
||||
'title' => 'Order Received in SureCart',
|
||||
'description' => 'This will run once new order has been placed as processing',
|
||||
'icon' => 'el-icon-shopping-cart-full',
|
||||
];
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
|
||||
}
|
||||
|
||||
private function getCrmTriggers()
|
||||
{
|
||||
return [
|
||||
'fluentcrm_contact_added_to_lists' => [
|
||||
'category' => 'CRM',
|
||||
'label' => 'List Applied',
|
||||
'icon' => 'fc-icon-list_applied_2',
|
||||
'description' => 'This will run when selected lists have been applied to a contact',
|
||||
'disabled' => true
|
||||
],
|
||||
|
||||
'fluentcrm_contact_removed_from_lists' => [
|
||||
'category' => 'CRM',
|
||||
'label' => 'List Removed',
|
||||
'icon' => 'fc-icon-list_removed',
|
||||
'description' => 'This will run when selected lists have been removed from a contact',
|
||||
'disabled' => true
|
||||
],
|
||||
|
||||
'fluentcrm_contact_added_to_tags' => [
|
||||
'category' => 'CRM',
|
||||
'label' => 'Tag Applied',
|
||||
'icon' => 'fc-icon-tag_applied',
|
||||
'description' => 'This will run when selected tags have been applied to a contact',
|
||||
'disabled' => true
|
||||
],
|
||||
|
||||
'fluentcrm_contact_removed_from_tags' => [
|
||||
'category' => 'CRM',
|
||||
'label' => 'Tag Removed',
|
||||
'icon' => 'fc-icon-tag_removed',
|
||||
'description' => 'This will run when selected Tags have been removed from a contact',
|
||||
'disabled' => true
|
||||
],
|
||||
|
||||
'fluentcrm_contact_birthday' => [
|
||||
'category' => 'CRM',
|
||||
'label' => 'Contact\'s Birthday',
|
||||
'icon' => '', // 'fc-icon-tag_applied',
|
||||
'description' => 'Funnel will be initiated on the day of contact\'s birthday',
|
||||
'disabled' => true
|
||||
],
|
||||
|
||||
'fluent_crm/contact_created' => [
|
||||
'category' => 'CRM',
|
||||
'label' => 'Contact Created',
|
||||
'icon' => '', // 'fc-icon-tag_applied',
|
||||
'description' => 'This will run when a new contact will be added',
|
||||
'disabled' => true
|
||||
],
|
||||
|
||||
'fluentcrm_contact_added_to_companies' => [
|
||||
'category' => 'CRM',
|
||||
'label' => 'Company Applied',
|
||||
'icon' => '', // 'fc-icon-tag_applied',
|
||||
'description' => 'This will run when selected companies have been applied to a contact',
|
||||
'disabled' => true
|
||||
],
|
||||
|
||||
'fluentcrm_contact_removed_from_companies' => [
|
||||
'category' => 'CRM',
|
||||
'label' => 'Company Removed',
|
||||
'icon' => '', // 'fc-icon-tag_applied',
|
||||
'description' => 'This will run when selected companies have been removed from a contact',
|
||||
'disabled' => true
|
||||
],
|
||||
|
||||
'wp_login' => [
|
||||
'category' => 'WordPress Triggers',
|
||||
'label' => 'User Login',
|
||||
'icon' => 'fc-icon-wp_new_user_signup',
|
||||
'description' => 'This Funnel will be initiated when a user login to your site',
|
||||
'disabled' => true
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel;
|
||||
|
||||
use FluentCrm\App\Models\FunnelSequence;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class SequencePoints
|
||||
{
|
||||
private $nextSequence = null;
|
||||
|
||||
private $immediateSequences = [];
|
||||
|
||||
private $lastSequence = null;
|
||||
|
||||
private $requiredBenchMark = null;
|
||||
|
||||
private $funnel;
|
||||
|
||||
private $hasNext = null;
|
||||
|
||||
private $funnelSubscriber;
|
||||
|
||||
private $nextSequenceExecutionTime = false;
|
||||
|
||||
public $hasEndSequence = false;
|
||||
|
||||
public function __construct($funnel, $funnelSubscriber = false)
|
||||
{
|
||||
$this->funnel = $funnel;
|
||||
$this->funnelSubscriber = $funnelSubscriber;
|
||||
$this->setupData();
|
||||
}
|
||||
|
||||
private function setupData()
|
||||
{
|
||||
$this->resolveLastSequence();
|
||||
|
||||
$isInChild = false;
|
||||
$sequences = $this->queryRemainingSequences($isInChild);
|
||||
|
||||
if (!$sequences || $sequences->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$inWaitTimes = false;
|
||||
$this->classifySequences($sequences, $isInChild, $inWaitTimes);
|
||||
|
||||
// If we're inside a child block and exhausted all child sequences,
|
||||
// transition back to the parent-level sequence flow
|
||||
if ($isInChild && !$this->nextSequence && !$this->hasEndSequence) {
|
||||
$this->handleChildToParentTransition($inWaitTimes);
|
||||
}
|
||||
|
||||
if ($this->nextSequence && $this->nextSequenceExecutionTime) {
|
||||
$this->nextSequence->execution_date_time = $this->nextSequenceExecutionTime;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveLastSequence()
|
||||
{
|
||||
if ($this->funnelSubscriber && $this->funnelSubscriber->last_sequence_id) {
|
||||
$this->lastSequence = FunnelSequence::where('id', $this->funnelSubscriber->last_sequence_id)->first();
|
||||
}
|
||||
}
|
||||
|
||||
private function queryRemainingSequences(&$isInChild)
|
||||
{
|
||||
if ($this->lastSequence) {
|
||||
return $this->queryFromLastSequence($isInChild);
|
||||
}
|
||||
|
||||
return $this->queryFromStart();
|
||||
}
|
||||
|
||||
private function queryFromLastSequence(&$isInChild)
|
||||
{
|
||||
$query = FunnelSequence::orderBy('sequence', 'ASC')
|
||||
->where('funnel_id', $this->funnel->id)
|
||||
->where('sequence', '>', $this->lastSequence->sequence);
|
||||
|
||||
if ($this->lastSequence->parent_id) {
|
||||
$isInChild = true;
|
||||
$query->where('parent_id', $this->lastSequence->parent_id)
|
||||
->where('condition_type', $this->lastSequence->condition_type);
|
||||
}
|
||||
|
||||
$sequences = $query->get();
|
||||
|
||||
// If inside a child block but no more child sequences, escape to parent level
|
||||
if ($isInChild && $sequences->isEmpty()) {
|
||||
$isInChild = false;
|
||||
$sequences = $this->queryParentEscapeSequences();
|
||||
}
|
||||
|
||||
return $sequences;
|
||||
}
|
||||
|
||||
private function queryParentEscapeSequences()
|
||||
{
|
||||
$nextSequenceNumber = $this->funnelSubscriber->next_sequence;
|
||||
|
||||
if ($this->funnelSubscriber->next_sequence_item) {
|
||||
$nextSequenceNumber = $this->funnelSubscriber->next_sequence_item->sequence;
|
||||
}
|
||||
|
||||
if (!$nextSequenceNumber) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return FunnelSequence::orderBy('sequence', 'ASC')
|
||||
->where('funnel_id', $this->funnel->id)
|
||||
->where('sequence', '>=', $nextSequenceNumber)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('parent_id')
|
||||
->orWhere('parent_id', '0');
|
||||
})
|
||||
->get();
|
||||
}
|
||||
|
||||
private function queryFromStart()
|
||||
{
|
||||
$nextSequenceNumber = $this->funnelSubscriber ? $this->funnelSubscriber->next_sequence : null;
|
||||
|
||||
return FunnelSequence::orderBy('sequence', 'ASC')
|
||||
->where('funnel_id', $this->funnel->id)
|
||||
->when($nextSequenceNumber, function ($q) use ($nextSequenceNumber) {
|
||||
$q->where('sequence', '>=', $nextSequenceNumber);
|
||||
})
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-pass classification of sequences into immediate, next, benchmark, and end categories.
|
||||
*
|
||||
* @param \Illuminate\Support\Collection $sequences
|
||||
* @param bool $isInChild Whether we're inside a conditional child block
|
||||
* @param bool &$inWaitTimes Tracks whether a wait-time action has been encountered
|
||||
*/
|
||||
private function classifySequences($sequences, $isInChild, &$inWaitTimes)
|
||||
{
|
||||
$firstSequence = $sequences[0];
|
||||
$conditionalBlock = false;
|
||||
$hasEndSequence = false;
|
||||
$immediateSequences = [];
|
||||
|
||||
foreach ($sequences as $sequence) {
|
||||
if ($this->requiredBenchMark || $conditionalBlock || $hasEndSequence) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip orphaned child sequences when processing at parent level
|
||||
if (!$isInChild && $sequence->parent_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($sequence->action_name == 'fluentcrm_wait_times' && !$inWaitTimes) {
|
||||
$inWaitTimes = true;
|
||||
$funnelSubId = $this->funnelSubscriber ? $this->funnelSubscriber->id : null;
|
||||
$seconds = FunnelHelper::getCurrentDelayInSeconds($sequence->settings, $sequence, $funnelSubId);
|
||||
$this->nextSequenceExecutionTime = gmdate('Y-m-d H:i:s', current_time('timestamp') + $seconds);
|
||||
}
|
||||
|
||||
if ($sequence->type == 'benchmark') {
|
||||
if (Arr::get($sequence->settings, 'type') == 'required') {
|
||||
if (!$this->funnelSubscriber || !apply_filters('fluent_crm/benchmark_already_asserted_' . $sequence->action_name, false, $sequence, $this->funnelSubscriber)) {
|
||||
$this->requiredBenchMark = $sequence;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($sequence->type == 'conditional') {
|
||||
$conditionalBlock = $sequence;
|
||||
}
|
||||
|
||||
if ($sequence->c_delay == $firstSequence->c_delay) {
|
||||
$immediateSequences[] = $sequence;
|
||||
if ($sequence->action_name == 'end_this_funnel') {
|
||||
$hasEndSequence = true;
|
||||
}
|
||||
} else {
|
||||
if (!$this->nextSequence || $sequence->c_delay < $this->nextSequence->c_delay) {
|
||||
$this->hasNext = true;
|
||||
$this->nextSequence = $sequence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($conditionalBlock) {
|
||||
$this->hasNext = true;
|
||||
}
|
||||
|
||||
$this->immediateSequences = array_merge($this->immediateSequences, $immediateSequences);
|
||||
$this->hasEndSequence = $hasEndSequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a child block is exhausted, find the next top-level sequences after the parent conditional.
|
||||
*/
|
||||
private function handleChildToParentTransition($inWaitTimes)
|
||||
{
|
||||
if (!$this->lastSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parentSequence = FunnelSequence::where('id', $this->lastSequence->parent_id)->first();
|
||||
|
||||
if (!$parentSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sequences = FunnelSequence::where('funnel_id', $this->funnel->id)
|
||||
->where('sequence', '>', $parentSequence->sequence)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('parent_id')
|
||||
->orWhere('parent_id', '0');
|
||||
})
|
||||
->orderBy('sequence', 'ASC')
|
||||
->get();
|
||||
|
||||
if ($sequences->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a wait time was already applied inside the child block,
|
||||
// schedule the first parent sequence with that delay
|
||||
if ($inWaitTimes) {
|
||||
$this->hasNext = true;
|
||||
$this->nextSequence = $sequences[0];
|
||||
if ($this->nextSequenceExecutionTime) {
|
||||
$this->nextSequence->execution_date_time = $this->nextSequenceExecutionTime;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Classify the parent-level sequences using the same logic
|
||||
$this->classifySequences($sequences, false, $inWaitTimes);
|
||||
}
|
||||
|
||||
public function getCurrentSequences()
|
||||
{
|
||||
return $this->immediateSequences;
|
||||
}
|
||||
|
||||
public function getNextSequence()
|
||||
{
|
||||
return $this->nextSequence;
|
||||
}
|
||||
|
||||
public function hasNext()
|
||||
{
|
||||
return $this->hasNext || !!$this->nextSequence;
|
||||
}
|
||||
|
||||
public function getRequiredBenchmark()
|
||||
{
|
||||
return $this->requiredBenchMark;
|
||||
}
|
||||
|
||||
public function hasSequences()
|
||||
{
|
||||
return !!$this->requiredBenchMark || !!$this->immediateSequences;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel;
|
||||
|
||||
class StaticTemplates
|
||||
{
|
||||
public static function get()
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => 1,
|
||||
'label' => __('Welcome Email', 'fluent-crm'),
|
||||
'description' => __('Send a welcome email to new subscribers', 'fluent-crm'),
|
||||
'category' => 'email',
|
||||
'icon' => 'el-icon-message',
|
||||
'disabled' => false,
|
||||
'depends_on' => ['crm', 'email'],
|
||||
'ribbon' => __('Free', 'fluent-crm'),
|
||||
'funnel_data' => [
|
||||
"id" => 20,
|
||||
"type" => "funnels",
|
||||
"title" => "List Applied (Created at 2024-07-03)",
|
||||
"trigger_name" => "fluentcrm_contact_added_to_lists",
|
||||
"status" => "published",
|
||||
"conditions" => [
|
||||
"run_multiple" => "no",
|
||||
],
|
||||
"settings" => [
|
||||
"lists" => [
|
||||
],
|
||||
"select_type" => "any",
|
||||
],
|
||||
"created_by" => "1",
|
||||
"created_at" => "2024-07-03 11:10:24",
|
||||
"updated_at" => "2024-07-03 17:56:07",
|
||||
"settingsFields" => [
|
||||
"title" => __("List Applied", 'fluent-crm'),
|
||||
"sub_title" => __("This will run when selected lists have been applied to a contact", 'fluent-crm'),
|
||||
"fields" => [
|
||||
"lists" => [
|
||||
"type" => "option_selectors",
|
||||
"option_key" => "lists",
|
||||
"is_multiple" => true,
|
||||
"label" => __("Select Lists", 'fluent-crm'),
|
||||
"placeholder" => __("Select List", 'fluent-crm'),
|
||||
"creatable" => true,
|
||||
],
|
||||
"select_type" => [
|
||||
"label" => __("Run When", 'fluent-crm'),
|
||||
"type" => "radio",
|
||||
"options" => [
|
||||
[
|
||||
"id" => "any",
|
||||
"title" => __("contact added in any of the selected lists", 'fluent-crm'),
|
||||
],
|
||||
[
|
||||
"id" => "all",
|
||||
"title" => __("contact added in all of the selected lists", 'fluent-crm'),
|
||||
],
|
||||
],
|
||||
"dependency" => [
|
||||
"depends_on" => "lists",
|
||||
"operator" => "!=",
|
||||
"value" => [
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
"conditionFields" => [
|
||||
"run_multiple" => [
|
||||
"type" => "yes_no_check",
|
||||
"label" => "",
|
||||
"check_label" => __("Restart the Automation Multiple times for a contact for this event. (Only enable if you want to restart automation for the same contact)", 'fluent-crm'),
|
||||
"inline_help" => __("If you enable, then it will restart the automation for a contact if the contact already in the automation. Otherwise, It will just skip if already exist", 'fluent-crm'),
|
||||
],
|
||||
],
|
||||
"sequences" => [
|
||||
[
|
||||
"id" => 21,
|
||||
"funnel_id" => "20",
|
||||
"parent_id" => "0",
|
||||
"action_name" => "add_contact_to_company",
|
||||
"condition_type" => null,
|
||||
"type" => "action",
|
||||
"title" => __("Apply Company", 'fluent-crm'),
|
||||
"description" => __("Add contact to the selected company", 'fluent-crm'),
|
||||
"status" => "published",
|
||||
"conditions" => [
|
||||
],
|
||||
"settings" => [
|
||||
"company" => null,
|
||||
],
|
||||
"note" => null,
|
||||
"delay" => "0",
|
||||
"c_delay" => "0",
|
||||
"sequence" => "1",
|
||||
"created_by" => "1",
|
||||
"created_at" => "2024-07-03 17:55:55",
|
||||
"updated_at" => "2024-07-03 17:56:07",
|
||||
],
|
||||
[
|
||||
"id" => 22,
|
||||
"funnel_id" => "20",
|
||||
"parent_id" => "0",
|
||||
"action_name" => "fluentcrm_wait_times",
|
||||
"condition_type" => null,
|
||||
"type" => "action",
|
||||
"title" => __("Wait X Days/Hours", 'fluent-crm'),
|
||||
"description" => __("Wait defined timespan before execute the next action", 'fluent-crm'),
|
||||
"status" => "published",
|
||||
"conditions" => [
|
||||
],
|
||||
"settings" => [
|
||||
"wait_type" => "unit_wait",
|
||||
"wait_time_amount" => 0,
|
||||
"wait_time_unit" => "days",
|
||||
"is_timestamp_wait" => "",
|
||||
"wait_date_time" => "",
|
||||
"to_day" => [
|
||||
],
|
||||
"to_day_time" => "",
|
||||
],
|
||||
"note" => null,
|
||||
"delay" => "0",
|
||||
"c_delay" => "0",
|
||||
"sequence" => "2",
|
||||
"created_by" => "1",
|
||||
"created_at" => "2024-07-03 17:56:01",
|
||||
"updated_at" => "2024-07-03 17:56:07",
|
||||
],
|
||||
],
|
||||
"site_hash" => "74401346bac500b8b0e449bdc75b2255",
|
||||
"export_date" => "2024-07-03 11:56:26",
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Triggers;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentForm\App\Modules\Form\FormFieldsParser;
|
||||
use FluentForm\App\Services\FormBuilder\ShortCodeParser;
|
||||
|
||||
class FluentFormSubmissionTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
if (!defined('FLUENTFORM')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$this->actionArgNum = 3;
|
||||
$this->triggerName = 'fluentform_submission_inserted';
|
||||
$this->priority = 25;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'category' => __('Fluent Forms', 'fluent-crm'),
|
||||
'label' => __('New Form Submission (Fluent Forms)', 'fluent-crm'),
|
||||
'description' => __('This Funnel will be initiated when a new form submission has been submitted', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-fluentforms',
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'form_id' => '',
|
||||
'primary_fields' => [
|
||||
'first_name' => '',
|
||||
'last_name' => '',
|
||||
'email' => ''
|
||||
],
|
||||
'other_fields' => [
|
||||
[
|
||||
'field_key' => '',
|
||||
'field_value' => ''
|
||||
]
|
||||
],
|
||||
'subscription_status' => 'subscribed'
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
$valueOptions = $this->getValueOptions($funnel);
|
||||
|
||||
$formId = Arr::get($funnel->settings, 'form_id');
|
||||
|
||||
$subtitle = __('This Funnel will be initiated when a new form submission has been submitted.', 'fluent-crm');
|
||||
|
||||
if ($formId) {
|
||||
$subtitle .= ' Use shortcode <b> [fluentform id="' . $formId . '"] </b> to show the form in your WordPress page/posts. <a target="_blank" href="' . admin_url('admin.php?page=fluent_forms&route=editor&form_id=' . $formId) . '">Edit The Form</a>';
|
||||
}
|
||||
|
||||
$secondaryFields = apply_filters('fluent_crm/fluentform_other_map_fields',
|
||||
FunnelHelper::getSecondaryContactFieldMaps()
|
||||
);
|
||||
|
||||
return [
|
||||
'title' => __('New Fluent Forms Submission Funnel', 'fluent-crm'),
|
||||
'sub_title' => $subtitle,
|
||||
'fields' => [
|
||||
'form_id' => [
|
||||
'type' => 'reload_field_selection',
|
||||
'label' => __('Select your form', 'fluent-crm'),
|
||||
'options' => $this->getForms($funnel)
|
||||
],
|
||||
'primary_fields' => [
|
||||
'label' => __('Map Primary Data', 'fluent-crm'),
|
||||
'type' => 'form-group-mapper',
|
||||
'value_options' => $valueOptions,
|
||||
'local_label' => __('Contact Field (CRM)', 'fluent-crm'),
|
||||
'remote_label' => __('Form Field', 'fluent-crm'),
|
||||
'fields' => FunnelHelper::getPrimaryContactFieldMaps()
|
||||
],
|
||||
'other_fields' => [
|
||||
'label' => __('Map Other Data', 'fluent-crm'),
|
||||
'type' => 'form-many-drop-down-mapper',
|
||||
'value_options' => $valueOptions,
|
||||
'local_label' => __('Select Contact Property', 'fluent-crm'),
|
||||
'remote_label' => __('Select Form Field', 'fluent-crm'),
|
||||
'local_placeholder' => __('Select Contact Property', 'fluent-crm'),
|
||||
'remote_placeholder' => __('Select Form Property', 'fluent-crm'),
|
||||
'fields' => $secondaryFields
|
||||
],
|
||||
'subscription_status' => [
|
||||
'type' => 'select',
|
||||
'is_multiple' => false,
|
||||
'options' => [
|
||||
[
|
||||
'id' => 'subscribed',
|
||||
'title' => 'Subscribed'
|
||||
],
|
||||
[
|
||||
'id' => 'pending',
|
||||
'title' => 'Pending'
|
||||
]
|
||||
],
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm')
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>An Automated double-optin email will be sent if the contact is new or not subscribed already</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
protected function getForms($funnel)
|
||||
{
|
||||
return fluentCrmDb()->table('fluentform_forms')
|
||||
->select('id', 'title')
|
||||
->orderBy('id', 'DESC')
|
||||
->get();
|
||||
}
|
||||
|
||||
protected function getValueOptions($funnel)
|
||||
{
|
||||
$formId = Arr::get($funnel->settings, 'form_id');
|
||||
if (!$formId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$form = fluentCrmDb()->table('fluentform_forms')
|
||||
->find($formId);
|
||||
|
||||
if(!$form) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$formFields = FormFieldsParser::getShortCodeInputs(
|
||||
$form, [
|
||||
'admin_label'
|
||||
]);
|
||||
|
||||
$formattedInputs = [];
|
||||
|
||||
foreach ($formFields as $inputKey => $input) {
|
||||
$formattedInputs[] = [
|
||||
'id' => '{inputs.' . $inputKey . '}',
|
||||
'title' => $input['admin_label']
|
||||
];
|
||||
}
|
||||
|
||||
return $formattedInputs;
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'run_only_one' => 'yes'
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
'run_only_one' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Run this automation only once per contact. If unchecked then it will over-write existing flow', 'fluent-crm'),
|
||||
'help' => __('If you enable this then this will run only once per customer otherwise, It will delete the existing automation flow and start new', 'fluent-crm'),
|
||||
'options' => FunnelHelper::getUpdateOptions()
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
$insertId = $originalArgs[0];
|
||||
$formData = $originalArgs[1];
|
||||
$form = $originalArgs[2];
|
||||
|
||||
$processedValues = $funnel->settings;
|
||||
|
||||
if (Arr::get($processedValues, 'form_id') != $form->id) {
|
||||
return; // not our form
|
||||
}
|
||||
|
||||
$processedValues['primary_fields']['ip'] = '{submission.ip}';
|
||||
|
||||
$processedValues = ShortCodeParser::parse($processedValues, $insertId, $formData);
|
||||
|
||||
if (!is_email(Arr::get($processedValues, 'primary_fields.email'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = Arr::get($processedValues, 'primary_fields', []);
|
||||
|
||||
$subscriberData['custom_values'] = [];
|
||||
|
||||
foreach (Arr::get($processedValues, 'other_fields', []) as $otherField) {
|
||||
if (!empty($otherField['field_key']) && !empty($otherField['field_value'])) {
|
||||
$key = $otherField['field_key'];
|
||||
if (strpos($key, '.') !== false) {
|
||||
$subscriberData['custom_values'][str_replace('custom.', '', $key)] = $otherField['field_value'];
|
||||
} else {
|
||||
$subscriberData[$key] = $otherField['field_value'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $subscriberData);
|
||||
|
||||
$willProcess = apply_filters('fluentcrm_funnel_will_process_' . $this->triggerName, $willProcess, $funnel, $subscriberData, $originalArgs);
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData['status'] = $processedValues['subscription_status'];
|
||||
|
||||
$entry = fluentFormApi('submissions')->find($insertId);
|
||||
|
||||
if ($entry && $entry->status == 'confirmed') {
|
||||
$subscriberData['status'] = 'subscribed';
|
||||
}
|
||||
|
||||
if (!empty($subscriberData['country'])) {
|
||||
$country = FunnelHelper::getCountryShortName($subscriberData['country']);
|
||||
if ($country) {
|
||||
$subscriberData['country'] = $country;
|
||||
} else {
|
||||
unset($subscriberData['country']);
|
||||
}
|
||||
}
|
||||
|
||||
(new FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $insertId,
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
private function isProcessable($funnel, $subscriberData)
|
||||
{
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
if ($subscriber && FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id)) {
|
||||
$conditions = $funnel->conditions;
|
||||
// check update_type
|
||||
$runMultiple = Arr::get($conditions, 'run_only_one') == 'no';
|
||||
|
||||
// if run multiple then delete
|
||||
if ($runMultiple) {
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
}
|
||||
|
||||
return $runMultiple;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Triggers;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class FluentFormSubscriptionCancelledTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
if (!defined('FLUENTFORM')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$this->actionArgNum = 3;
|
||||
$this->triggerName = 'fluentform/subscription_payment_canceled';
|
||||
$this->priority = 25;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'category' => __('Fluent Forms', 'fluent-crm'),
|
||||
'label' => __('Subscription Cancelled (Fluent Forms)', 'fluent-crm'),
|
||||
'description' => __('This Funnel will be initiated when a subscription is cancelled via Fluent Forms.', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-fluentforms',
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'subscription_status' => 'subscribed'
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
return [
|
||||
'title' => __('Subscription Cancelled Funnel', 'fluent-crm'),
|
||||
'sub_title' => __('This Funnel will be initiated when a subscription is cancelled via Fluent Forms.', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'subscription_status' => [
|
||||
'type' => 'select',
|
||||
'is_multiple' => false,
|
||||
'options' => [
|
||||
[
|
||||
'id' => 'subscribed',
|
||||
'title' => __('Subscribed', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'id' => 'pending',
|
||||
'title' => __('Pending', 'fluent-crm')
|
||||
]
|
||||
],
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm')
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => sprintf('<b>%s</b>', __('An automated double-opt-in email will be sent if the contact is new or not already subscribed.', 'fluent-crm')),
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
protected function getForms($funnel)
|
||||
{
|
||||
return fluentCrmDb()->table('fluentform_forms')
|
||||
->select('id', 'title')
|
||||
->orderBy('id', 'DESC')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'form_ids' => []
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
'form_ids' => [
|
||||
'type' => 'multi-select',
|
||||
'label' => __('Target Forms', 'fluent-crm'),
|
||||
'options' => $this->getForms($funnel),
|
||||
'inline_help' => __('Keep it blank to run for any form', 'fluent-crm')
|
||||
],
|
||||
'run_multiple' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Restart the Automation Multiple times for a contact for this event. (Only enable if you want to restart automation for the same contact)', 'fluent-crm'),
|
||||
'inline_help' => __('If you enable, then it will restart the automation for a contact if the contact already in the automation. Otherwise, It will just skip if already exist', 'fluent-crm')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
[$subscription, $submission, $vendorData] = $originalArgs;
|
||||
|
||||
$userId = $submission->user_id;
|
||||
|
||||
$subscriberData = FunnelHelper::prepareUserData($userId);
|
||||
|
||||
if (empty($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $subscription, $subscriberData);
|
||||
|
||||
$willProcess = apply_filters('fluentcrm_funnel_will_process_' . $this->triggerName, $willProcess, $funnel, $subscriberData, $originalArgs);
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
|
||||
|
||||
$subscriberData['status'] = $subscriberData['subscription_status'];
|
||||
unset($subscriberData['subscription_status']);
|
||||
|
||||
(new FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $subscription->id
|
||||
]);
|
||||
}
|
||||
|
||||
private function isProcessable($funnel, $subscription, $subscriberData)
|
||||
{
|
||||
$conditions = $funnel->conditions;
|
||||
// check the Form Ids
|
||||
if ($conditions['form_ids']) {
|
||||
if (!in_array($subscription->form_id, $conditions['form_ids'])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
|
||||
// check run_only_one
|
||||
if ($subscriber && FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id)) {
|
||||
$multipleRun = Arr::get($conditions, 'run_multiple') == 'yes';
|
||||
if ($multipleRun) {
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
namespace FluentCrm\App\Services\Funnel\Triggers;
|
||||
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentForm\App\Modules\Form\FormFieldsParser;
|
||||
use FluentForm\App\Services\FormBuilder\ShortCodeParser;
|
||||
|
||||
class FluentFormSubscriptionPaymentReceivedTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
if (!defined('FLUENTFORM')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->actionArgNum = 2;
|
||||
$this->triggerName = 'fluentform/subscription_payment_active';
|
||||
$this->priority = 25;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'category' => __('Fluent Forms', 'fluent-crm'),
|
||||
'label' => __('Subscription Payment Received (Fluent Forms)', 'fluent-crm'),
|
||||
'description' => __('This Funnel will be initiated when a subscription payment is successfully received through Fluent Forms.', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-fluentforms',
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'form_id' => '',
|
||||
'primary_fields' => [
|
||||
'first_name' => '',
|
||||
'last_name' => '',
|
||||
'email' => ''
|
||||
],
|
||||
'other_fields' => [
|
||||
[
|
||||
'field_key' => '',
|
||||
'field_value' => ''
|
||||
]
|
||||
],
|
||||
'subscription_status' => 'subscribed'
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
$valueOptions = $this->getValueOptions($funnel);
|
||||
$secondaryFields = apply_filters('fluent_crm/fluentform_other_map_fields',
|
||||
FunnelHelper::getSecondaryContactFieldMaps()
|
||||
);
|
||||
|
||||
return [
|
||||
'title' => __('Subscription Payment Received', 'fluent-crm'),
|
||||
'sub_title' => __('This Funnel will be initiated when a subscription payment is successfully received through Fluent Forms.', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'form_id' => [
|
||||
'type' => 'reload_field_selection',
|
||||
'label' => __('Select your form', 'fluent-crm'),
|
||||
'options' => $this->getForms($funnel)
|
||||
],
|
||||
'primary_fields' => [
|
||||
'label' => __('Map Primary Data', 'fluent-crm'),
|
||||
'type' => 'form-group-mapper',
|
||||
'value_options' => $valueOptions,
|
||||
'local_label' => __('Contact Field (CRM)', 'fluent-crm'),
|
||||
'remote_label' => __('Form Field', 'fluent-crm'),
|
||||
'fields' => FunnelHelper::getPrimaryContactFieldMaps()
|
||||
],
|
||||
'other_fields' => [
|
||||
'label' => __('Map Other Data', 'fluent-crm'),
|
||||
'type' => 'form-many-drop-down-mapper',
|
||||
'value_options' => $valueOptions,
|
||||
'local_label' => __('Select Contact Property', 'fluent-crm'),
|
||||
'remote_label' => __('Select Form Field', 'fluent-crm'),
|
||||
'local_placeholder' => __('Select Contact Property', 'fluent-crm'),
|
||||
'remote_placeholder' => __('Select Form Property', 'fluent-crm'),
|
||||
'fields' => $secondaryFields
|
||||
],
|
||||
'subscription_status' => [
|
||||
'type' => 'select',
|
||||
'is_multiple' => false,
|
||||
'options' => [
|
||||
[
|
||||
'id' => 'subscribed',
|
||||
'title' => 'Subscribed'
|
||||
],
|
||||
[
|
||||
'id' => 'pending',
|
||||
'title' => 'Pending'
|
||||
]
|
||||
],
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm')
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>An Automated double-optin email will be sent if the contact is new or not subscribed already</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
protected function getValueOptions($funnel)
|
||||
{
|
||||
$formId = Arr::get($funnel->settings, 'form_id');
|
||||
if (!$formId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$form = fluentCrmDb()->table('fluentform_forms')
|
||||
->find($formId);
|
||||
|
||||
if(!$form) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$formFields = FormFieldsParser::getShortCodeInputs(
|
||||
$form, [
|
||||
'admin_label'
|
||||
]);
|
||||
|
||||
$formattedInputs = [];
|
||||
|
||||
foreach ($formFields as $inputKey => $input) {
|
||||
$formattedInputs[] = [
|
||||
'id' => '{inputs.' . $inputKey . '}',
|
||||
'title' => $input['admin_label']
|
||||
];
|
||||
}
|
||||
|
||||
return $formattedInputs;
|
||||
}
|
||||
|
||||
protected function getForms($funnel)
|
||||
{
|
||||
return fluentCrmDb()->table('fluentform_forms')
|
||||
->select('id', 'title')
|
||||
->orderBy('id', 'DESC')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'run_only_one' => 'yes'
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
'run_only_one' => [
|
||||
'type' => 'yes_no_check',
|
||||
'label' => '',
|
||||
'check_label' => __('Run this automation only once per contact. If unchecked then it will over-write existing flow', 'fluent-crm'),
|
||||
'help' => __('If you enable this then this will run only once per customer otherwise, It will delete the existing automation flow and start new', 'fluent-crm'),
|
||||
'options' => FunnelHelper::getUpdateOptions()
|
||||
],
|
||||
];
|
||||
}
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
[$submission, $formResponse] = $originalArgs;
|
||||
$formData = $formResponse->response;
|
||||
|
||||
$processedValues = $funnel->settings;
|
||||
$insertId = $submission->submission_id;
|
||||
|
||||
if (Arr::get($processedValues, 'form_id') != $formResponse->form_id) {
|
||||
return; // not our form
|
||||
}
|
||||
|
||||
$processedValues = ShortCodeParser::parse($processedValues, $insertId, $formData);
|
||||
|
||||
$subscriberData = Arr::get($processedValues, 'primary_fields', []);
|
||||
$subscriberData['custom_values'] = $this->processOtherFields(Arr::get($processedValues, 'other_fields', []));
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $subscriberData);
|
||||
|
||||
$willProcess = apply_filters('fluentcrm_funnel_will_process_' . $this->triggerName, $willProcess, $funnel, $subscriberData, $originalArgs);
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
$subscriberData['status'] = $processedValues['subscription_status'];
|
||||
|
||||
(new FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $insertId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function processOtherFields(array $otherFields)
|
||||
{
|
||||
$customValues = [];
|
||||
foreach ($otherFields as $otherField) {
|
||||
if (!empty($otherField['field_key']) && !empty($otherField['field_value'])) {
|
||||
$key = $otherField['field_key'];
|
||||
if (strpos($key, '.') !== false) {
|
||||
$customValues[str_replace('custom.', '', $key)] = $otherField['field_value'];
|
||||
} else {
|
||||
$customValues[$key] = $otherField['field_value'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $customValues;
|
||||
}
|
||||
|
||||
private function isProcessable($funnel, $subscriberData)
|
||||
{
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
if ($subscriber && FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id)) {
|
||||
$conditions = $funnel->conditions;
|
||||
// check update_type
|
||||
$runMultiple = Arr::get($conditions, 'run_only_one') == 'no';
|
||||
|
||||
// if run multiple then delete
|
||||
if ($runMultiple) {
|
||||
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
|
||||
}
|
||||
|
||||
return $runMultiple;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Funnel\Triggers;
|
||||
|
||||
use FluentCrm\App\Services\Funnel\BaseTrigger;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class UserRegistrationTrigger extends BaseTrigger
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->triggerName = 'user_register';
|
||||
$this->priority = 10;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getTrigger()
|
||||
{
|
||||
return [
|
||||
'category' => __('WordPress Triggers', 'fluent-crm'),
|
||||
'label' => __('New User Sign Up', 'fluent-crm'),
|
||||
'description' => __('This Funnel will be initiated when a new user has been registered in your site', 'fluent-crm'),
|
||||
'icon' => 'fc-icon-wp_new_user_signup',
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelSettingsDefaults()
|
||||
{
|
||||
return [
|
||||
'subscription_status' => 'subscribed'
|
||||
];
|
||||
}
|
||||
|
||||
public function getSettingsFields($funnel)
|
||||
{
|
||||
return [
|
||||
'title' => __('New User Sign Up Funnel', 'fluent-crm'),
|
||||
'sub_title' => __('This Funnel will be initiated when a new user has been registered in your site', 'fluent-crm'),
|
||||
'fields' => [
|
||||
'subscription_status' => [
|
||||
'type' => 'option_selectors',
|
||||
'option_key' => 'editable_statuses',
|
||||
'is_multiple' => false,
|
||||
'label' => __('Subscription Status', 'fluent-crm'),
|
||||
'placeholder' => __('Select Status', 'fluent-crm')
|
||||
],
|
||||
'subscription_status_info' => [
|
||||
'type' => 'html',
|
||||
'info' => '<b>'.__('An Automated double-optin email will be sent for new subscribers', 'fluent-crm').'</b>',
|
||||
'dependency' => [
|
||||
'depends_on' => 'subscription_status',
|
||||
'operator' => '=',
|
||||
'value' => 'pending'
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunnelConditionDefaults($funnel)
|
||||
{
|
||||
return [
|
||||
'update_type' => 'update', // skip_all_actions, skip_update_if_exist
|
||||
'user_roles' => []
|
||||
];
|
||||
}
|
||||
|
||||
public function getConditionFields($funnel)
|
||||
{
|
||||
return [
|
||||
'update_type' => [
|
||||
'type' => 'radio',
|
||||
'label' => __('If Contact Already Exist?', 'fluent-crm'),
|
||||
'help' => __('Please specify what will happen if the subscriber already exist in the database', 'fluent-crm'),
|
||||
'options' => FunnelHelper::getUpdateOptions()
|
||||
],
|
||||
'user_roles' => [
|
||||
'type' => 'multi-select',
|
||||
'is_multiple' => true,
|
||||
'label' => __('Targeted User Roles', 'fluent-crm'),
|
||||
'help' => __('Select which roles registration will run this automation Funnel', 'fluent-crm'),
|
||||
'placeholder' => __('Select Roles', 'fluent-crm'),
|
||||
'options' => FunnelHelper::getUserRoles(),
|
||||
'inline_help' => __('Leave blank to run for all user roles', 'fluent-crm')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function handle($funnel, $originalArgs)
|
||||
{
|
||||
$userId = $originalArgs[0];
|
||||
$subscriberData = FunnelHelper::prepareUserData($userId);
|
||||
if (empty($subscriberData['email'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$willProcess = $this->isProcessable($funnel, $subscriberData);
|
||||
$willProcess = apply_filters('fluentcrm_funnel_will_process_' . $this->triggerName, $willProcess, $funnel, $subscriberData, $originalArgs);
|
||||
if (!$willProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
|
||||
$subscriberData['status'] = $subscriberData['subscription_status'];
|
||||
unset($subscriberData['subscription_status']);
|
||||
|
||||
(new FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
|
||||
'source_trigger_name' => $this->triggerName,
|
||||
'source_ref_id' => $userId
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
private function isProcessable($funnel, $subscriberData)
|
||||
{
|
||||
$user = get_user_by('ID', $subscriberData['user_id']);
|
||||
|
||||
$conditions = $funnel->conditions;
|
||||
// check update_type
|
||||
$updateType = Arr::get($conditions, 'update_type');
|
||||
|
||||
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
|
||||
if ($updateType == 'skip_all_if_exist' && $subscriber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check run_only_one
|
||||
if ($subscriber && FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check user roles
|
||||
if ($roles = Arr::get($conditions, 'user_roles', [])) {
|
||||
$userRoles = array_values($user->roles);
|
||||
return !!array_intersect($userRoles, $roles);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,512 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Html;
|
||||
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class FormElementBuilder
|
||||
{
|
||||
/**
|
||||
* Per-request guards so each library / the initializer is enqueued at most
|
||||
* once, even when renderFields() recurses into nested containers or multiple
|
||||
* forms render on the same page. Assets are enqueued lazily from the field
|
||||
* render methods that actually need them, so a basic form (text/email/etc.)
|
||||
* loads no extra JS/CSS.
|
||||
*/
|
||||
private static $initEnqueued = false;
|
||||
private static $datePickerEnqueued = false;
|
||||
private static $multiSelectEnqueued = false;
|
||||
|
||||
public function renderFields($fields, $print = false)
|
||||
{
|
||||
$html = '';
|
||||
foreach ($fields as $field) {
|
||||
$html .= $this->renderField($field);
|
||||
}
|
||||
if ($print) {
|
||||
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function renderField($field)
|
||||
{
|
||||
$type = Arr::get($field, 'type');
|
||||
if ($type == 'container') {
|
||||
return $this->renderContainer($field);
|
||||
}
|
||||
|
||||
if ($type == 'raw_html') {
|
||||
return (string) Arr::get($field, 'html');
|
||||
}
|
||||
|
||||
if ($type == 'hidden') {
|
||||
$atts = $this->buildAttributes($field['atts']);
|
||||
return '<input type="hidden" ' . $atts . '"/>';
|
||||
}
|
||||
|
||||
$inputHtml = '';
|
||||
|
||||
if ($type == 'input') {
|
||||
$inputHtml = $this->renderInput($field);
|
||||
} else if ($type == 'select') {
|
||||
$inputHtml = $this->renderSelect($field);
|
||||
} else if ($type == 'select-multi') {
|
||||
$inputHtml = $this->renderMultiSelect($field);
|
||||
} else if ($type == 'radio') {
|
||||
$inputHtml = $this->renderRadio($field);
|
||||
} else if ($type == 'checkboxes') {
|
||||
$inputHtml = $this->renderCheckboxes($field);
|
||||
} else if ($type == 'date') {
|
||||
$inputHtml = $this->renderDate($field);
|
||||
} else if ($type == 'textarea') {
|
||||
$inputHtml = $this->renderTextarea($field);
|
||||
} else if ($type == 'number') {
|
||||
$inputHtml = $this->renderInput($field);
|
||||
} else if ($type == 'custom_date') {
|
||||
$inputHtml = $this->renderDatePicker($field);
|
||||
} else if ($type == 'custom_date_time') {
|
||||
$inputHtml = $this->renderDateTimePicker($field);
|
||||
} else if ($type == 'date_dropdowns') {
|
||||
$inputHtml = $this->renderDateDropdowns($field);
|
||||
}
|
||||
|
||||
return $this->renderLabel($field, $inputHtml);
|
||||
}
|
||||
|
||||
public function renderSelect($field)
|
||||
{
|
||||
$atts = $this->buildAttributes([
|
||||
'id' => Arr::get($field, 'id'),
|
||||
'name' => Arr::get($field, 'name'),
|
||||
]);
|
||||
|
||||
$html = '<select ' . $atts . '>';
|
||||
|
||||
if ($placeholder = Arr::get($field, 'placeholder')) {
|
||||
$selected = $field['value'] ? '' : 'selected';
|
||||
$html .= '<option ' . $selected . ' value="">' . esc_html($placeholder) . '</option>';
|
||||
}
|
||||
|
||||
foreach ($field['options'] as $key => $label) {
|
||||
$selected = ($key == $field['value']) ? 'selected' : '';
|
||||
$html .= '<option ' . $selected . ' value="' . esc_html($key) . '">' . esc_html($label) . '</option>';
|
||||
}
|
||||
|
||||
$html .= '</select>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function renderRadio($field)
|
||||
{
|
||||
$name = $field['name'];
|
||||
$html = '<div class="fc_radio_buttons">';
|
||||
|
||||
foreach ($field['options'] as $key => $label) {
|
||||
$attributes = [
|
||||
'type' => 'radio',
|
||||
'name' => $name,
|
||||
'value' => $key
|
||||
];
|
||||
|
||||
if ($key == $field['value']) {
|
||||
$attributes['checked'] = true;
|
||||
}
|
||||
|
||||
$html .= '<label class="fc_radio_item">';
|
||||
$html .= '<input ' . $this->buildAttributes($attributes) . ' /> ' . esc_html($label);
|
||||
$html .= '</label>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function renderCheckboxes($field)
|
||||
{
|
||||
$name = $field['name'];
|
||||
$options = $field['options'];
|
||||
$selectedValues = (array) $field['value'];
|
||||
|
||||
$html = '<div class="fc_checkboxes">';
|
||||
|
||||
$isAssoc = array_keys($options) !== range(0, count($options) - 1);
|
||||
|
||||
foreach ($options as $optionKey => $list_option) {
|
||||
$optionValue = $isAssoc ? $optionKey : $list_option;
|
||||
|
||||
$attrbutes = [
|
||||
'type' => 'checkbox',
|
||||
'name' => esc_attr($name) . '[]',
|
||||
'value' => esc_attr($optionValue)
|
||||
];
|
||||
|
||||
if (in_array($optionValue, $selectedValues)) {
|
||||
$attrbutes['checked'] = true;
|
||||
}
|
||||
|
||||
$html .= '<label class="fc_list_items">';
|
||||
$html .= '<input ' . $this->buildAttributes($attrbutes) . ' /> ' . esc_html($list_option);
|
||||
$html .= '</label>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function renderContainer($field)
|
||||
{
|
||||
$innerFields = Arr::get($field, 'fields', []);
|
||||
if (!$innerFields) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = '<div class="fc_field_container ' . esc_attr(Arr::get($field, 'container_class')) . '">';
|
||||
$html .= $this->renderFields($innerFields);
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function renderLabel($field, $innerHtml = '')
|
||||
{
|
||||
$containerClass = 'fc_field fc_field_' . $field['name'] . ' fc_field_' . $field['type'];
|
||||
|
||||
if ($givenClass = Arr::get($field, 'container_class')) {
|
||||
$containerClass .= ' ' . $givenClass;
|
||||
}
|
||||
|
||||
$html = '<div class="' . esc_attr($containerClass) . '">';
|
||||
|
||||
if ($label = Arr::get($field, 'label')) {
|
||||
if ($id = Arr::get($field, 'id')) {
|
||||
// date_dropdowns: label must target first visible select, not the hidden input
|
||||
$forId = (Arr::get($field, 'type') === 'date_dropdowns') ? $id . '_day' : $id;
|
||||
$labelAtts = $this->buildAttributes([
|
||||
'for' => $forId
|
||||
]);
|
||||
} else {
|
||||
$labelAtts = '';
|
||||
}
|
||||
$required = '';
|
||||
if (Arr::get($field, 'required')) {
|
||||
$required = ' <span class="fc_required_mark">*</span>';
|
||||
}
|
||||
|
||||
$html .= '<label ' . $labelAtts . '>' . esc_html($label) . $required . '</label>';
|
||||
}
|
||||
|
||||
return $html . $innerHtml . '</div>';
|
||||
}
|
||||
|
||||
public function renderInput($field)
|
||||
{
|
||||
$atts = Arr::get($field, 'atts', []);
|
||||
$atts['name'] = $field['name'];
|
||||
|
||||
if (!empty($field['required'])) {
|
||||
$atts['required'] = true;
|
||||
}
|
||||
|
||||
if (!empty($field['id'])) {
|
||||
$atts['id'] = $field['id'];
|
||||
}
|
||||
|
||||
if (empty($atts['class'])) {
|
||||
$atts['class'] = 'fc_input_control';
|
||||
} else {
|
||||
$atts['class'] .= ' fc_input_control';
|
||||
}
|
||||
|
||||
$atts['value'] = $field['value'];
|
||||
|
||||
return '<input ' . $this->buildAttributes($atts) . '/>';
|
||||
}
|
||||
|
||||
public function renderDate($field)
|
||||
{
|
||||
// Legacy combodate field. combodate requires moment.js (not bundled on
|
||||
// public pages), so custom_date (flatpickr) is preferred for new fields.
|
||||
// Kept for backward compatibility.
|
||||
wp_enqueue_script('combodate', FLUENTCRM_PLUGIN_URL . 'assets/libs/combodate/combodate.js', ['jquery'], '1.0.7', true);
|
||||
$this->ensureFieldInitializer();
|
||||
|
||||
// Tag the input so the externalized initializer (form-fields.js) can find
|
||||
// it, instead of emitting an inline <script> that would require
|
||||
// `script-src 'unsafe-inline'`.
|
||||
$atts = Arr::get($field, 'atts', []);
|
||||
$atts['class'] = trim((isset($atts['class']) ? $atts['class'] : '') . ' fc-js-combodate');
|
||||
$field['atts'] = $atts;
|
||||
|
||||
return $this->renderInput($field);
|
||||
}
|
||||
|
||||
public function renderDateDropdowns($field)
|
||||
{
|
||||
$id = esc_attr(Arr::get($field, 'id', 'fc_date'));
|
||||
$name = esc_attr(Arr::get($field, 'name', 'date'));
|
||||
$value = Arr::get($field, 'value', '');
|
||||
|
||||
$selectedDay = '';
|
||||
$selectedMonth = '';
|
||||
$selectedYear = '';
|
||||
|
||||
if ($value && preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $value, $m)) {
|
||||
$selectedYear = (int) $m[1];
|
||||
$selectedMonth = (int) $m[2];
|
||||
$selectedDay = (int) $m[3];
|
||||
}
|
||||
|
||||
$currentYear = (int) gmdate('Y');
|
||||
$minYear = $currentYear - 120;
|
||||
|
||||
// Day select (id for label for= so clicking label focuses first visible control)
|
||||
$html = '<div class="fc_date_dropdowns" id="' . $id . '_wrap">';
|
||||
$html .= '<select class="fc_date_dropdown fc_date_day" id="' . $id . '_day" data-role="day">';
|
||||
$html .= '<option value="">' . esc_html__('Day', 'fluent-crm') . '</option>';
|
||||
for ($d = 1; $d <= 31; $d++) {
|
||||
$sel = ($selectedDay === $d) ? ' selected' : '';
|
||||
$html .= '<option value="' . $d . '"' . $sel . '>' . sprintf('%02d', $d) . '</option>';
|
||||
}
|
||||
$html .= '</select>';
|
||||
|
||||
// Month select
|
||||
$html .= '<select class="fc_date_dropdown fc_date_month" data-role="month">';
|
||||
$html .= '<option value="">' . esc_html__('Month', 'fluent-crm') . '</option>';
|
||||
for ($mo = 1; $mo <= 12; $mo++) {
|
||||
$sel = ($selectedMonth === $mo) ? ' selected' : '';
|
||||
$html .= '<option value="' . $mo . '"' . $sel . '>' . sprintf('%02d', $mo) . '</option>';
|
||||
}
|
||||
$html .= '</select>';
|
||||
|
||||
// Year select
|
||||
$html .= '<select class="fc_date_dropdown fc_date_year" data-role="year">';
|
||||
$html .= '<option value="">' . esc_html__('Year', 'fluent-crm') . '</option>';
|
||||
for ($y = $currentYear; $y >= $minYear; $y--) {
|
||||
$sel = ($selectedYear === $y) ? ' selected' : '';
|
||||
$html .= '<option value="' . $y . '"' . $sel . '>' . $y . '</option>';
|
||||
}
|
||||
$html .= '</select>';
|
||||
|
||||
$html .= '<input type="hidden" name="' . $name . '" id="' . $id . '" value="' . esc_attr($value) . '" />';
|
||||
$html .= '</div>';
|
||||
|
||||
// Day/month/year sync is handled by form-fields.js, which scans for
|
||||
// `.fc_date_dropdowns` wrappers on the page — no inline <script> required.
|
||||
// No third-party library needed here (vanilla JS).
|
||||
$this->ensureFieldInitializer();
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function renderButton($field)
|
||||
{
|
||||
$containerClass = 'fc_field fc_field_btn';
|
||||
|
||||
if ($givenClass = Arr::get($field, 'container_class')) {
|
||||
$containerClass .= ' ' . $givenClass;
|
||||
}
|
||||
|
||||
$html = '<div class="' . esc_attr($containerClass) . '">';
|
||||
|
||||
$label = Arr::get($field, 'btn_text');
|
||||
|
||||
$html .= '<button ' . $this->buildAttributes(Arr::get($field, 'atts', [])) . '>' . wp_kses_post($label) . '</button>';
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
|
||||
}
|
||||
|
||||
private function buildAttributes($atts)
|
||||
{
|
||||
$items = [];
|
||||
|
||||
$singleKeys = ['required', 'disabled', 'readonly', 'checked', 'selected', 'multiple'];
|
||||
|
||||
foreach ($atts as $key => $value) {
|
||||
if ($value && in_array($key, $singleKeys)) {
|
||||
$items[] = $key;
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = esc_attr($key) . '="' . esc_html($value) . '"';
|
||||
}
|
||||
|
||||
return implode(' ', $items);
|
||||
}
|
||||
|
||||
private function renderTextarea($field)
|
||||
{
|
||||
$atts = $this->buildAttributes($field['atts']);
|
||||
$value = Arr::get($field, 'value', '');
|
||||
return '<textarea ' . $atts . '>' . esc_html($value) . '</textarea>';
|
||||
}
|
||||
public function renderMultiSelect($field)
|
||||
{
|
||||
$this->enqueueMultiSelectAssets();
|
||||
$this->ensureFieldInitializer();
|
||||
|
||||
$name = Arr::get($field, 'name');
|
||||
if (substr($name, -2) !== '[]') {
|
||||
$name .= '[]';
|
||||
}
|
||||
|
||||
$atts = $this->buildAttributes([
|
||||
'id' => Arr::get($field, 'id'),
|
||||
'name' => $name,
|
||||
'multiple' => true,
|
||||
'class' => 'fc_input_control fc-js-choice-multi',
|
||||
'data-placeholder' => esc_attr(Arr::get($field, 'placeholder', __('Select options', 'fluent-crm'))),
|
||||
]);
|
||||
|
||||
$values = is_array($field['value']) ? $field['value'] : [];
|
||||
|
||||
$html = '<select ' . $atts . '>';
|
||||
if ($placeholder = Arr::get($field, 'placeholder')) {
|
||||
$html .= '<option value="" disabled hidden>' . esc_html($placeholder) . '</option>';
|
||||
}
|
||||
foreach ($field['options'] as $key => $label) {
|
||||
$selected = in_array($key, $values) ? ' selected="selected"' : '';
|
||||
$html .= '<option value="' . esc_attr($key) . '" ' . $selected . '>' . esc_html($label) . '</option>';
|
||||
}
|
||||
$html .= '</select>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function renderDateTimePicker($field)
|
||||
{
|
||||
$this->enqueueDatePickerAssets();
|
||||
$this->ensureFieldInitializer();
|
||||
|
||||
// Build attributes for the input field
|
||||
$atts = $this->buildAttributes([
|
||||
'type' => 'text',
|
||||
'id' => Arr::get($field, 'id'),
|
||||
'name' => Arr::get($field, 'name'),
|
||||
'value' => esc_attr($field['value'] ?? ''),
|
||||
'class' => 'fc_input_control fc-js-datetime-picker',
|
||||
'placeholder' => esc_attr(Arr::get($field, 'placeholder', 'Select date & time')),
|
||||
]);
|
||||
|
||||
// Return the input element
|
||||
return '<input ' . $atts . ' />';
|
||||
}
|
||||
public function renderDatePicker($field)
|
||||
{
|
||||
$this->enqueueDatePickerAssets();
|
||||
$this->ensureFieldInitializer();
|
||||
|
||||
// Build attributes for the input field
|
||||
$atts = $this->buildAttributes([
|
||||
'type' => 'text',
|
||||
'id' => Arr::get($field, 'id'),
|
||||
'name' => Arr::get($field, 'name'),
|
||||
'value' => esc_attr($field['value'] ?? ''),
|
||||
'class' => 'fc_input_control fc-js-date-picker',
|
||||
'placeholder' => esc_attr(Arr::get($field, 'placeholder', 'Select date')),
|
||||
]);
|
||||
|
||||
// Return the input element
|
||||
return '<input ' . $atts . ' />';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the externalized field initializer + its i18n, once per request.
|
||||
* Called lazily by the field render methods that need JS (date / datetime /
|
||||
* multi-select / combodate / date_dropdowns). form-fields.js depends only on
|
||||
* jQuery and feature-detects flatpickr/Choices at runtime (deferred to
|
||||
* DOMContentLoaded), so it works whether or not those libs were enqueued —
|
||||
* letting a date_dropdowns-only form skip flatpickr/Choices entirely.
|
||||
*
|
||||
* Enqueued directly rather than via the wp_enqueue_scripts hook: forms
|
||||
* render inside the_content (shortcode) or a standalone page body, by which
|
||||
* point wp_enqueue_scripts has already fired. Direct enqueue lets WordPress
|
||||
* print these as late footer items (scripts via the footer queue, styles via
|
||||
* print_late_styles()).
|
||||
*/
|
||||
private function ensureFieldInitializer()
|
||||
{
|
||||
if (self::$initEnqueued) {
|
||||
return;
|
||||
}
|
||||
self::$initEnqueued = true;
|
||||
|
||||
// Source lives in resources/libs/fluentcrm/form-fields.js; the build
|
||||
// copies resources/libs -> assets/libs (viteStaticCopy). Routed through
|
||||
// the script loader so a site owner's CSP/nonce plugin can filter the
|
||||
// tag — no inline <script>.
|
||||
wp_enqueue_script(
|
||||
'fluentcrm_form_fields',
|
||||
FLUENTCRM_PLUGIN_URL . 'assets/libs/fluentcrm/form-fields.js',
|
||||
['jquery'],
|
||||
FLUENTCRM_PLUGIN_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
// Translatable strings passed via the loader (nonce-able).
|
||||
wp_localize_script('fluentcrm_form_fields', 'fluentcrmFormFields', [
|
||||
'i18n' => [
|
||||
'selectOptions' => __('Select options', 'fluent-crm'),
|
||||
'noResults' => __('No matching options found', 'fluent-crm'),
|
||||
'noChoices' => __('No options available', 'fluent-crm'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function enqueueDatePickerAssets()
|
||||
{
|
||||
if (self::$datePickerEnqueued) {
|
||||
return;
|
||||
}
|
||||
self::$datePickerEnqueued = true;
|
||||
|
||||
wp_enqueue_style('flatpickr-css', FLUENTCRM_PLUGIN_URL . 'assets/libs/flatpickr/flatpickr.min.css', [], '4.6.13');
|
||||
wp_enqueue_script('flatpickr-js', FLUENTCRM_PLUGIN_URL . 'assets/libs/flatpickr/flatpickr.min.js', [], '4.6.13', true);
|
||||
}
|
||||
|
||||
private function enqueueMultiSelectAssets()
|
||||
{
|
||||
if (self::$multiSelectEnqueued) {
|
||||
return;
|
||||
}
|
||||
self::$multiSelectEnqueued = true;
|
||||
|
||||
wp_enqueue_style('choices-css', FLUENTCRM_PLUGIN_URL . 'assets/libs/choices/choices.min.css', [], '10.0.0');
|
||||
wp_enqueue_script('choices-js', FLUENTCRM_PLUGIN_URL . 'assets/libs/choices/choices.min.js', ['jquery'], '10.0.0', true);
|
||||
|
||||
// Dropdown positioning overrides. Printed on wp_footer (still pending
|
||||
// when forms render) instead of wp_head (already fired). NOTE: this is
|
||||
// still an inline <style> — a `style-src` concern tracked separately
|
||||
// from the `script-src` work.
|
||||
add_action('wp_footer', function () {
|
||||
?>
|
||||
<style>
|
||||
.fc_field_select-multi {
|
||||
overflow: inherit !important;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
/* Position the choices container as relative */
|
||||
.choices {
|
||||
position: relative !important;
|
||||
overflow: inherit !important;
|
||||
}
|
||||
|
||||
/* Make dropdown absolute so it doesn't take space */
|
||||
.choices__list.choices__list--dropdown {
|
||||
position: absolute !important;
|
||||
top: 100% !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
z-index: 999999999999999999 !important;
|
||||
}
|
||||
</style>
|
||||
<?php
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Html;
|
||||
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class TableBuilder
|
||||
{
|
||||
private $header = [];
|
||||
|
||||
private $rows = [];
|
||||
|
||||
public $tableClass = 'fc_horizontal_table';
|
||||
|
||||
public function setHeader($header)
|
||||
{
|
||||
$this->header = $header;
|
||||
}
|
||||
|
||||
public function addRow($row)
|
||||
{
|
||||
$this->rows[] = $row;
|
||||
}
|
||||
|
||||
public function getHtml()
|
||||
{
|
||||
if(!$this->header) {
|
||||
return '';
|
||||
}
|
||||
$table = '<table class="'.esc_html($this->tableClass).'"><thead><tr>';
|
||||
foreach ($this->header as $key => $heading) {
|
||||
$table .= '<th class="fc_head_'.esc_attr($key).'">'.wp_kses_post($heading).'</th>';
|
||||
}
|
||||
$table .= '</tr></thead><tbody>';
|
||||
|
||||
foreach ($this->rows as $row) {
|
||||
$table .= '<tr>';
|
||||
foreach ($this->header as $key => $heading) {
|
||||
$table .= '<td>'.Arr::get($row, $key).'</td>';
|
||||
}
|
||||
$table .= '</tr>';
|
||||
}
|
||||
$table .= '</tbody></table>';
|
||||
|
||||
return $table;
|
||||
|
||||
}
|
||||
|
||||
public function printHtml()
|
||||
{
|
||||
echo $this->getHtml(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
|
||||
public function reset()
|
||||
{
|
||||
$this->header = [];
|
||||
$this->rows = [];
|
||||
$this->tableClass = 'fc_horizontal_table';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs;
|
||||
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\Framework\Support\Str;
|
||||
|
||||
class ConditionAssessor
|
||||
{
|
||||
public static function matchAllGroups($groups, $inputs, $matchType = 'match_any')
|
||||
{
|
||||
$hasConditionMet = true;
|
||||
foreach ($groups as $group) {
|
||||
$hasConditionMet = self::evaluate($group, $inputs);
|
||||
if ($hasConditionMet && $matchType == 'match_any') {
|
||||
return true;
|
||||
}
|
||||
if ($matchType === 'match_all' && !$hasConditionMet) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $hasConditionMet;
|
||||
}
|
||||
|
||||
public static function evaluate($conditionGroup, $inputs)
|
||||
{
|
||||
$hasConditionMet = true;
|
||||
$conditionals = Arr::get($conditionGroup, 'conditions', []);
|
||||
|
||||
if ($conditionals) {
|
||||
$toMatch = Arr::get($conditionGroup, 'match_type');
|
||||
foreach ($conditionals as $conditional) {
|
||||
$hasConditionMet = static::assess($conditional, $inputs);
|
||||
|
||||
if ($hasConditionMet && $toMatch == 'match_any') {
|
||||
return true;
|
||||
}
|
||||
if ($toMatch === 'match_all' && !$hasConditionMet) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $hasConditionMet;
|
||||
}
|
||||
|
||||
public static function matchAllConditions($conditions, $inputs)
|
||||
{
|
||||
foreach ($conditions as $condition) {
|
||||
if (!static::assess($condition, $inputs)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function assess($conditional, $inputs)
|
||||
{
|
||||
if ($conditional['data_key']) {
|
||||
$sourceValue = Arr::get($inputs, $conditional['data_key']);
|
||||
$dataValue = $conditional['data_value'];
|
||||
|
||||
if ($conditional['data_key'] === 'order_status' && !Str::startsWith($sourceValue, 'wc-')) {
|
||||
$sourceValue = 'wc-' . $sourceValue;
|
||||
}
|
||||
|
||||
switch ($conditional['operator']) {
|
||||
case '=':
|
||||
if (is_array($sourceValue)) {
|
||||
return in_array($dataValue, $sourceValue);
|
||||
}
|
||||
return $sourceValue == $dataValue;
|
||||
break;
|
||||
case '!=':
|
||||
if (is_array($sourceValue)) {
|
||||
return !in_array($dataValue, $sourceValue);
|
||||
}
|
||||
return $sourceValue != $dataValue;
|
||||
break;
|
||||
case '>':
|
||||
return $sourceValue > $dataValue;
|
||||
break;
|
||||
case '<':
|
||||
return $sourceValue < $dataValue;
|
||||
break;
|
||||
case '>=':
|
||||
return $sourceValue >= $dataValue;
|
||||
break;
|
||||
case '<=':
|
||||
return $sourceValue <= $dataValue;
|
||||
break;
|
||||
case 'startsWith':
|
||||
return Str::startsWith($sourceValue, $dataValue);
|
||||
break;
|
||||
case 'endsWith':
|
||||
return Str::endsWith($sourceValue, $dataValue);
|
||||
break;
|
||||
case 'contains':
|
||||
|
||||
$sourceValue = strtolower($sourceValue);
|
||||
if (is_string($dataValue)) {
|
||||
$dataValue = strtolower($dataValue);
|
||||
}
|
||||
|
||||
return Str::contains($sourceValue, $dataValue);
|
||||
break;
|
||||
case 'doNotContains':
|
||||
case 'not_contains':
|
||||
$sourceValue = strtolower($sourceValue);
|
||||
if (is_string($dataValue)) {
|
||||
$dataValue = strtolower($dataValue);
|
||||
}
|
||||
return !Str::contains($sourceValue, $dataValue);
|
||||
break;
|
||||
case 'length_equal':
|
||||
if (is_array($sourceValue)) {
|
||||
return count($sourceValue) == $dataValue;
|
||||
}
|
||||
$sourceValue = strval($sourceValue);
|
||||
return strlen($sourceValue) == $dataValue;
|
||||
break;
|
||||
case 'length_less_than':
|
||||
if (is_array($sourceValue)) {
|
||||
return count($sourceValue) < $dataValue;
|
||||
}
|
||||
$sourceValue = strval($sourceValue);
|
||||
return strlen($sourceValue) < $dataValue;
|
||||
break;
|
||||
case 'length_greater_than':
|
||||
if (is_array($sourceValue)) {
|
||||
return count($sourceValue) > $dataValue;
|
||||
}
|
||||
$sourceValue = strval($sourceValue);
|
||||
return strlen($sourceValue) > $dataValue;
|
||||
break;
|
||||
case 'match_all':
|
||||
// Exact match (order-independent)
|
||||
$sourceValue = (array) $sourceValue;
|
||||
$dataValue = (array) $dataValue;
|
||||
sort($sourceValue);
|
||||
sort($dataValue);
|
||||
$dataValue = array_map('intval', $dataValue);
|
||||
return $sourceValue == $dataValue;
|
||||
case 'in_all':
|
||||
$sourceValue = (array)$sourceValue;
|
||||
$dataValue = (array)$dataValue;
|
||||
sort($sourceValue);
|
||||
sort($dataValue);
|
||||
$dataValue = array_map('intval', $dataValue);
|
||||
return empty(array_diff($dataValue, $sourceValue));
|
||||
case 'match_none_of':
|
||||
case 'not_in_all':
|
||||
$sourceValue = (array)$sourceValue;
|
||||
$dataValue = (array)$dataValue;
|
||||
return !(array_intersect($sourceValue, $dataValue));
|
||||
break;
|
||||
case 'in':
|
||||
$dataValue = (array)$dataValue;
|
||||
if (!is_array($sourceValue)) {
|
||||
$sourceValue = array_map('trim', explode(',', $sourceValue));
|
||||
}
|
||||
return !!array_intersect($sourceValue, $dataValue);
|
||||
case 'not_in':
|
||||
$dataValue = (array)$dataValue;
|
||||
if (is_array($sourceValue)) {
|
||||
return !(array_intersect($sourceValue, $dataValue));
|
||||
}
|
||||
return !in_array($sourceValue, $dataValue);
|
||||
case 'before':
|
||||
if (!$sourceValue || $sourceValue == '0000-00-00') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtotime($sourceValue) < strtotime($dataValue);
|
||||
case 'after':
|
||||
if (!$sourceValue || $sourceValue == '0000-00-00') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtotime($sourceValue) > strtotime($dataValue);
|
||||
case 'date_equal':
|
||||
if (!$sourceValue || $sourceValue == '0000-00-00') {
|
||||
return false;
|
||||
}
|
||||
return gmdate('Ymd', strtotime($sourceValue)) == gmdate('Ymd', strtotime($dataValue));
|
||||
case 'days_before':
|
||||
if (!$sourceValue || $sourceValue == '0000-00-00') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtotime($sourceValue) < strtotime("-{$dataValue} days", current_time('timestamp'));
|
||||
case 'days_within':
|
||||
if (!$sourceValue || $sourceValue == '0000-00-00') {
|
||||
return false;
|
||||
}
|
||||
return strtotime($sourceValue) > strtotime("-{$dataValue} days", current_time('timestamp'));
|
||||
case 'is_null':
|
||||
return !$sourceValue;
|
||||
case 'not_null':
|
||||
return !!$sourceValue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Emogrifier;
|
||||
|
||||
|
||||
class Emogrifier
|
||||
{
|
||||
private $html = '';
|
||||
|
||||
private $disableInvisibleNode = false;
|
||||
|
||||
public function __construct($html)
|
||||
{
|
||||
$this->html = (string) $html;
|
||||
}
|
||||
|
||||
public function disableInvisibleNodeRemoval()
|
||||
{
|
||||
$this->disableInvisibleNode = true;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function emogrify()
|
||||
{
|
||||
if (!class_exists('\FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\CssToInlineStyles')) {
|
||||
require_once __DIR__ . '/scoped-vendor/autoload.php';
|
||||
}
|
||||
|
||||
return $this->handleTijsVerkoyen();
|
||||
}
|
||||
|
||||
private function handleTijsVerkoyen()
|
||||
{
|
||||
$css = '';
|
||||
$html = $this->html;
|
||||
|
||||
if (preg_match_all('/<style[^>]*>(.*?)<\/style>/si', $html, $matches)) {
|
||||
$css = implode("\n", $matches[1]);
|
||||
$html = preg_replace('/<style[^>]*>.*?<\/style>/si', '', $html);
|
||||
}
|
||||
|
||||
// Preserve @media queries — TijsVerkoyen strips them during CSS processing
|
||||
// but email clients like Apple Mail and Gmail support them for responsive layouts.
|
||||
// Regex handles both spaced (@media screen) and minified (@media(max-width:600px)) forms.
|
||||
$mediaBlocks = '';
|
||||
if (preg_match_all('/@media\s*[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/s', $css, $mediaMatches)) {
|
||||
$mediaBlocks = implode("\n", $mediaMatches[0]);
|
||||
}
|
||||
|
||||
$inliner = new \FluentEmogrifier\Vendor\TijsVerkoyen\CssToInlineStyles\CssToInlineStyles();
|
||||
$result = $inliner->convert($html, $css);
|
||||
|
||||
// Re-inject @media blocks for responsive email support
|
||||
if ($mediaBlocks) {
|
||||
$styleTag = '<style type="text/css">' . $mediaBlocks . '</style>';
|
||||
|
||||
if (stripos($result, '</head>') !== false) {
|
||||
$result = str_ireplace('</head>', $styleTag . '</head>', $result);
|
||||
} elseif (stripos($result, '<body') !== false) {
|
||||
$result = preg_replace('/<body/i', $styleTag . '<body', $result, 1);
|
||||
} else {
|
||||
$result = $styleTag . $result;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/vendor/
|
||||
@@ -0,0 +1,37 @@
|
||||
# Emogrifier Scoped Dependency Build
|
||||
|
||||
This directory is the source of truth for rebuilding the scoped `symfony/css-selector` bundle used by:
|
||||
|
||||
- `app/Services/Libs/Emogrifier/scoped-vendor/symfony/css-selector`
|
||||
|
||||
## Why this exists
|
||||
|
||||
A direct vendor snapshot can drift to PHP-8-only packages. This build workspace pins a PHP-7.4-compatible dependency set and regenerates the scoped bundle deterministically.
|
||||
|
||||
## Rebuild command
|
||||
|
||||
Run from repository root:
|
||||
|
||||
```bash
|
||||
bash app/Services/Libs/Emogrifier/build/rebuild_scoped_css_selector.sh
|
||||
```
|
||||
|
||||
The rebuild script will:
|
||||
|
||||
1. Install locked dependencies from `build/composer.lock`.
|
||||
2. Copy `symfony/css-selector` into `scoped-vendor/symfony/css-selector`.
|
||||
3. Prefix namespaces to `FluentEmogrifier\\Vendor\\Symfony\\Component\\CssSelector`.
|
||||
4. Replace `str_contains()` usage with a local compatibility helper.
|
||||
5. Update scoped composer metadata (`installed.json`, `installed.php`, `platform_check.php`).
|
||||
|
||||
## Updating css-selector
|
||||
|
||||
1. Edit `build/composer.json` version constraints.
|
||||
2. Run:
|
||||
|
||||
```bash
|
||||
composer update symfony/css-selector --working-dir app/Services/Libs/Emogrifier/build
|
||||
bash app/Services/Libs/Emogrifier/build/rebuild_scoped_css_selector.sh
|
||||
```
|
||||
|
||||
3. Verify with PHP 7.4 and 8.x before release.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "fluentcrm/emogrifier-build",
|
||||
"type": "project",
|
||||
"description": "Build workspace for FluentCRM Emogrifier scoped dependencies",
|
||||
"require": {
|
||||
"symfony/css-selector": "5.4.45"
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "7.4.33"
|
||||
},
|
||||
"sort-packages": true
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
composer install --no-dev --prefer-dist --no-interaction --working-dir "$SCRIPT_DIR"
|
||||
php "$SCRIPT_DIR/sync_css_selector.php"
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
const CSS_SELECTOR_PACKAGE = 'symfony/css-selector';
|
||||
|
||||
$buildDir = __DIR__;
|
||||
$emogrifierDir = dirname(__DIR__);
|
||||
$scopedVendorDir = $emogrifierDir . '/scoped-vendor';
|
||||
$sourceCssSelectorDir = $buildDir . '/vendor/symfony/css-selector';
|
||||
$targetCssSelectorDir = $scopedVendorDir . '/symfony/css-selector';
|
||||
$installedJsonPath = $scopedVendorDir . '/composer/installed.json';
|
||||
$installedPhpPath = $scopedVendorDir . '/composer/installed.php';
|
||||
$platformCheckPath = $scopedVendorDir . '/composer/platform_check.php';
|
||||
$buildLockPath = $buildDir . '/composer.lock';
|
||||
|
||||
if (!is_dir($sourceCssSelectorDir)) {
|
||||
fwrite(STDERR, "Missing {$sourceCssSelectorDir}. Run composer install in build directory first.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!file_exists($buildLockPath)) {
|
||||
fwrite(STDERR, "Missing composer.lock in build directory.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$lock = json_decode((string) file_get_contents($buildLockPath), true);
|
||||
if (!is_array($lock)) {
|
||||
fwrite(STDERR, "Could not parse build composer.lock.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$package = null;
|
||||
foreach (($lock['packages'] ?? []) as $candidate) {
|
||||
if (($candidate['name'] ?? '') === CSS_SELECTOR_PACKAGE) {
|
||||
$package = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$package) {
|
||||
fwrite(STDERR, "Could not find " . CSS_SELECTOR_PACKAGE . " in build composer.lock.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$version = (string) ($package['version'] ?? '');
|
||||
$reference = (string) ($package['source']['reference'] ?? '');
|
||||
$requirePhp = (string) ($package['require']['php'] ?? '>=7.3.0');
|
||||
$supportSource = (string) ($package['support']['source'] ?? '');
|
||||
$distUrl = (string) ($package['dist']['url'] ?? '');
|
||||
|
||||
if (!$version || !$reference) {
|
||||
fwrite(STDERR, "Missing version/reference in build lock for " . CSS_SELECTOR_PACKAGE . ".\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$normalizedVersion = preg_replace('/^v/', '', $version) . '.0';
|
||||
if (!$normalizedVersion) {
|
||||
fwrite(STDERR, "Failed to compute normalized version for {$version}.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
rrmdir($targetCssSelectorDir);
|
||||
mkdirOrFail(dirname($targetCssSelectorDir));
|
||||
copyDir($sourceCssSelectorDir, $targetCssSelectorDir);
|
||||
|
||||
removeIfExists($targetCssSelectorDir . '/README.md');
|
||||
removeIfExists($targetCssSelectorDir . '/CHANGELOG.md');
|
||||
removeIfExists($targetCssSelectorDir . '/LICENSE');
|
||||
removeIfExists($targetCssSelectorDir . '/composer.json');
|
||||
|
||||
$phpFiles = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($targetCssSelectorDir, FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($phpFiles as $file) {
|
||||
/** @var SplFileInfo $file */
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->getPathname();
|
||||
$content = (string) file_get_contents($path);
|
||||
|
||||
$content = str_replace(
|
||||
'namespace Symfony\\Component\\CssSelector',
|
||||
'namespace FluentEmogrifier\\Vendor\\Symfony\\Component\\CssSelector',
|
||||
$content
|
||||
);
|
||||
$content = str_replace(
|
||||
'use Symfony\\Component\\CssSelector\\',
|
||||
'use FluentEmogrifier\\Vendor\\Symfony\\Component\\CssSelector\\',
|
||||
$content
|
||||
);
|
||||
|
||||
$content = preg_replace(
|
||||
'/(?<![A-Za-z0-9_\\\\])str_contains\s*\(/',
|
||||
'\\FluentEmogrifier\\Vendor\\Symfony\\Component\\CssSelector\\Util\\Php74Compat::strContains(',
|
||||
$content
|
||||
);
|
||||
|
||||
file_put_contents($path, (string) $content);
|
||||
}
|
||||
|
||||
$compatDir = $targetCssSelectorDir . '/Util';
|
||||
mkdirOrFail($compatDir);
|
||||
file_put_contents($compatDir . '/Php74Compat.php', php74CompatClass());
|
||||
|
||||
updateInstalledJson($installedJsonPath, $version, $normalizedVersion, $reference, $requirePhp, $supportSource, $distUrl);
|
||||
updateInstalledPhp($installedPhpPath, $version, $normalizedVersion, $reference);
|
||||
updatePlatformCheck($platformCheckPath);
|
||||
|
||||
echo "Synced css-selector {$version} ({$reference}) into scoped-vendor.\n";
|
||||
|
||||
function mkdirOrFail(string $path): void
|
||||
{
|
||||
if (is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mkdir($path, 0777, true) && !is_dir($path)) {
|
||||
throw new RuntimeException("Failed to create directory: {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
function rrmdir(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($items as $item) {
|
||||
/** @var SplFileInfo $item */
|
||||
if ($item->isDir()) {
|
||||
rmdir($item->getPathname());
|
||||
} else {
|
||||
unlink($item->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
function copyDir(string $source, string $target): void
|
||||
{
|
||||
mkdirOrFail($target);
|
||||
|
||||
$items = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($items as $item) {
|
||||
/** @var SplFileInfo $item */
|
||||
$relative = substr($item->getPathname(), strlen($source) + 1);
|
||||
$destination = $target . '/' . $relative;
|
||||
|
||||
if ($item->isDir()) {
|
||||
mkdirOrFail($destination);
|
||||
continue;
|
||||
}
|
||||
|
||||
mkdirOrFail(dirname($destination));
|
||||
copy($item->getPathname(), $destination);
|
||||
}
|
||||
}
|
||||
|
||||
function removeIfExists(string $path): void
|
||||
{
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
function php74CompatClass(): string
|
||||
{
|
||||
return <<<'PHP'
|
||||
<?php
|
||||
|
||||
namespace FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Util;
|
||||
|
||||
/**
|
||||
* Minimal compatibility helpers for keeping scoped css-selector PHP 7.4-safe.
|
||||
*/
|
||||
final class Php74Compat
|
||||
{
|
||||
public static function strContains(string $haystack, string $needle): bool
|
||||
{
|
||||
if ($needle === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return strpos($haystack, $needle) !== false;
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
function updateInstalledJson(
|
||||
string $path,
|
||||
string $version,
|
||||
string $normalizedVersion,
|
||||
string $reference,
|
||||
string $requirePhp,
|
||||
string $supportSource,
|
||||
string $distUrl
|
||||
): void {
|
||||
$json = json_decode((string) file_get_contents($path), true);
|
||||
if (!is_array($json) || !isset($json['packages']) || !is_array($json['packages'])) {
|
||||
throw new RuntimeException("Could not parse installed.json: {$path}");
|
||||
}
|
||||
|
||||
foreach ($json['packages'] as &$package) {
|
||||
if (($package['name'] ?? '') !== CSS_SELECTOR_PACKAGE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$package['version'] = $version;
|
||||
$package['version_normalized'] = $normalizedVersion;
|
||||
|
||||
if (isset($package['source']) && is_array($package['source'])) {
|
||||
$package['source']['reference'] = $reference;
|
||||
}
|
||||
|
||||
if (isset($package['dist']) && is_array($package['dist'])) {
|
||||
$package['dist']['reference'] = $reference;
|
||||
if ($distUrl) {
|
||||
$package['dist']['url'] = $distUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($package['require']) || !is_array($package['require'])) {
|
||||
$package['require'] = [];
|
||||
}
|
||||
$package['require']['php'] = $requirePhp;
|
||||
|
||||
if ($supportSource) {
|
||||
if (!isset($package['support']) || !is_array($package['support'])) {
|
||||
$package['support'] = [];
|
||||
}
|
||||
$package['support']['source'] = $supportSource;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
unset($package);
|
||||
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL
|
||||
);
|
||||
}
|
||||
|
||||
function updateInstalledPhp(string $path, string $version, string $normalizedVersion, string $reference): void
|
||||
{
|
||||
$content = (string) file_get_contents($path);
|
||||
$content = preg_replace_callback(
|
||||
"/'symfony\\/css-selector' => array\\([\\s\\S]*?\\n\\s*\\),/",
|
||||
function (array $matches) use ($version, $normalizedVersion, $reference): string {
|
||||
$block = $matches[0];
|
||||
$block = preg_replace("/'pretty_version' => '[^']+'/", "'pretty_version' => '{$version}'", $block, 1);
|
||||
$block = preg_replace("/'version' => '[^']+'/", "'version' => '{$normalizedVersion}'", $block, 1);
|
||||
$block = preg_replace("/'reference' => '[^']+'/", "'reference' => '{$reference}'", $block, 1);
|
||||
return (string) $block;
|
||||
},
|
||||
$content,
|
||||
1
|
||||
);
|
||||
|
||||
file_put_contents($path, $content);
|
||||
}
|
||||
|
||||
function updatePlatformCheck(string $path): void
|
||||
{
|
||||
$content = (string) file_get_contents($path);
|
||||
$content = preg_replace('/PHP_VERSION_ID >= \d+/', 'PHP_VERSION_ID >= 70300', $content);
|
||||
$content = preg_replace('/>= [0-9]+\.[0-9]+\.[0-9]+/', '>= 7.3.0', $content);
|
||||
file_put_contents($path, $content);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
// Silence is golden.
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
// autoload.php @generated by Composer (init/static class names hardened — see note)
|
||||
|
||||
// FluentCart and several other Fluent plugins ship this exact same php-scoped
|
||||
// Emogrifier bundle. Composer's generated autoloader-init class name is identical
|
||||
// across those copies, and because require_once dedupes by file path (not class
|
||||
// name) the second plugin to load fataled with:
|
||||
// "Cannot declare class FluentEmogComposerAutoloaderInit... already in use".
|
||||
// To stay collision-proof, this plugin's init/static classes are namespaced with
|
||||
// an "Fcrm" marker (see composer/autoload_real.php + autoload_static.php) so they
|
||||
// can never clash with another plugin's copy. The shared library prefix
|
||||
// (FluentEmogrifier\Vendor\...) is intentionally left unchanged so the actual
|
||||
// inliner classes are still reused once any plugin has loaded them — see the
|
||||
// class_exists() short-circuit in ../../Emogrifier.php.
|
||||
if (!class_exists('FluentEmogComposerAutoloaderInitFcrm6ba88f1695515329cfc7e4b26033cc69', false)) {
|
||||
require_once __DIR__ . '/composer/autoload_real.php';
|
||||
}
|
||||
return FluentEmogComposerAutoloaderInitFcrm6ba88f1695515329cfc7e4b26033cc69::getLoader();
|
||||
+579
@@ -0,0 +1,579 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Composer.
|
||||
*
|
||||
* (c) Nils Adermann <naderman@naderman.de>
|
||||
* Jordi Boggiano <j.boggiano@seld.be>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Composer\Autoload;
|
||||
|
||||
/**
|
||||
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
|
||||
*
|
||||
* $loader = new \Composer\Autoload\ClassLoader();
|
||||
*
|
||||
* // register classes with namespaces
|
||||
* $loader->add('Symfony\Component', __DIR__.'/component');
|
||||
* $loader->add('Symfony', __DIR__.'/framework');
|
||||
*
|
||||
* // activate the autoloader
|
||||
* $loader->register();
|
||||
*
|
||||
* // to enable searching the include path (eg. for PEAR packages)
|
||||
* $loader->setUseIncludePath(true);
|
||||
*
|
||||
* In this example, if you try to use a class in the Symfony\Component
|
||||
* namespace or one of its children (Symfony\Component\Console for instance),
|
||||
* the autoloader will first look for the class under the component/
|
||||
* directory, and it will then fallback to the framework/ directory if not
|
||||
* found before giving up.
|
||||
*
|
||||
* This class is loosely based on the Symfony UniversalClassLoader.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
* @author Jordi Boggiano <j.boggiano@seld.be>
|
||||
* @see https://www.php-fig.org/psr/psr-0/
|
||||
* @see https://www.php-fig.org/psr/psr-4/
|
||||
*/
|
||||
class ClassLoader
|
||||
{
|
||||
/** @var \Closure(string):void */
|
||||
private static $includeFile;
|
||||
|
||||
/** @var string|null */
|
||||
private $vendorDir;
|
||||
|
||||
// PSR-4
|
||||
/**
|
||||
* @var array<string, array<string, int>>
|
||||
*/
|
||||
private $prefixLengthsPsr4 = array();
|
||||
/**
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
private $prefixDirsPsr4 = array();
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private $fallbackDirsPsr4 = array();
|
||||
|
||||
// PSR-0
|
||||
/**
|
||||
* List of PSR-0 prefixes
|
||||
*
|
||||
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
|
||||
*
|
||||
* @var array<string, array<string, list<string>>>
|
||||
*/
|
||||
private $prefixesPsr0 = array();
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private $fallbackDirsPsr0 = array();
|
||||
|
||||
/** @var bool */
|
||||
private $useIncludePath = false;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private $classMap = array();
|
||||
|
||||
/** @var bool */
|
||||
private $classMapAuthoritative = false;
|
||||
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private $missingClasses = array();
|
||||
|
||||
/** @var string|null */
|
||||
private $apcuPrefix;
|
||||
|
||||
/**
|
||||
* @var array<string, self>
|
||||
*/
|
||||
private static $registeredLoaders = array();
|
||||
|
||||
/**
|
||||
* @param string|null $vendorDir
|
||||
*/
|
||||
public function __construct($vendorDir = null)
|
||||
{
|
||||
$this->vendorDir = $vendorDir;
|
||||
self::initializeIncludeClosure();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function getPrefixes()
|
||||
{
|
||||
if (!empty($this->prefixesPsr0)) {
|
||||
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
|
||||
}
|
||||
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function getPrefixesPsr4()
|
||||
{
|
||||
return $this->prefixDirsPsr4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getFallbackDirs()
|
||||
{
|
||||
return $this->fallbackDirsPsr0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getFallbackDirsPsr4()
|
||||
{
|
||||
return $this->fallbackDirsPsr4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> Array of classname => path
|
||||
*/
|
||||
public function getClassMap()
|
||||
{
|
||||
return $this->classMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $classMap Class to filename map
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addClassMap(array $classMap)
|
||||
{
|
||||
if ($this->classMap) {
|
||||
$this->classMap = array_merge($this->classMap, $classMap);
|
||||
} else {
|
||||
$this->classMap = $classMap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-0 directories for a given prefix, either
|
||||
* appending or prepending to the ones previously set for this prefix.
|
||||
*
|
||||
* @param string $prefix The prefix
|
||||
* @param list<string>|string $paths The PSR-0 root directories
|
||||
* @param bool $prepend Whether to prepend the directories
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add($prefix, $paths, $prepend = false)
|
||||
{
|
||||
$paths = (array) $paths;
|
||||
if (!$prefix) {
|
||||
if ($prepend) {
|
||||
$this->fallbackDirsPsr0 = array_merge(
|
||||
$paths,
|
||||
$this->fallbackDirsPsr0
|
||||
);
|
||||
} else {
|
||||
$this->fallbackDirsPsr0 = array_merge(
|
||||
$this->fallbackDirsPsr0,
|
||||
$paths
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$first = $prefix[0];
|
||||
if (!isset($this->prefixesPsr0[$first][$prefix])) {
|
||||
$this->prefixesPsr0[$first][$prefix] = $paths;
|
||||
|
||||
return;
|
||||
}
|
||||
if ($prepend) {
|
||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||
$paths,
|
||||
$this->prefixesPsr0[$first][$prefix]
|
||||
);
|
||||
} else {
|
||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||
$this->prefixesPsr0[$first][$prefix],
|
||||
$paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-4 directories for a given namespace, either
|
||||
* appending or prepending to the ones previously set for this namespace.
|
||||
*
|
||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||
* @param list<string>|string $paths The PSR-4 base directories
|
||||
* @param bool $prepend Whether to prepend the directories
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addPsr4($prefix, $paths, $prepend = false)
|
||||
{
|
||||
$paths = (array) $paths;
|
||||
if (!$prefix) {
|
||||
// Register directories for the root namespace.
|
||||
if ($prepend) {
|
||||
$this->fallbackDirsPsr4 = array_merge(
|
||||
$paths,
|
||||
$this->fallbackDirsPsr4
|
||||
);
|
||||
} else {
|
||||
$this->fallbackDirsPsr4 = array_merge(
|
||||
$this->fallbackDirsPsr4,
|
||||
$paths
|
||||
);
|
||||
}
|
||||
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
|
||||
// Register directories for a new namespace.
|
||||
$length = strlen($prefix);
|
||||
if ('\\' !== $prefix[$length - 1]) {
|
||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||
}
|
||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||
$this->prefixDirsPsr4[$prefix] = $paths;
|
||||
} elseif ($prepend) {
|
||||
// Prepend directories for an already registered namespace.
|
||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||
$paths,
|
||||
$this->prefixDirsPsr4[$prefix]
|
||||
);
|
||||
} else {
|
||||
// Append directories for an already registered namespace.
|
||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||
$this->prefixDirsPsr4[$prefix],
|
||||
$paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-0 directories for a given prefix,
|
||||
* replacing any others previously set for this prefix.
|
||||
*
|
||||
* @param string $prefix The prefix
|
||||
* @param list<string>|string $paths The PSR-0 base directories
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function set($prefix, $paths)
|
||||
{
|
||||
if (!$prefix) {
|
||||
$this->fallbackDirsPsr0 = (array) $paths;
|
||||
} else {
|
||||
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-4 directories for a given namespace,
|
||||
* replacing any others previously set for this namespace.
|
||||
*
|
||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||
* @param list<string>|string $paths The PSR-4 base directories
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setPsr4($prefix, $paths)
|
||||
{
|
||||
if (!$prefix) {
|
||||
$this->fallbackDirsPsr4 = (array) $paths;
|
||||
} else {
|
||||
$length = strlen($prefix);
|
||||
if ('\\' !== $prefix[$length - 1]) {
|
||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||
}
|
||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||
$this->prefixDirsPsr4[$prefix] = (array) $paths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns on searching the include path for class files.
|
||||
*
|
||||
* @param bool $useIncludePath
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setUseIncludePath($useIncludePath)
|
||||
{
|
||||
$this->useIncludePath = $useIncludePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be used to check if the autoloader uses the include path to check
|
||||
* for classes.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getUseIncludePath()
|
||||
{
|
||||
return $this->useIncludePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns off searching the prefix and fallback directories for classes
|
||||
* that have not been registered with the class map.
|
||||
*
|
||||
* @param bool $classMapAuthoritative
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setClassMapAuthoritative($classMapAuthoritative)
|
||||
{
|
||||
$this->classMapAuthoritative = $classMapAuthoritative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should class lookup fail if not found in the current class map?
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isClassMapAuthoritative()
|
||||
{
|
||||
return $this->classMapAuthoritative;
|
||||
}
|
||||
|
||||
/**
|
||||
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
|
||||
*
|
||||
* @param string|null $apcuPrefix
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setApcuPrefix($apcuPrefix)
|
||||
{
|
||||
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The APCu prefix in use, or null if APCu caching is not enabled.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getApcuPrefix()
|
||||
{
|
||||
return $this->apcuPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers this instance as an autoloader.
|
||||
*
|
||||
* @param bool $prepend Whether to prepend the autoloader or not
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register($prepend = false)
|
||||
{
|
||||
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
|
||||
|
||||
if (null === $this->vendorDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($prepend) {
|
||||
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
|
||||
} else {
|
||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||
self::$registeredLoaders[$this->vendorDir] = $this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters this instance as an autoloader.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function unregister()
|
||||
{
|
||||
spl_autoload_unregister(array($this, 'loadClass'));
|
||||
|
||||
if (null !== $this->vendorDir) {
|
||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the given class or interface.
|
||||
*
|
||||
* @param string $class The name of the class
|
||||
* @return true|null True if loaded, null otherwise
|
||||
*/
|
||||
public function loadClass($class)
|
||||
{
|
||||
if ($file = $this->findFile($class)) {
|
||||
$includeFile = self::$includeFile;
|
||||
$includeFile($file);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the path to the file where the class is defined.
|
||||
*
|
||||
* @param string $class The name of the class
|
||||
*
|
||||
* @return string|false The path if found, false otherwise
|
||||
*/
|
||||
public function findFile($class)
|
||||
{
|
||||
// class map lookup
|
||||
if (isset($this->classMap[$class])) {
|
||||
return $this->classMap[$class];
|
||||
}
|
||||
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
|
||||
return false;
|
||||
}
|
||||
if (null !== $this->apcuPrefix) {
|
||||
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
|
||||
if ($hit) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
$file = $this->findFileWithExtension($class, '.php');
|
||||
|
||||
// Search for Hack files if we are running on HHVM
|
||||
if (false === $file && defined('HHVM_VERSION')) {
|
||||
$file = $this->findFileWithExtension($class, '.hh');
|
||||
}
|
||||
|
||||
if (null !== $this->apcuPrefix) {
|
||||
apcu_add($this->apcuPrefix.$class, $file);
|
||||
}
|
||||
|
||||
if (false === $file) {
|
||||
// Remember that this class does not exist.
|
||||
$this->missingClasses[$class] = true;
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently registered loaders keyed by their corresponding vendor directories.
|
||||
*
|
||||
* @return array<string, self>
|
||||
*/
|
||||
public static function getRegisteredLoaders()
|
||||
{
|
||||
return self::$registeredLoaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $class
|
||||
* @param string $ext
|
||||
* @return string|false
|
||||
*/
|
||||
private function findFileWithExtension($class, $ext)
|
||||
{
|
||||
// PSR-4 lookup
|
||||
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
|
||||
|
||||
$first = $class[0];
|
||||
if (isset($this->prefixLengthsPsr4[$first])) {
|
||||
$subPath = $class;
|
||||
while (false !== $lastPos = strrpos($subPath, '\\')) {
|
||||
$subPath = substr($subPath, 0, $lastPos);
|
||||
$search = $subPath . '\\';
|
||||
if (isset($this->prefixDirsPsr4[$search])) {
|
||||
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
|
||||
foreach ($this->prefixDirsPsr4[$search] as $dir) {
|
||||
if (file_exists($file = $dir . $pathEnd)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-4 fallback dirs
|
||||
foreach ($this->fallbackDirsPsr4 as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 lookup
|
||||
if (false !== $pos = strrpos($class, '\\')) {
|
||||
// namespaced class name
|
||||
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
|
||||
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
|
||||
} else {
|
||||
// PEAR-like class name
|
||||
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
|
||||
}
|
||||
|
||||
if (isset($this->prefixesPsr0[$first])) {
|
||||
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
|
||||
if (0 === strpos($class, $prefix)) {
|
||||
foreach ($dirs as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 fallback dirs
|
||||
foreach ($this->fallbackDirsPsr0 as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 include paths.
|
||||
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
private static function initializeIncludeClosure()
|
||||
{
|
||||
if (self::$includeFile !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope isolated include.
|
||||
*
|
||||
* Prevents access to $this/self from included files.
|
||||
*
|
||||
* @param string $file
|
||||
* @return void
|
||||
*/
|
||||
self::$includeFile = \Closure::bind(static function($file) {
|
||||
include $file;
|
||||
}, null, null);
|
||||
}
|
||||
}
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Composer.
|
||||
*
|
||||
* (c) Nils Adermann <naderman@naderman.de>
|
||||
* Jordi Boggiano <j.boggiano@seld.be>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Composer;
|
||||
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use Composer\Semver\VersionParser;
|
||||
|
||||
/**
|
||||
* This class is copied in every Composer installed project and available to all
|
||||
*
|
||||
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
|
||||
*
|
||||
* To require its presence, you can require `composer-runtime-api ^2.0`
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
class InstalledVersions
|
||||
{
|
||||
/**
|
||||
* @var mixed[]|null
|
||||
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
|
||||
*/
|
||||
private static $installed;
|
||||
|
||||
/**
|
||||
* @var bool|null
|
||||
*/
|
||||
private static $canGetVendors;
|
||||
|
||||
/**
|
||||
* @var array[]
|
||||
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
private static $installedByVendor = array();
|
||||
|
||||
/**
|
||||
* Returns a list of all package names which are present, either by being installed, replaced or provided
|
||||
*
|
||||
* @return string[]
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public static function getInstalledPackages()
|
||||
{
|
||||
$packages = array();
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
$packages[] = array_keys($installed['versions']);
|
||||
}
|
||||
|
||||
if (1 === \count($packages)) {
|
||||
return $packages[0];
|
||||
}
|
||||
|
||||
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all package names with a specific type e.g. 'library'
|
||||
*
|
||||
* @param string $type
|
||||
* @return string[]
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public static function getInstalledPackagesByType($type)
|
||||
{
|
||||
$packagesByType = array();
|
||||
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
foreach ($installed['versions'] as $name => $package) {
|
||||
if (isset($package['type']) && $package['type'] === $type) {
|
||||
$packagesByType[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $packagesByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given package is installed
|
||||
*
|
||||
* This also returns true if the package name is provided or replaced by another package
|
||||
*
|
||||
* @param string $packageName
|
||||
* @param bool $includeDevRequirements
|
||||
* @return bool
|
||||
*/
|
||||
public static function isInstalled($packageName, $includeDevRequirements = true)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (isset($installed['versions'][$packageName])) {
|
||||
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given package satisfies a version constraint
|
||||
*
|
||||
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
|
||||
*
|
||||
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
|
||||
*
|
||||
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
|
||||
* @param string $packageName
|
||||
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
|
||||
* @return bool
|
||||
*/
|
||||
public static function satisfies(VersionParser $parser, $packageName, $constraint)
|
||||
{
|
||||
$constraint = $parser->parseConstraints((string) $constraint);
|
||||
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
|
||||
|
||||
return $provided->matches($constraint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a version constraint representing all the range(s) which are installed for a given package
|
||||
*
|
||||
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
|
||||
* whether a given version of a package is installed, and not just whether it exists
|
||||
*
|
||||
* @param string $packageName
|
||||
* @return string Version constraint usable with composer/semver
|
||||
*/
|
||||
public static function getVersionRanges($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ranges = array();
|
||||
if (isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
|
||||
}
|
||||
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
|
||||
}
|
||||
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
|
||||
}
|
||||
if (array_key_exists('provided', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
|
||||
}
|
||||
|
||||
return implode(' || ', $ranges);
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Composer-generated file
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||
*/
|
||||
public static function getVersion($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['version'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['version'];
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Composer-generated file
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||
*/
|
||||
public static function getPrettyVersion($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['pretty_version'];
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Composer-generated file
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
|
||||
*/
|
||||
public static function getReference($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['reference'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['reference'];
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Composer-generated file
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
|
||||
*/
|
||||
public static function getInstallPath($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
|
||||
*/
|
||||
public static function getRootPackage()
|
||||
{
|
||||
$installed = self::getInstalled();
|
||||
|
||||
return $installed[0]['root'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw installed.php data for custom implementations
|
||||
*
|
||||
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
|
||||
* @return array[]
|
||||
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
|
||||
*/
|
||||
public static function getRawData()
|
||||
{
|
||||
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
|
||||
|
||||
if (null === self::$installed) {
|
||||
// only require the installed.php file if this file is loaded from its dumped location,
|
||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||
self::$installed = include __DIR__ . '/installed.php';
|
||||
} else {
|
||||
self::$installed = array();
|
||||
}
|
||||
}
|
||||
|
||||
return self::$installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw data of all installed.php which are currently loaded for custom implementations
|
||||
*
|
||||
* @return array[]
|
||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
public static function getAllRawData()
|
||||
{
|
||||
return self::getInstalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets you reload the static array from another file
|
||||
*
|
||||
* This is only useful for complex integrations in which a project needs to use
|
||||
* this class but then also needs to execute another project's autoloader in process,
|
||||
* and wants to ensure both projects have access to their version of installed.php.
|
||||
*
|
||||
* A typical case would be PHPUnit, where it would need to make sure it reads all
|
||||
* the data it needs from this class, then call reload() with
|
||||
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
|
||||
* the project in which it runs can then also use this class safely, without
|
||||
* interference between PHPUnit's dependencies and the project's dependencies.
|
||||
*
|
||||
* @param array[] $data A vendor/composer/installed.php data set
|
||||
* @return void
|
||||
*
|
||||
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
|
||||
*/
|
||||
public static function reload($data)
|
||||
{
|
||||
self::$installed = $data;
|
||||
self::$installedByVendor = array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array[]
|
||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
private static function getInstalled()
|
||||
{
|
||||
if (null === self::$canGetVendors) {
|
||||
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
|
||||
}
|
||||
|
||||
$installed = array();
|
||||
|
||||
if (self::$canGetVendors) {
|
||||
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
|
||||
if (isset(self::$installedByVendor[$vendorDir])) {
|
||||
$installed[] = self::$installedByVendor[$vendorDir];
|
||||
} elseif (is_file($vendorDir.'/composer/installed.php')) {
|
||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||
$required = require $vendorDir.'/composer/installed.php';
|
||||
$installed[] = self::$installedByVendor[$vendorDir] = $required;
|
||||
if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
|
||||
self::$installed = $installed[count($installed) - 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (null === self::$installed) {
|
||||
// only require the installed.php file if this file is loaded from its dumped location,
|
||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||
$required = require __DIR__ . '/installed.php';
|
||||
self::$installed = $required;
|
||||
} else {
|
||||
self::$installed = array();
|
||||
}
|
||||
}
|
||||
|
||||
if (self::$installed !== array()) {
|
||||
$installed[] = self::$installed;
|
||||
}
|
||||
|
||||
return $installed;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
|
||||
Copyright (c) Nils Adermann, Jordi Boggiano
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished
|
||||
to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
// autoload_classmap.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
|
||||
);
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
// autoload_namespaces.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
);
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
// autoload_psr4.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
'FluentEmogrifier\Vendor\\TijsVerkoyen\\CssToInlineStyles\\' => array($vendorDir . '/tijsverkoyen/css-to-inline-styles/src'),
|
||||
'FluentEmogrifier\Vendor\\Symfony\\Component\\CssSelector\\' => array($vendorDir . '/symfony/css-selector'),
|
||||
);
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
// autoload_real.php @generated by Composer
|
||||
|
||||
class FluentEmogComposerAutoloaderInitFcrm6ba88f1695515329cfc7e4b26033cc69
|
||||
{
|
||||
private static $loader;
|
||||
|
||||
public static function loadClassLoader($class)
|
||||
{
|
||||
if ('Composer\Autoload\ClassLoader' === $class) {
|
||||
require __DIR__ . '/ClassLoader.php';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Composer\Autoload\ClassLoader
|
||||
*/
|
||||
public static function getLoader()
|
||||
{
|
||||
if (null !== self::$loader) {
|
||||
return self::$loader;
|
||||
}
|
||||
|
||||
spl_autoload_register(array('FluentEmogComposerAutoloaderInitFcrm6ba88f1695515329cfc7e4b26033cc69', 'loadClassLoader'), true, true);
|
||||
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
|
||||
spl_autoload_unregister(array('FluentEmogComposerAutoloaderInitFcrm6ba88f1695515329cfc7e4b26033cc69', 'loadClassLoader'));
|
||||
|
||||
require __DIR__ . '/autoload_static.php';
|
||||
call_user_func(\Composer\Autoload\ComposerFluentStaticInitFcrm6ba88f1695515329cfc7e4b26033cc69::getInitializer($loader));
|
||||
|
||||
$loader->register(true);
|
||||
|
||||
return $loader;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
// autoload_static.php @generated by Composer
|
||||
|
||||
namespace Composer\Autoload;
|
||||
|
||||
class ComposerFluentStaticInitFcrm6ba88f1695515329cfc7e4b26033cc69
|
||||
{
|
||||
public static $prefixLengthsPsr4 = array (
|
||||
'F' =>
|
||||
array (
|
||||
'FluentEmogrifier\Vendor\\TijsVerkoyen\\CssToInlineStyles\\' => 55,
|
||||
'FluentEmogrifier\Vendor\\Symfony\\Component\\CssSelector\\' => 54,
|
||||
)
|
||||
);
|
||||
|
||||
public static $prefixDirsPsr4 = array (
|
||||
'FluentEmogrifier\Vendor\\TijsVerkoyen\\CssToInlineStyles\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/tijsverkoyen/css-to-inline-styles/src',
|
||||
),
|
||||
'FluentEmogrifier\Vendor\\Symfony\\Component\\CssSelector\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/css-selector',
|
||||
),
|
||||
);
|
||||
|
||||
public static $classMap = array (
|
||||
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
|
||||
);
|
||||
|
||||
public static function getInitializer(ClassLoader $loader)
|
||||
{
|
||||
return \Closure::bind(function () use ($loader) {
|
||||
$loader->prefixLengthsPsr4 = ComposerFluentStaticInitFcrm6ba88f1695515329cfc7e4b26033cc69::$prefixLengthsPsr4;
|
||||
$loader->prefixDirsPsr4 = ComposerFluentStaticInitFcrm6ba88f1695515329cfc7e4b26033cc69::$prefixDirsPsr4;
|
||||
$loader->classMap = ComposerFluentStaticInitFcrm6ba88f1695515329cfc7e4b26033cc69::$classMap;
|
||||
|
||||
}, null, ClassLoader::class);
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"name": "tijsverkoyen/css-to-inline-styles",
|
||||
"version": "v2.4.0",
|
||||
"version_normalized": "2.4.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tijsverkoyen/CssToInlineStyles.git",
|
||||
"reference": "f0292ccf0ec75843d65027214426b6b163b48b41"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41",
|
||||
"reference": "f0292ccf0ec75843d65027214426b6b163b48b41",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"ext-libxml": "*",
|
||||
"php": "^7.4 || ^8.0",
|
||||
"symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpunit/phpunit": "^8.5.21 || ^9.5.10"
|
||||
},
|
||||
"time": "2025-12-02T11:56:42+00:00",
|
||||
"type": "library",
|
||||
"installation-source": "dist",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"TijsVerkoyen\\CssToInlineStyles\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Tijs Verkoyen",
|
||||
"email": "css_to_inline_styles@verkoyen.eu",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.",
|
||||
"homepage": "https://github.com/tijsverkoyen/CssToInlineStyles",
|
||||
"support": {
|
||||
"issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues",
|
||||
"source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0"
|
||||
},
|
||||
"install-path": "../tijsverkoyen/css-to-inline-styles"
|
||||
},
|
||||
{
|
||||
"name": "symfony/css-selector",
|
||||
"version": "v5.4.45",
|
||||
"version_normalized": "5.4.45.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/css-selector.git",
|
||||
"reference": "4f7f3c35fba88146b56d0025d20ace3f3901f097"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/css-selector/zipball/4f7f3c35fba88146b56d0025d20ace3f3901f097",
|
||||
"reference": "4f7f3c35fba88146b56d0025d20ace3f3901f097",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2.5"
|
||||
},
|
||||
"time": "2024-05-31T14:57:53+00:00",
|
||||
"type": "library",
|
||||
"installation-source": "dist",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\CssSelector\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Jean-François Simon",
|
||||
"email": "jeanfrancois.simon@sensiolabs.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Converts CSS selectors to XPath expressions",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/css-selector/tree/v5.4.45"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"install-path": "../symfony/css-selector"
|
||||
}
|
||||
],
|
||||
"dev": true,
|
||||
"dev-package-names": []
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<?php return array(
|
||||
'root' => array(
|
||||
'name' => '__root__',
|
||||
'pretty_version' => 'dev-master',
|
||||
'version' => 'dev-master',
|
||||
'reference' => 'ca1f0cfca7d137d392980237881ddb455c77e351',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
'dev' => true,
|
||||
),
|
||||
'versions' => array(
|
||||
'__root__' => array(
|
||||
'pretty_version' => 'dev-master',
|
||||
'version' => 'dev-master',
|
||||
'reference' => 'ca1f0cfca7d137d392980237881ddb455c77e351',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'tijsverkoyen/css-to-inline-styles' => array(
|
||||
'pretty_version' => 'v2.4.0',
|
||||
'version' => '2.4.0.0',
|
||||
'reference' => 'f0292ccf0ec75843d65027214426b6b163b48b41',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../tijsverkoyen/css-to-inline-styles',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/css-selector' => array(
|
||||
'pretty_version' => 'v5.4.45',
|
||||
'version' => '5.4.45.0',
|
||||
'reference' => '4f7f3c35fba88146b56d0025d20ace3f3901f097',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/css-selector',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
),
|
||||
);
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
// platform_check.php @generated by Composer
|
||||
|
||||
$issues = array();
|
||||
|
||||
if (!(PHP_VERSION_ID >= 70300)) {
|
||||
$issues[] = 'Your Composer dependencies require a PHP version ">= 7.3.0". You are running ' . PHP_VERSION . '.';
|
||||
}
|
||||
|
||||
if ($issues) {
|
||||
if (!headers_sent()) {
|
||||
header('HTTP/1.1 500 Internal Server Error');
|
||||
}
|
||||
if (!ini_get('display_errors')) {
|
||||
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
|
||||
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
|
||||
} elseif (!headers_sent()) {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Composer-generated file, CLI/error output context
|
||||
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
|
||||
}
|
||||
}
|
||||
trigger_error(
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Composer-generated file, CLI/error output context
|
||||
'Composer detected issues in your platform: ' . implode(' ', $issues),
|
||||
E_USER_ERROR
|
||||
);
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace FluentEmogrifier\Vendor\Symfony\Component\CssSelector;
|
||||
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Shortcut\ClassParser;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Shortcut\ElementParser;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Shortcut\EmptyStringParser;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\Parser\Shortcut\HashParser;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Extension\HtmlExtension;
|
||||
use FluentEmogrifier\Vendor\Symfony\Component\CssSelector\XPath\Translator;
|
||||
|
||||
/**
|
||||
* CssSelectorConverter is the main entry point of the component and can convert CSS
|
||||
* selectors to XPath expressions.
|
||||
*
|
||||
* @author Christophe Coevoet <stof@notk.org>
|
||||
*/
|
||||
class CssSelectorConverter
|
||||
{
|
||||
private $translator;
|
||||
private $cache;
|
||||
|
||||
private static $xmlCache = [];
|
||||
private static $htmlCache = [];
|
||||
|
||||
/**
|
||||
* @param bool $html Whether HTML support should be enabled. Disable it for XML documents
|
||||
*/
|
||||
public function __construct(bool $html = true)
|
||||
{
|
||||
$this->translator = new Translator();
|
||||
|
||||
if ($html) {
|
||||
$this->translator->registerExtension(new HtmlExtension($this->translator));
|
||||
$this->cache = &self::$htmlCache;
|
||||
} else {
|
||||
$this->cache = &self::$xmlCache;
|
||||
}
|
||||
|
||||
$this->translator
|
||||
->registerParserShortcut(new EmptyStringParser())
|
||||
->registerParserShortcut(new ElementParser())
|
||||
->registerParserShortcut(new ClassParser())
|
||||
->registerParserShortcut(new HashParser())
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a CSS expression to its XPath equivalent.
|
||||
*
|
||||
* Optionally, a prefix can be added to the resulting XPath
|
||||
* expression with the $prefix parameter.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function toXPath(string $cssExpr, string $prefix = 'descendant-or-self::')
|
||||
{
|
||||
return $this->cache[$prefix][$cssExpr] ?? $this->cache[$prefix][$cssExpr] = $this->translator->cssToXPath($cssExpr, $prefix);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1816 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user