Initial commit

This commit is contained in:
gustavooth
2026-07-23 22:56:30 -03:00
commit 22a1006598
1816 changed files with 410742 additions and 0 deletions
@@ -0,0 +1,247 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
use FluentCrm\App\Modules\AbandonCart\Drivers\DriverManager;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
class AbCartHelper
{
public static function getSettings($useCache = true)
{
static $settings;
if ($useCache && $settings) {
return $settings;
}
$defaults = [
'enabled' => 'no',
'enabled_providers' => [],
'capture_after_minutes' => 30,
'lost_cart_days' => 10,
'cool_off_period_days' => 10,
'gdpr_consent' => 'no',
'gdpr_consent_text' => 'Your email and cart are saved so we can send you email reminders about this order. {{opt_out label="No Thanks"}}',
'disabled_user_roles' => [],
'track_add_to_cart' => 'no',
'add_to_cart_exclude_user_roles' => [],
'tags_on_cart_abandoned' => [],
'lists_on_cart_abandoned' => [],
'tags_on_cart_lost' => [],
'lists_on_cart_lost' => [],
'new_contact_status' => 'transactional',
];
// Merge provider-specific defaults from available drivers
foreach (DriverManager::getAvailable() as $driver) {
$providerDefaults = $driver->getProviderSettingsDefaults();
if ($providerDefaults) {
$defaults = array_merge($defaults, $providerDefaults);
}
}
$settings = get_option('_fc_ab_cart_settings', []);
if (is_array($settings) && $settings) {
if ($settings['enabled'] === 'yes' && !isset($settings['enabled_providers'])) {
// backwards compatibility: if enabled but no providers selected, enable woo only as we had that.
if (defined('WC_PLUGIN_FILE')) {
$settings['enabled_providers'] = ['woo'];
}
}
$settings = wp_parse_args($settings, $defaults);
} else {
$settings = $defaults;
}
// Let each driver process settings (e.g. merge WC paid statuses)
foreach (DriverManager::getAvailable() as $driver) {
$settings = $driver->processSettings($settings);
}
return $settings;
}
public static function getSetting($key, $default = '')
{
$setting = self::getSettings();
return Arr::get($setting, $key, $default);
}
public static function isActive()
{
return Helper::isExperimentalEnabled('abandoned_cart');
}
public static function willCartTrack()
{
if (!self::isActive()) {
return false;
}
$settings = self::getSettings();
if ($settings['enabled'] !== 'yes') {
return false;
}
$disableUserRoles = Arr::get($settings, 'disabled_user_roles', []);
if (!$disableUserRoles) {
return true;
}
$user = wp_get_current_user();
if (!$user) {
return true;
}
$userRoles = array_values($user->roles);
return !array_intersect($userRoles, $disableUserRoles);
}
public static function getGDPRMessage()
{
$settings = self::getSettings();
if (Arr::get($settings, 'gdpr_consent') !== 'yes' || empty($settings['gdpr_consent_text'])) {
return '';
}
$text = wp_kses_post($settings['gdpr_consent_text']);
// {{opt_out label="No Thanks"}}
return preg_replace('/{{opt_out label="([^"]+)"}}/', '<a style="text-decoration:underline;cursor: pointer;" id="fc_ab_opt_out" class="fc-ab-cart-opt-out">$1</a>', $text);
}
public static function getCountAndSumByStatus($status, $dateRange = [], $dateColumn = 'created_at')
{
$query = AbandonCartModel::where('status', $status);
if ($dateRange) {
$query = $query->whereBetween($dateColumn, $dateRange);
}
$count = $query->count();
$sum = 0;
if ($count) {
$sum = $query->sum('total');
}
return [$count, $sum];
}
public static function getSortedAutomations($provider = 'woo')
{
$triggerName = 'fc_ab_cart_simulation_' . $provider;
$funnels = Funnel::where('trigger_name', $triggerName)
->where('status', 'published')
->orderBy('id', 'DESC')
->get();
$formattedFunnels = [];
foreach ($funnels as $funnel) {
$priority = Arr::get($funnel->settings, 'priority', 1);
if (isset($formattedFunnels[$priority])) {
$priority++;
}
$formattedFunnels[$priority] = $funnel;
}
// reverse the array to get the latest funnels first
krsort($formattedFunnels);
return array_values($formattedFunnels);
}
public static function getAbCartByDataProps($props = [], $statuses = ['processing', 'draft'])
{
if (empty($props)) {
return null;
}
if ($token = Arr::get($props, 'checkout_key')) {
$record = AbandonCartModel::where('checkout_key', $token)
->when($statuses, function ($query) use ($statuses) {
return $query->whereIn('status', $statuses);
})
->first();
if ($record) {
return $record;
}
}
if ($billingEmail = Arr::get($props, 'email')) {
$record = AbandonCartModel::where('email', $billingEmail)
->when($statuses, function ($query, $statuses) {
return $query->whereIn('status', $statuses);
})
->orderBy('id', 'DESC')
->first();
if ($record) {
return $record;
}
}
if ($userId = Arr::get($props, 'user_id')) {
$record = AbandonCartModel::where('user_id', $userId)
->when($statuses, function ($query, $statuses) {
return $query->whereIn('status', $statuses);
})
->orderBy('id', 'DESC')
->first();
if ($record) {
return $record;
}
}
return null;
}
/**
* Check if the given order status is considered a "win" (i.e., completed) status for the specified driver.
* @param string $driver The driver slug (e.g., 'woo')
* @param string $orderStatus The order status to check (e.g., 'completed')
* @return bool True if it's a win status, false otherwise
*/
public static function isWinOrderStatus($driver, $orderStatus)
{
$driver = DriverManager::getDriver($driver);
if ($driver) {
return $driver->isWinOrderStatus($orderStatus);
}
return false;
}
/**
* @deprecated Use DriverManager::getDriver('woo')->isWithinCoolOffPeriod() instead
*/
public static function isWooWithinCoolOffPeriod($abCartModel)
{
$driver = DriverManager::getDriver('woo');
if ($driver) {
return $driver->isWithinCoolOffPeriod($abCartModel);
}
return false;
}
}
@@ -0,0 +1,195 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
use FluentCrm\App\Modules\AbandonCart\Drivers\DriverManager;
use FluentCrm\App\Modules\AbandonCart\Drivers\FluentCart\FluentCartDriver;
use FluentCrm\App\Services\PermissionManager;
use FluentCrm\Framework\Support\Arr;
class AbandonCart
{
public function register()
{
add_action('init', function () {
// Register built-in FluentCart driver
DriverManager::register(new FluentCartDriver());
// Allow pro addon and third parties to register drivers
do_action('fluent_crm/abandon_cart_register_drivers');
if (!AbCartHelper::isActive()) {
return false;
}
$this->init();
}, 90);
}
protected function init()
{
$drivers = DriverManager::getEnabled();
if (!$drivers) {
return false;
}
// Boot only enabled drivers (available + toggled on in settings)
foreach ($drivers as $driver) {
$driver->register();
$driver->registerAutomationTrigger();
}
// Expose abandon cart availability to the frontend via fcAdmin vars
add_filter('fluent_crm/admin_vars', function ($vars) {
$vars['has_abandon_carts'] = true;
$vars['can_read_abandon_carts'] = PermissionManager::currentUserCan('fcrm_read_funnels');
return $vars;
});
// Add Abandoned Carts as a sub-item under the Reports dropdown
add_filter('fluent_crm/menu_items', function ($items) {
if (!PermissionManager::currentUserCan('fcrm_read_funnels')) {
return $items;
}
$urlBase = fluentcrm_menu_url_base();
$hasReportsMenu = false;
foreach ($items as &$item) {
if (!empty($item['key']) && $item['key'] === 'reports' && isset($item['sub_items'])) {
$item['sub_items'][] = [
'key' => 'reports_abandoned_carts',
'label' => __('Abandoned Carts', 'fluent-crm'),
'permalink' => $urlBase . 'reports?tab=abandoned_carts',
'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" class="fcrm_menu_icon"><path d="M5.5 15.75C5.76522 15.75 6.0195 15.8554 6.20703 16.043C6.39457 16.2305 6.5 16.4848 6.5 16.75C6.5 17.0152 6.39457 17.2695 6.20703 17.457C6.0195 17.6446 5.76522 17.75 5.5 17.75C5.23478 17.75 4.9805 17.6446 4.79297 17.457C4.60543 17.2695 4.5 17.0152 4.5 16.75C4.5 16.4848 4.60543 16.2305 4.79297 16.043C4.9805 15.8554 5.23478 15.75 5.5 15.75ZM14.5 15.75C14.7652 15.75 15.0195 15.8554 15.207 16.043C15.3946 16.2305 15.5 16.4848 15.5 16.75C15.5 17.0152 15.3946 17.2695 15.207 17.457C15.0195 17.6446 14.7652 17.75 14.5 17.75C14.2348 17.75 13.9805 17.6446 13.793 17.457C13.6054 17.2695 13.5 17.0152 13.5 16.75C13.5 16.4848 13.6054 16.2305 13.793 16.043C13.9805 15.8554 14.2348 15.75 14.5 15.75ZM4.75 3C4.8163 3 4.87987 3.02636 4.92676 3.07324C4.97364 3.12013 5 3.1837 5 3.25V12.75H15.2188L16.9688 5.75H7.5V5.25H17.29C17.328 5.25001 17.3653 5.25878 17.3994 5.27539C17.4336 5.29206 17.4639 5.31672 17.4873 5.34668C17.5105 5.37653 17.5263 5.41126 17.5342 5.44824C17.542 5.48538 17.5414 5.52373 17.5322 5.56055L15.6572 13.0605C15.6437 13.1146 15.6123 13.163 15.5684 13.1973C15.5245 13.2314 15.4706 13.25 15.415 13.25H4.75C4.68369 13.25 4.62012 13.2236 4.57324 13.1768C4.52636 13.1299 4.5 13.0663 4.5 13V3.5H3V3H4.75Z" stroke="var(--fc-secondary-text)"></path></svg>',
];
$hasReportsMenu = true;
break;
}
}
unset($item);
if (!$hasReportsMenu) {
$items[] = [
'key' => 'reports',
'label' => __('Reports', 'fluent-crm'),
'permalink' => $urlBase . 'reports?tab=abandoned_carts',
'layout_class' => 'fc_1_col_menu',
'sub_items' => [
[
'key' => 'reports_abandoned_carts',
'label' => __('Abandoned Carts', 'fluent-crm'),
'permalink' => $urlBase . 'reports?tab=abandoned_carts',
'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" class="fcrm_menu_icon"><path d="M5.5 15.75C5.76522 15.75 6.0195 15.8554 6.20703 16.043C6.39457 16.2305 6.5 16.4848 6.5 16.75C6.5 17.0152 6.39457 17.2695 6.20703 17.457C6.0195 17.6446 5.76522 17.75 5.5 17.75C5.23478 17.75 4.9805 17.6446 4.79297 17.457C4.60543 17.2695 4.5 17.0152 4.5 16.75C4.5 16.4848 4.60543 16.2305 4.79297 16.043C4.9805 15.8554 5.23478 15.75 5.5 15.75ZM14.5 15.75C14.7652 15.75 15.0195 15.8554 15.207 16.043C15.3946 16.2305 15.5 16.4848 15.5 16.75C15.5 17.0152 15.3946 17.2695 15.207 17.457C15.0195 17.6446 14.7652 17.75 14.5 17.75C14.2348 17.75 13.9805 17.6446 13.793 17.457C13.6054 17.2695 13.5 17.0152 13.5 16.75C13.5 16.4848 13.6054 16.2305 13.793 16.043C13.9805 15.8554 14.2348 15.75 14.5 15.75ZM4.75 3C4.8163 3 4.87987 3.02636 4.92676 3.07324C4.97364 3.12013 5 3.1837 5 3.25V12.75H15.2188L16.9688 5.75H7.5V5.25H17.29C17.328 5.25001 17.3653 5.25878 17.3994 5.27539C17.4336 5.29206 17.4639 5.31672 17.4873 5.34668C17.5105 5.37653 17.5263 5.41126 17.5342 5.44824C17.542 5.48538 17.5414 5.52373 17.5322 5.56055L15.6572 13.0605C15.6437 13.1146 15.6123 13.163 15.5684 13.1973C15.5245 13.2314 15.4706 13.25 15.415 13.25H4.75C4.68369 13.25 4.62012 13.2236 4.57324 13.1768C4.52636 13.1299 4.5 13.0663 4.5 13V3.5H3V3H4.75Z" stroke="var(--fc-secondary-text)"></path></svg>',
]
],
];
}
return $items;
});
// Run the runner
add_action('fluentcrm_scheduled_five_minute_tasks', [$this, 'maybeRunAbRunner'], 999);
add_action('fluentcrm_scheduled_daily_tasks', [$this, 'markOldCartsAsLost'], 10);
add_filter('fluent_crm/sales_stats', function ($stats) {
[$recoveredCount, $recoveredRevenue] = AbCartHelper::getCountAndSumByStatus('recovered', [], 'recovered_at');
if (!$recoveredRevenue) {
return $stats;
}
$dateRange = [
gmdate('Y-m-01 00:00:00', current_time('timestamp')),
gmdate('Y-m-t 23:59:59', current_time('timestamp'))
];
[$thisMonth, $thisMonthRevenue] = AbCartHelper::getCountAndSumByStatus('recovered', $dateRange, 'recovered_at');
$stats[] = [
'title' => __('Cart Recovered (This Month)', 'fluent-crm'),
'content' => DriverManager::formatPrice($thisMonthRevenue)
];
$stats[] = [
'title' => __('Cart Recovered (All Time)', 'fluent-crm'),
'content' => DriverManager::formatPrice($recoveredRevenue)
];
return $stats;
});
}
public function maybeRunAbRunner()
{
static $counter = 0;
if (!$counter) {
if (fluentCrmIsTimeOut(30)) {
return false;
}
// It's the first time. Check if there has any runner or not
$lastRunner = fluentCrmGetOptionCache('__fc_ab_runner');
if ($lastRunner) {
$timeElapsed = time() - $lastRunner;
if ($timeElapsed < 50) {
return false;
}
fluentCrmSetOptionCache('__fc_ab_runner', null, 50);
}
}
fluentCrmSetOptionCache('__fc_ab_runner', time(), 50);
$counter = $counter + 1;
// Get Draft Carts that need to be abandoned
$settings = AbCartHelper::getSettings();
$cutMinutes = Arr::get($settings, 'capture_after_minutes', 5);
$cutDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - ($cutMinutes * 60));
$enabledSlugs = DriverManager::getEnabledSlugs();
if (!$enabledSlugs) {
fluentCrmSetOptionCache('__fc_ab_runner', null, 50);
return false;
}
$abCarts = AbandonCartModel::where('status', 'draft')
->whereIn('provider', $enabledSlugs)
->where('updated_at', '<=', $cutDateTime)
->orderBy('id', 'DESC')
->limit(10)
->get();
if ($abCarts->isEmpty()) {
fluentCrmSetOptionCache('__fc_ab_runner', null, 50);
return false;
}
foreach ($abCarts as $abCart) {
(new AbandonCartRunner())->runAbandonCart($abCart);
}
fluentCrmSetOptionCache('__fc_ab_runner', null, 50);
if (!fluentCrmIsTimeOut(40)) {
$this->maybeRunAbRunner();
}
return true;
}
public function markOldCartsAsLost()
{
$settings = AbCartHelper::getSettings();
$cutDays = Arr::get($settings, 'lost_cart_days', 15);
$cutDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - ($cutDays * 86400));
AbandonCartModel::where('status', 'processing')
->where('created_at', '<=', $cutDateTime)
->update(['status' => 'lost']);
}
}
@@ -0,0 +1,236 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
use FluentCrm\App\Modules\AbandonCart\Drivers\DriverManager;
use FluentCrm\App\Http\Controllers\Controller;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Http\Request\Request;
use FluentCrm\Framework\Support\Arr;
class AbandonCartController extends Controller
{
public function getCarts(Request $request)
{
$query = $request->get('query', []);
$dateRangeInput = $request->get('date_range', []);
$dateRange = $this->getDateRange($dateRangeInput);
$enabledDrivers = DriverManager::getEnabled();
$missingAutomations = [];
$formattedDrivers = [];
$triggerNames = [];
foreach ($enabledDrivers as $driver) {
$triggerNames[$driver->getTriggerName()] = $driver;
}
$activeTriggers = Funnel::query()
->whereIn('trigger_name', array_keys($triggerNames))
->where('status', 'published')
->pluck('trigger_name')
->unique()
->toArray();
foreach ($enabledDrivers as $driver) {
if (!in_array($driver->getTriggerName(), $activeTriggers, true)) {
// Include the provider slug so the UI can scope the "no automation" notice
// (doc link + starter template differ per provider).
$missingAutomations[] = [
'provider' => $driver->getProviderSlug(),
'label' => $driver->getProviderLabel(),
];
}
$formattedDrivers[$driver->getProviderSlug()] = [
'label' => $driver->getProviderLabel(),
'logo' => $driver->getLogo()
];
}
$carts = AbandonCartModel::orderBy('id', 'DESC')
->with(['subscriber', 'automation']);
if ($dateRange) {
$carts = $carts->whereBetween('created_at', $dateRange);
}
$status = sanitize_text_field(Arr::get($query, 'status', ''));
$search = sanitize_text_field(Arr::get($query, 'search', ''));
$carts = $carts->statusBy($status)
->searchBy($search)
->paginate();
return [
'carts' => $this->mutateCartData($carts),
'haveAutomation' => empty($missingAutomations),
'missingAutomations' => $missingAutomations,
'drivers' => $formattedDrivers
];
}
public function mutateCartData($carts)
{
$updatedData = $carts->getCollection()->transform(function ($cart) {
if ($cart->status == 'processing') {
$cart->recovery_url = $cart->getRecoveryUrl();
}
$subscriber = $cart->subscriber;
// Customer Avatar
$cart->customer_avatar = $subscriber ? $subscriber->photo : fluentcrmGravatar($cart->email, $cart->full_name);
// Driver-specific enrichment (product images, order URL, etc.)
$driver = DriverManager::getDriver($cart->provider);
if ($driver) {
$cart = $driver->enrichCartForListing($cart);
}
// Remove subscriber to clean up output
unset($cart->subscriber);
return $cart;
});
$carts->setCollection(
$updatedData
);
return $carts;
}
public function handleBulkDeleteCart(Request $request)
{
$cartIds = $request->get('cart_ids', []);
if (!$cartIds || !is_array($cartIds)) {
return $this->sendError([
'message' => __('No carts selected to delete', 'fluent-crm')
]);
}
$cartIds = array_map('intval', $cartIds);
$carts = AbandonCartModel::whereIn('id', $cartIds)->get();
foreach ($carts as $cart) {
$cart->deleteCart();
}
return [
'message' => __('Selected carts have been deleted successfully', 'fluent-crm')
];
}
public function getReportSummary(Request $request)
{
$dateRangeInput = $request->get('date_range', []);
$dateRange = $this->getDateRange($dateRangeInput);
[$recoveredCount, $recoveredRevenue] = AbCartHelper::getCountAndSumByStatus('recovered', $dateRange, 'recovered_at');
[$processingCount, $processingRevenue] = AbCartHelper::getCountAndSumByStatus('processing', $dateRange);
[$lostCount, $lostRevenue] = AbCartHelper::getCountAndSumByStatus('lost', $dateRange);
[$draftCount, $draftRevenue] = AbCartHelper::getCountAndSumByStatus('draft', $dateRange);
[$optoutCount, $optoutRevenue] = AbCartHelper::getCountAndSumByStatus('opt_out', $dateRange);
$recoveryRate = '0%';
if ($lostCount) {
$recoveryRate = number_format(($recoveredCount / ($lostCount + $recoveredCount)) * 100, 2) . '%';
} else if ($recoveredCount) {
$recoveryRate = '100%';
}
return [
'widgets' => [
'recovered_revenue' => [
'title' => esc_html__('Recovered Revenue', 'fluent-crm'),
'value' => DriverManager::formatPrice($recoveredRevenue),
'count' => number_format($recoveredCount),
],
'processing_revenue' => [
'title' => esc_html__('Processing Revenue', 'fluent-crm'),
'value' => DriverManager::formatPrice($processingRevenue),
'count' => number_format($processingCount),
],
'lost_revenue' => [
'title' => esc_html__('Lost Revenue', 'fluent-crm'),
'value' => DriverManager::formatPrice($lostRevenue),
'count' => number_format($lostCount),
],
'draft_revenue' => [
'title' => esc_html__('Draft Revenue', 'fluent-crm'),
'value' => DriverManager::formatPrice($draftRevenue),
'count' => number_format($draftCount)
],
'optout_revenue' => [
'title' => esc_html__('Optout Revenue', 'fluent-crm'),
'value' => DriverManager::formatPrice($optoutRevenue),
'count' => number_format($optoutCount)
],
'recovery_rate' => [
'title' => esc_html__('Recovery Rate', 'fluent-crm'),
'value' => $recoveryRate,
'count' => ''
]
]
];
}
public function getDateRange($dateRangeInput)
{
if ($dateRangeInput) {
$dateRange = array_filter($dateRangeInput);
$startTime = isset($dateRange[0]) ? strtotime($dateRange[0]) : false;
$endTime = isset($dateRange[1]) ? strtotime($dateRange[1]) : false;
if (count($dateRange) != 2 || !$startTime || !$endTime || $startTime > $endTime) {
// Invalid date range, fallback to last 30 days
$startDate = gmdate('Y-m-d 00:00:01', strtotime('-30 days'));
$endDate = gmdate('Y-m-d 23:59:59');
$dateRange = [$startDate, $endDate];
} else {
$startDateString = $dateRange[0];
$endDateString = $dateRange[1];
// Remove timezone identifiers
$startDateString = preg_replace('/\(.*\)/', '', $startDateString);
$endDateString = preg_replace('/\(.*\)/', '', $endDateString);
try {
// Parse dates
$startDate = new \DateTime($startDateString);
$endDate = new \DateTime($endDateString);
// Adjust times for range
$startDate->setTime(0, 0, 1); // Set time to 00:00:01
$endDate->setTime(23, 59, 59); // Set time to 23:59:59
// Format for SQL or other usage
$dateRange = [
$startDate->format("Y-m-d H:i:s"),
$endDate->format("Y-m-d H:i:s")
];
} catch (\Exception $e) {
// Fallback to last 30 days
$dateRange = [
gmdate('Y-m-d 00:00:01', strtotime('-30 days')),
gmdate('Y-m-d 23:59:59')
];
}
}
} else {
// Default to last 30 days if no date range provided
$startDate = gmdate('Y-m-d 00:00:01', strtotime('-30 days'));
$endDate = gmdate('Y-m-d 23:59:59');
$dateRange = [$startDate, $endDate];
}
return $dateRange;
}
}
@@ -0,0 +1,56 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
class AbandonCartMigrator
{
/**
* On-Demand Action Links Migrator.
*
* @param bool $isForced
* @return void
*/
public static function migrate($isForced = false)
{
global $wpdb;
$charsetCollate = $wpdb->get_charset_collate();
$table = $wpdb->prefix .'fc_abandoned_carts';
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") != $table || $isForced) {
$sql = "CREATE TABLE $table (
`id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
`checkout_key` VARCHAR(192),
`cart_hash` VARCHAR(192),
`is_optout` TINYINT(1) DEFAULT 0,
`full_name` VARCHAR(192),
`email` VARCHAR(192),
`provider` VARCHAR(100) DEFAULT 'woo',
`user_id` BIGINT UNSIGNED NULL,
`click_counts` BIGINT UNSIGNED DEFAULT 0,
`contact_id` BIGINT UNSIGNED NULL,
`order_id` BIGINT UNSIGNED NULL,
`automation_id` BIGINT UNSIGNED NULL,
`checkout_page_id` BIGINT UNSIGNED NULL,
`status` VARCHAR(30) DEFAULT 'draft',
`subtotal` DECIMAL(10,2),
`shipping` DECIMAL(10,2),
`tax` DECIMAL(10,2),
`discounts` DECIMAL(10,2),
`fees` DECIMAL(10,2),
`total` DECIMAL(10,2),
`currency` VARCHAR(50),
`cart` LONGTEXT,
`note` TEXT,
`abandoned_at` TIMESTAMP NULL,
`recovered_at` TIMESTAMP NULL,
`created_at` TIMESTAMP NULL,
`updated_at` TIMESTAMP NULL,
KEY `status` (`status`),
KEY `checkout_key` (`checkout_key`)
) $charsetCollate;";
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
dbDelta($sql);
}
}
}
@@ -0,0 +1,208 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
use FluentCrm\App\Modules\AbandonCart\Drivers\DriverManager;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Models\Model;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Services\Funnel\FunnelHelper;
use FluentCrm\Framework\Support\Arr;
class AbandonCartModel extends Model
{
protected $table = 'fc_abandoned_carts';
protected $fillable = [
'checkout_key',
'cart_hash',
'contact_id',
'is_optout',
'full_name',
'email',
'provider',
'user_id',
'order_id',
'automation_id',
'checkout_page_id',
'status',
'subtotal',
'shipping',
'discounts',
'fees',
'tax',
'total',
'currency',
'cart',
'note',
'recovered_at',
'abandoned_at',
'click_counts'
];
protected $searchable = ['full_name', 'email'];
public static function boot()
{
parent::boot();
static::creating(function ($model) {
$model->checkout_key = md5(time() . wp_generate_uuid4());
});
}
public function scopeProvider($query, $provider)
{
return $query->where('provider', $provider);
}
public function scopeStatusBy($query, $status)
{
if (!$status || $status == 'all') {
return $query;
}
return $query->where('status', $status);
}
public function scopeSearchBy($query, $search)
{
if (!$search) {
return $query;
}
return $query->where(function ($q) use ($search) {
$q->where('full_name', 'LIKE', '%' . $search . '%')
->orWhere('email', 'LIKE', '%' . $search . '%');
});
}
public function setCartAttribute($data)
{
$this->attributes['cart'] = \maybe_serialize($data);
}
public function getCartAttribute($data)
{
return \maybe_unserialize($data);
}
public function subscriber()
{
return $this->belongsTo(Subscriber::class, 'contact_id');
}
public function automation()
{
return $this->belongsTo(Funnel::class, 'automation_id');
}
public function getAddress($type = 'billing')
{
$customerData = Arr::get($this->cart, 'customer_data', []);
if (Arr::get($customerData, 'differentShipping') != 'yes') {
$type = 'billingAddress';
} else {
$type = 'shippingAddress';
}
return array_filter([
'address_1' => Arr::get($customerData, $type . '.address_1'),
'address_2' => Arr::get($customerData, $type . '.address_2'),
'city' => Arr::get($customerData, $type . '.city'),
'state' => Arr::get($customerData, $type . '.state'),
'postcode' => Arr::get($customerData, $type . '.postcode'),
'country' => Arr::get($customerData, $type . '.country'),
]);
}
private function getAddressLineByKey($type, $key)
{
$address = $this->getAddress($type);
return Arr::get($address, $key, '');
}
public function getInputProp($key, $default = '')
{
$customerData = Arr::get($this->cart, 'customer_data', []);
return Arr::get($customerData, $key, $default);
}
public function getAddressProp($key, $addressType = 'billingAddress', $default = '')
{
$address = Arr::get($this->cart, 'customer_data.'.$addressType, []);
return Arr::get($address, $key, $default);
}
/*
* Get the cart items as html
* This function is called by shortcodes/mergecodes/smartcode
* e.g. {{ab_cart_woo.cart_items_table}}
*/
public function getCartItemsHtml()
{
$driver = DriverManager::getDriver($this->provider);
if ($driver) {
return $driver->getCartItemsHtml($this);
}
return '';
}
public function getRecoveryUrl()
{
$driver = DriverManager::getDriver($this->provider);
if ($driver) {
return $driver->getRecoveryUrl($this);
}
if ($this->status != 'processing') {
return '';
}
return add_query_arg([
'fluentcrm' => 1,
'route' => 'general',
'handler' => 'fc_cart_' . $this->provider,
'fc_ab_hash' => $this->checkout_key
], home_url());
}
public function deleteCart()
{
if ($this->automation_id && $this->contact_id) {
FunnelHelper::removeSubscribersFromFunnel($this->automation_id, [$this->contact_id]);
}
$this->delete();
}
public function optOut()
{
if ($this->is_optout) {
return $this;
}
$originalStatus = $this->status;
$this->is_optout = 1;
$this->status = 'opt_out';
$this->save();
if (!$this->contact_id || !$this->automation_id) {
return $this;
}
if ($originalStatus == 'processing') {
FunnelHelper::removeSubscribersFromFunnel($this->automation_id, [$this->contact_id]);
$this->automation_id = null;
$this->save();
}
return $this;
}
}
@@ -0,0 +1,322 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
use FluentCrm\App\Modules\AbandonCart\Drivers\DriverManager;
use FluentCrm\App\Models\FunnelMetric;
use FluentCrm\App\Models\FunnelSubscriber;
use FluentCrm\App\Services\Funnel\FunnelProcessor;
use FluentCrm\App\Services\Libs\ConditionAssessor;
use FluentCrm\Framework\Support\Arr;
class AbandonCartRunner
{
public function runAbandonCart(AbandonCartModel $abandonCart)
{
if ($abandonCart->status != 'draft') {
return false;
}
$driver = DriverManager::getDriver($abandonCart->provider);
if (!$driver) {
$abandonCart->status = 'skipped';
$abandonCart->note = 'No driver found for provider: ' . $abandonCart->provider;
$abandonCart->save();
return $abandonCart;
}
if ($driver->isWithinCoolOffPeriod($abandonCart)) {
$abandonCart->status = 'skipped';
$abandonCart->note = 'Under Cool Off Period';
$abandonCart->save();
return $abandonCart;
}
$automationData = $this->getEligibleAutomation($abandonCart);
if (!$automationData) {
$abandonCart->status = 'skipped';
$abandonCart->note = 'No automation found for this cart';
$abandonCart->save();
return $abandonCart;
}
if (is_wp_error($automationData)) {
$abandonCart->status = 'skipped';
$abandonCart->note = $automationData->get_error_message();
$abandonCart->save();
return $abandonCart;
}
$automation = $automationData['automation'];
$contact = $automationData['contact'];
if (!$contact) {
$contact = $this->createContactFromCart($abandonCart);
}
// Check if exit
$existingFunnelSub = FunnelSubscriber::where('funnel_id', $automation->id)
->where('subscriber_id', $contact->id)
->first();
if ($existingFunnelSub) {
FunnelMetric::where('funnel_id', $existingFunnelSub->funnel_id)
->where('subscriber_id', $contact->id)
->delete();
$existingFunnelSub->delete();
}
$settings = AbCartHelper::getSettings();
if ($attachLists = Arr::get($settings, 'lists_on_cart_abandoned', [])) {
$contact->attachLists($attachLists);
}
if ($attachTags = Arr::get($settings, 'tags_on_cart_abandoned', [])) {
$contact->attachTags($attachTags);
}
$abandonCart->status = 'processing';
$abandonCart->automation_id = $automation->id;
$abandonCart->contact_id = $contact->id;
$abandonCart->abandoned_at = current_time('mysql');
$abandonCart->save();
(new FunnelProcessor())->startFunnelSequence($automation, [], [
'source_trigger_name' => $driver->getTriggerName(),
'source_ref_id' => $abandonCart->id
], $contact);
return $abandonCart;
}
public function getEligibleAutomation(AbandonCartModel $abandonCart)
{
$automations = AbCartHelper::getSortedAutomations($abandonCart->provider);
if (!$automations) {
return new \WP_Error('no_automation', 'No automation found for this cart');
}
$existingContact = fluentCrmApi('contacts')->getContact($abandonCart->email);
$processableStatuses = ['subscribed', 'transactional'];
if ($existingContact && !in_array($existingContact->status, $processableStatuses)) {
return new \WP_Error('contact_unsubscribed', 'Contact status is not allowed to process this cart');
}
$items = Arr::get($abandonCart->cart, 'cart_contents', []);
// Use driver for provider-specific condition data extraction
$driver = DriverManager::getDriver($abandonCart->provider);
if ($driver) {
$conditionData = $driver->extractCartConditionData($abandonCart);
$productIds = $conditionData['product_ids'];
$categoryIds = $conditionData['category_ids'];
} else {
$productIds = [];
$categoryIds = [];
foreach ($items as $item) {
$productIds[] = $item['product_id'];
}
}
$cartData = [
'cart_total' => $abandonCart->total,
'cart_items_count' => count($items),
'cart_items' => $productIds,
'cart_items_categories' => $categoryIds,
];
$contact = $existingContact;
foreach ($automations as $automation) {
$conditions = (array)$automation->conditions;
if (Arr::get($conditions, 'require_subscribed') === 'yes' && (!$existingContact || $existingContact->status != 'subscribed')) {
continue;
}
$existingFunnelSub = null;
$checkActive = Arr::get($conditions, 'active_once') === 'yes';
if ($checkActive && $existingContact) {
// check if the contact is already has an automation for this one
$existingFunnelSub = FunnelSubscriber::where('funnel_id', $automation->id)
->where('subscriber_id', $existingContact->id)
->first();
if ($existingFunnelSub && $existingFunnelSub->status == 'active') {
continue;
}
}
$cartConditions = array_filter(Arr::get($conditions, 'cart_conditions', []));
if (!$cartConditions) {
return [
'automation' => $automation,
'contact' => $contact
];
}
if (!$contact && $this->hasContactConditions($cartConditions)) {
$contact = $this->createContactFromCart($abandonCart);
}
if ($this->assessConditionGroups($cartConditions, $contact, $cartData)) {
return [
'automation' => $automation,
'contact' => $contact
];
}
}
return new \WP_Error('no_automation', 'No automation found for this cart based on condition match');
}
protected function createContactFromCart(AbandonCartModel $abandonCart)
{
$customData = Arr::get($abandonCart->cart, 'customer_data', []);
$cartSettings = AbCartHelper::getSettings();
$contactData = array_filter([
'email' => $abandonCart->email,
'first_name' => Arr::get($customData, 'billingAddress.first_name'),
'last_name' => Arr::get($customData, 'billingAddress.last_name'),
'user_id' => $abandonCart->user_id,
'full_name' => $abandonCart->full_name,
'status' => Arr::get($cartSettings, 'new_contact_status', 'transactional'),
'address_line_1' => Arr::get($customData, 'billingAddress.address_1'),
'address_line_2' => Arr::get($customData, 'billingAddress.address_2'),
'city' => Arr::get($customData, 'billingAddress.city'),
'state' => Arr::get($customData, 'billingAddress.state'),
'postal_code' => Arr::get($customData, 'billingAddress.postcode'),
'country' => Arr::get($customData, 'billingAddress.country'),
'phone' => Arr::get($customData, 'billingAddress.phone'),
'tags' => Arr::get($cartSettings, 'tags_on_cart_abandoned', []),
'lists' => Arr::get($cartSettings, 'lists_on_cart_abandoned', [])
]);
return fluentCrmApi('contacts')->createOrUpdate($contactData);
}
protected function hasContactConditions($conditions)
{
$cartGroupKeys = DriverManager::getAllSmartCodeGroupKeys();
foreach ($conditions as $conditionGroup) {
foreach ($conditionGroup as $filterItem) {
if (count($filterItem['source']) != 2 || empty($filterItem['source'][0]) || empty($filterItem['source'][1]) || empty($filterItem['operator'])) {
continue;
}
$provider = $filterItem['source'][0];
if (!in_array($provider, $cartGroupKeys)) {
return true;
}
}
}
return false;
}
protected function assessConditionGroups($conditionGroups, $subscriber, $cartData = [])
{
foreach ($conditionGroups as $conditions) {
$result = $this->assessConditions($conditions, $subscriber, $cartData);
if ($result) {
return true;
}
}
return false;
}
protected function assessConditions($conditions, $subscriber, $cartData = [])
{
if (!defined('FLUENTCAMPAIGN_DIR_FILE')) {
return true;
}
$helperClass = 'FluentCampaign\App\Services\Funnel\Conditions\FunnelConditionHelper';
if (!class_exists($helperClass)) {
// Free-only: no advanced condition engine, accept all conditions
return true;
}
$formattedGroups = $helperClass::formatConditionGroups($conditions);
foreach ($formattedGroups as $groupName => $group) {
if ($groupName == 'subscriber') {
if (!$subscriber) {
return false;
}
$subscriberData = $subscriber->toArray();
if (!ConditionAssessor::matchAllConditions($group, $subscriberData)) {
return false;
}
} else if ($groupName == 'custom_fields') {
if (!$subscriber) {
return false;
}
$customData = $subscriber->custom_fields();
if (!ConditionAssessor::matchAllConditions($group, $customData)) {
return false;
}
} else if ($groupName == 'segment') {
if (!$subscriber) {
return false;
}
if (!$helperClass::assessSegmentConditions($group, $subscriber)) {
return false;
}
} else if ($groupName == 'activities') {
if (!$subscriber) {
return false;
}
if (!$helperClass::assessActivities($group, $subscriber)) {
return false;
}
} else if ($groupName == 'event_tracking') {
if (!$subscriber) {
return false;
}
if (!$helperClass::assessEventTrackingConditions($group, $subscriber)) {
return false;
}
} else if ($groupName == 'other') {
if (!$subscriber) {
return false;
}
foreach ($group as $condition) {
$prop = $condition['data_key'];
if (!apply_filters('fluentcrm_automation_custom_condition_assert_' . $prop, true, $condition, $subscriber, null, null)) {
return false;
}
}
} else if (DriverManager::getDriverByGroupKey($groupName)) {
if (!ConditionAssessor::matchAllConditions($group, $cartData)) {
return false;
}
} else {
if (!$subscriber) {
return false;
}
$result = apply_filters("fluentcrm_automation_conditions_assess_$groupName", true, $group, $subscriber, null, null);
if (!$result) {
return false;
}
}
}
return true;
}
}
@@ -0,0 +1,206 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart\Drivers;
use FluentCrm\App\Modules\AbandonCart\AbandonCartModel;
abstract class AbstractCartDriver
{
public $logo = '';
/**
* Unique provider slug. Must match the `provider` column value in fc_abandoned_carts.
* Examples: 'woo', 'fluent_cart', 'edd'
*
* @return string
*/
abstract public function getProviderSlug();
/**
* Human-readable provider label for UI display.
*
* @return string
*/
abstract public function getProviderLabel();
/**
* Whether this driver's platform is currently available (plugin active).
*
* @return bool
*/
abstract public function isAvailable();
/**
* Register all hooks, filters, frontend scripts, ajax handlers,
* order lifecycle listeners, and cart recovery URL handlers.
*
* @return void
*/
abstract public function register();
/**
* Register the automation trigger class for this provider.
*
* @return void
*/
abstract public function registerAutomationTrigger();
/**
* Check if the given cart is within a cool-off period for this provider.
*
* @param AbandonCartModel $cart
* @return bool
*/
abstract public function isWithinCoolOffPeriod(AbandonCartModel $cart);
/**
* Render cart items as HTML for email templates.
*
* @param AbandonCartModel $cart
* @return string
*/
abstract public function getCartItemsHtml(AbandonCartModel $cart);
/**
* Format a monetary amount for display using this provider's currency formatting.
*
* @param float|string $amount
* @param string $currency
* @return string
*/
abstract public function formatPrice($amount, $currency = '');
/**
* Return the recovery URL for this cart.
*
* @param AbandonCartModel $cart
* @return string
*/
abstract public function getRecoveryUrl(AbandonCartModel $cart);
/**
* Extract product IDs and category IDs from the cart for condition matching.
*
* @param AbandonCartModel $cart
* @return array ['product_ids' => [...], 'category_ids' => [...]]
*/
abstract public function extractCartConditionData(AbandonCartModel $cart);
/**
* Enrich cart data for the admin listing API response.
* Adds product images, order URL, etc.
*
* @param AbandonCartModel $cart
* @return AbandonCartModel
*/
abstract public function enrichCartForListing(AbandonCartModel $cart);
/**
* Return provider-specific data for the settings API response.
* e.g. WooCommerce returns order statuses.
*
* @return array
*/
public function getProviderSettingsResponse()
{
return [];
}
/**
* Return provider-specific settings fields for the settings page.
*
* @return array
*/
public function getSettingsFields()
{
return [];
}
/**
* Return provider-specific default settings to merge into global defaults.
*
* @return array
*/
public function getProviderSettingsDefaults()
{
return [];
}
/**
* Apply provider-specific processing to settings after loading.
*
* @param array $settings
* @return array
*/
public function processSettings($settings)
{
return $settings;
}
/**
* Get the trigger name for this provider's automation.
*
* @return string
*/
public function getTriggerName()
{
return 'fc_ab_cart_simulation_' . $this->getProviderSlug();
}
/**
* Get the handler name for cart recovery URL routing.
*
* @return string
*/
public function getHandlerName()
{
return 'fc_cart_' . $this->getProviderSlug();
}
/**
* Get the smart code group key for this provider.
*
* @return string
*/
public function getSmartCodeGroupKey()
{
return 'ab_cart_' . $this->getProviderSlug();
}
/**
* Get the base path for view templates.
* Drivers should override this to point to their own plugin's Views directory.
*
* @return string
*/
protected function getViewsBasePath()
{
return '';
}
/**
* Load a view template from the driver's Views directory.
*
* @param string $templateName
* @param array $data
* @return string
*/
protected function loadView($templateName, $data)
{
$basePath = $this->getViewsBasePath();
if (!$basePath) {
return '';
}
extract($data, EXTR_SKIP);
ob_start();
include $basePath . $templateName . '.php';
return ltrim(ob_get_clean());
}
public function getLogo()
{
return $this->logo;
}
}
@@ -0,0 +1,168 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart\Drivers;
use FluentCrm\App\Modules\AbandonCart\AbCartHelper;
class DriverManager
{
/** @var AbstractCartDriver[] keyed by provider slug */
private static $drivers = [];
public static function register(AbstractCartDriver $driver)
{
static::$drivers[$driver->getProviderSlug()] = $driver;
}
/**
* @param string $providerSlug
* @return AbstractCartDriver|null
*/
public static function getDriver($providerSlug)
{
return static::$drivers[$providerSlug] ?? null;
}
/**
* @return AbstractCartDriver[]
*/
public static function getAll()
{
return static::$drivers;
}
/**
* @return AbstractCartDriver[] Only drivers whose platform is currently active
*/
public static function getAvailable()
{
return array_filter(static::$drivers, function ($driver) {
return $driver->isAvailable();
});
}
/**
* @return string[]
*/
public static function getAvailableSlugs()
{
return array_keys(static::getAvailable());
}
/**
* @return bool
*/
public static function hasAvailableDrivers()
{
return count(static::getAvailable()) > 0;
}
/**
* Get drivers that are both available (plugin installed) and enabled in settings.
*
* @return AbstractCartDriver[]
*/
public static function getEnabled()
{
$available = static::getAvailable();
$settings = AbCartHelper::getSettings(true);
$enabledProviders = $settings['enabled_providers'] ?? [];
if (empty($enabledProviders)) {
return [];
}
return array_filter($available, function ($driver) use ($enabledProviders) {
return in_array($driver->getProviderSlug(), $enabledProviders);
});
}
/**
* @return string[]
*/
public static function getEnabledSlugs()
{
return array_keys(static::getEnabled());
}
/**
* Check if a specific driver is enabled
*
* @param string $providerSlug
* @return bool
*/
public static function isDriverEnabled($providerSlug)
{
return isset(static::getEnabled()[$providerSlug]);
}
/**
* Get trigger names from all enabled drivers
*
* @return string[]
*/
public static function getEnabledTriggerNames()
{
return array_map(function ($driver) {
return $driver->getTriggerName();
}, static::getEnabled());
}
/**
* Get smart code group keys from all registered drivers
*
* @return string[]
*/
public static function getAllSmartCodeGroupKeys()
{
return array_map(function ($driver) {
return $driver->getSmartCodeGroupKey();
}, static::getAll());
}
/**
* Find a driver by its smart code group key (e.g. 'ab_cart_woo')
*
* @param string $groupKey
* @return AbstractCartDriver|null
*/
public static function getDriverByGroupKey($groupKey)
{
foreach (static::$drivers as $driver) {
if ($driver->getSmartCodeGroupKey() === $groupKey) {
return $driver;
}
}
return null;
}
/**
* Format a price using the appropriate driver, with a generic fallback
*
* @param float|string $amount
* @param string $currency
* @param string|null $providerSlug
* @return string
*/
public static function formatPrice($amount, $currency = '', $providerSlug = null)
{
if ($providerSlug) {
$driver = static::getDriver($providerSlug);
if ($driver) {
return $driver->formatPrice($amount, $currency);
}
}
// Fall back to first available driver
$available = static::getAvailable();
if ($available) {
$driver = reset($available);
return $driver->formatPrice($amount, $currency);
}
return '$' . number_format((float)$amount, 2);
}
}
@@ -0,0 +1,270 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart\Drivers\FluentCart;
use FluentCrm\App\Services\Funnel\BaseTrigger;
use FluentCrm\App\Services\Funnel\FunnelHelper;
class FluentCartAutomationTrigger extends BaseTrigger
{
public function __construct()
{
$this->triggerName = 'fc_ab_cart_simulation_fluent_cart';
$this->priority = 99;
$this->actionArgNum = 1;
parent::__construct();
}
public function getTrigger()
{
return [
'category' => __('FluentCart', 'fluent-crm'),
'label' => __('Cart Abandoned - FluentCart', 'fluent-crm'),
'description' => __('This Funnel will be initiated when a cart has been abandoned in FluentCart', '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 getSettingsFields($funnel)
{
return [
'title' => __('Cart Abandoned - FluentCart', 'fluent-crm'),
'sub_title' => __('This Funnel will be initiated when a cart has been abandoned in FluentCart', 'fluent-crm'),
'fields' => [
'priority' => [
'label' => __('Priority of this abandon cart automation trigger', 'fluent-crm'),
'type' => 'input-number',
'placeholder' => __('Automation Priority', 'fluent-crm'),
'inline_help' => __('If you have multiple automations for abandoned cart, you can set the priority. The higher the priority means it will match earlier. Only one abandoned cart automation will run per abandonment depending on your conditional logic.', 'fluent-crm')
]
]
];
}
public function getFunnelSettingsDefaults()
{
return [
'priority' => 10
];
}
public function getFunnelConditionDefaults($funnel)
{
return [
'cart_conditions' => [[]],
'active_once' => 'no',
'require_subscribed' => 'no'
];
}
public function getConditionFields($funnel)
{
if (!defined('FLUENTCAMPAIGN_DIR_FILE')) {
$cartConditionField = [
'type' => 'html',
'label' => '',
'info' => '<h4 style="margin: 0; padding: 0;">Conditions by Cart Items</h4><div style="background-color: #FFF3DC !important;border-color: #FFF3DC; padding: 15px; line-height: 120%;">' . __('FluentCRM Pro plugin is required to use the conditional logic for this trigger. Please install and activate FluentCRM Pro to use this feature.', 'fluent-crm') . '</div>'
];
} else {
$cartConditionField = [
'type' => 'condition_block_groups',
'label' => __('Specify Matching Conditions', 'fluent-crm'),
'inline_help' => __('Specify which contact properties need to be matched. If the conditions match then the automation will run.', 'fluent-crm'),
'labels' => [
'match_type_all_label' => __('True if all conditions match', 'fluent-crm'),
'match_type_any_label' => __('True if any of the conditions match', 'fluent-crm'),
'data_key_label' => __('Contact Data', 'fluent-crm'),
'condition_label' => __('Condition', 'fluent-crm'),
'data_value_label' => __('Match Value', 'fluent-crm')
],
'groups' => $this->getConditionGroups($funnel),
'add_label' => __('Add Condition to check your contact\'s properties', 'fluent-crm'),
];
}
$fields = [
'cart_conditions' => $cartConditionField,
'active_once' => [
'type' => 'yes_no_check',
'label' => '',
'check_label' => __('Skip this automation if the contact is already in active state.', 'fluent-crm'),
'inline_help' => __('Enable this to prevent the automation from running multiple times for the same contact if it is currently active in this automation', 'fluent-crm')
],
'require_subscribed' => [
'type' => 'yes_no_check',
'label' => '',
'check_label' => __('Only run this automation for subscribed contacts', 'fluent-crm'),
'inline_help' => __('If you enable, then it will only run this automation for subscribed contacts', 'fluent-crm')
]
];
return $fields;
}
public function handle($funnel, $originalArgs)
{
// do nothing here - cart processing is handled by AbandonCartRunner
}
public function getConditionGroups($funnel)
{
$groups = [
'ab_cart_fluent_cart' => [
'label' => __('Cart Data', 'fluent-crm'),
'value' => 'ab_cart_fluent_cart',
'children' => [
[
'label' => __('Cart Total', 'fluent-crm'),
'value' => 'cart_total',
'type' => 'numeric'
],
[
'label' => __('Cart Items Count', 'fluent-crm'),
'value' => 'cart_items_count',
'type' => 'numeric'
],
[
'label' => __('Cart Items', 'fluent-crm'),
'value' => 'cart_items',
'type' => 'selections',
'component' => 'ajax_selector',
'option_key' => 'fluent_cart_products',
'is_multiple' => true,
'help' => __('Match the products on the cart', 'fluent-crm')
],
[
'label' => __('Cart Items Categories', 'fluent-crm'),
'value' => 'cart_items_categories',
'type' => 'selections',
'component' => 'tax_selector',
'taxonomy' => 'product-categories',
'is_multiple' => true,
'help' => __('Match the product categories on the cart', 'fluent-crm')
],
]
],
'subscriber' => [
'label' => __('Contact', 'fluent-crm'),
'value' => 'subscriber',
'children' => [
[
'label' => __('First Name', 'fluent-crm'),
'value' => 'first_name',
'type' => 'nullable_text'
],
[
'label' => __('Last Name', 'fluent-crm'),
'value' => 'last_name',
'type' => 'nullable_text'
],
[
'label' => __('Email', 'fluent-crm'),
'value' => 'email',
'type' => 'extended_text'
],
[
'label' => __('Country', 'fluent-crm'),
'value' => 'country',
'type' => 'selections',
'component' => 'options_selector',
'option_key' => 'countries',
'is_multiple' => true,
'is_singular_value' => true
],
[
'label' => __('Phone', 'fluent-crm'),
'value' => 'phone',
'type' => 'nullable_text'
],
[
'label' => __('Created At', 'fluent-crm'),
'value' => 'created_at',
'type' => 'dates',
]
],
],
'segment' => [
'label' => __('Contact Segment', 'fluent-crm'),
'value' => 'segment',
'children' => [
[
'label' => __('Tags', 'fluent-crm'),
'value' => 'tags',
'type' => 'selections',
'component' => 'options_selector',
'option_key' => 'tags',
'is_multiple' => true,
],
[
'label' => __('Lists', 'fluent-crm'),
'value' => 'lists',
'type' => 'selections',
'component' => 'options_selector',
'option_key' => 'lists',
'is_multiple' => true,
],
[
'label' => __('WP User Role', 'fluent-crm'),
'value' => 'user_role',
'type' => 'selections',
'is_singular_value' => true,
'options' => FunnelHelper::getUserRoles(true),
'is_multiple' => true,
]
],
],
];
if ($customFields = fluentcrm_get_custom_contact_fields()) {
$children = [];
foreach ($customFields as $field) {
$item = [
'label' => $field['label'],
'value' => $field['slug'],
'type' => $field['type'],
];
if ($item['type'] == 'number') {
$item['type'] = 'numeric';
} else if ($item['type'] == 'date') {
$item['type'] = 'dates';
$item['date_type'] = 'date';
$item['value_format'] = 'YYYY-MM-DD';
} else if ($item['type'] == 'date_time') {
$item['type'] = 'dates';
$item['has_time'] = 'yes';
$item['date_type'] = 'datetime';
$item['value_format'] = 'YYYY-MM-DD HH:mm:ss';
} else if (isset($field['options'])) {
$item['type'] = 'selections';
$options = $field['options'];
$formattedOptions = [];
foreach ($options as $option) {
$formattedOptions[$option] = $option;
}
$item['options'] = $formattedOptions;
$isMultiple = in_array($field['type'], ['checkbox', 'select-multi']);
$item['is_multiple'] = $isMultiple;
if ($isMultiple) {
$item['is_singular_value'] = true;
}
} else {
$item['type'] = 'extended_text';
}
$children[] = $item;
}
$groups['custom_fields'] = [
'label' => __('Custom Fields', 'fluent-crm'),
'value' => 'custom_fields',
'children' => $children
];
}
$groups = apply_filters('fluentcrm_automation_condition_groups', $groups, $funnel);
return array_values($groups);
}
}
@@ -0,0 +1,278 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart\Drivers\FluentCart;
use FluentCrm\App\Modules\AbandonCart\AbandonCartModel;
use FluentCrm\App\Modules\AbandonCart\AbCartHelper;
use FluentCrm\App\Modules\AbandonCart\Drivers\AbstractCartDriver;
use FluentCrm\Framework\Support\Arr;
class FluentCartDriver extends AbstractCartDriver
{
public function getProviderSlug()
{
return 'fluent_cart';
}
public function getProviderLabel()
{
return __('FluentCart', 'fluent-crm');
}
public function isAvailable()
{
return defined('FLUENTCART_VERSION');
}
public function getLogo()
{
return FLUENTCRM_PLUGIN_URL . 'assets/images/fluent-cart-dark.svg';
}
public function register()
{
(new FluentCartTrackingInit())->register();
}
public function registerAutomationTrigger()
{
// Registered inside FluentCartTrackingInit::register()
}
protected function getViewsBasePath()
{
return __DIR__ . '/Views/';
}
public function isWithinCoolOffPeriod(AbandonCartModel $cart)
{
$coolOffPeriodDay = AbCartHelper::getSetting('cool_off_period_days', 0);
if (!$coolOffPeriodDay) {
return false;
}
$coolOffDateTime = gmdate('Y-m-d H:i:s', time() - ($coolOffPeriodDay * DAY_IN_SECONDS));
$winStatuses = $this->getWinOrderStatuses();
return fluentCrmDb()->table('fct_orders')
->where('created_at', '>=', $coolOffDateTime)
->whereIn('status', $winStatuses)
->where(function ($q) use ($cart) {
$q->whereIn('customer_id', function ($sub) use ($cart) {
$sub->select('id')
->from('fct_customers')
->where('email', $cart->email);
if ($cart->user_id) {
$sub->orWhere('user_id', $cart->user_id);
}
});
})
->exists();
}
public function getCartItemsHtml(AbandonCartModel $cart)
{
$cartItems = Arr::get($cart->cart, 'cart_data', []);
return $this->loadView('AbandonCartItems', [
'cartItems' => $cartItems,
'currency' => $cart->currency
]);
}
public function formatPrice($amount, $currency = '')
{
if (!class_exists('\FluentCart\Api\CurrencySettings')) {
return '$' . number_format((float)$amount, 2);
}
return \FluentCart\Api\CurrencySettings::getPriceHtml((int) round(((float) $amount) * 100), $currency ?: null);
}
public function getRecoveryUrl(AbandonCartModel $cart)
{
if ($cart->status != 'processing') {
return '';
}
return add_query_arg([
'fluentcrm' => 1,
'route' => 'general',
'handler' => $this->getHandlerName(),
'fc_ab_hash' => $cart->checkout_key
], home_url());
}
public function extractCartConditionData(AbandonCartModel $cart)
{
$items = Arr::get($cart->cart, 'cart_data', []);
$productIds = [];
$categoryIds = [];
foreach ($items as $item) {
$postId = Arr::get($item, 'post_id');
if ($postId) {
$productIds[] = $postId;
}
}
if ($productIds) {
$cats = fluentCrmDb()->table('term_relationships')
->join('term_taxonomy', 'term_relationships.term_taxonomy_id', '=', 'term_taxonomy.term_taxonomy_id')
->whereIn('term_relationships.object_id', $productIds)
->where('term_taxonomy.taxonomy', 'product-categories')
->select('term_taxonomy.term_id')
->get();
foreach ($cats as $cat) {
$categoryIds[] = $cat->term_id;
}
}
return [
'product_ids' => $productIds,
'category_ids' => $categoryIds
];
}
public function enrichCartForListing(AbandonCartModel $cart)
{
if ($cart->order_id) {
$cart->order_url = admin_url('admin.php?page=fluent-cart#/orders/' . $cart->order_id . '/view');
}
$newCart = $cart->cart ?: [];
$formData = Arr::get($newCart, 'checkout_data.form_data', []);
$billingFullName = trim(Arr::get($formData, 'billing_first_name', '') . ' ' . Arr::get($formData, 'billing_last_name', ''));
if (!$billingFullName) {
$billingFullName = $cart->full_name;
}
// Build customer_data with billingAddress/shippingAddress for the shared Vue modal
$newCart['customer_data'] = [
'billingAddress' => [
'first_name' => $billingFullName,
'last_name' => '',
'address_1' => Arr::get($formData, 'billing_address_1', ''),
'address_2' => Arr::get($formData, 'billing_address_2', ''),
'postcode' => Arr::get($formData, 'billing_postcode', ''),
'city' => Arr::get($formData, 'billing_city', ''),
'country' => Arr::get($formData, 'billing_country', ''),
],
'shippingAddress' => [
'first_name' => Arr::get($formData, 'shipping_full_name', ''),
'last_name' => '',
'address_1' => Arr::get($formData, 'shipping_address_1', ''),
'address_2' => Arr::get($formData, 'shipping_address_2', ''),
'postcode' => Arr::get($formData, 'shipping_postcode', ''),
'city' => Arr::get($formData, 'shipping_city', ''),
'country' => Arr::get($formData, 'shipping_country', ''),
],
'order_comments' => Arr::get($formData, 'order_comments', ''),
];
if (Arr::get($formData, 'ship_to_different') !== 'yes') {
$newCart['customer_data']['shippingAddress'] = $newCart['customer_data']['billingAddress'];
}
// Build cart_contents from cart_data for the shared Vue modal
$cartContents = [];
$cartItems = Arr::get($newCart, 'cart_data', []);
foreach ($cartItems as $cartItem) {
$imageUrl = Arr::get($cartItem, 'featured_media', '');
if (!$imageUrl) {
$postId = Arr::get($cartItem, 'post_id');
if ($postId) {
$imageUrl = get_the_post_thumbnail_url($postId, 'thumbnail');
}
}
$subtotal = (int)Arr::get($cartItem, 'subtotal', 0);
$title = Arr::get($cartItem, 'post_title', '');
$subTitle = Arr::get($cartItem, 'title', '');
if($title && $subTitle && $title != $subTitle) {
$title .= ' - ' . $subTitle;
}
$cartContents[] = [
'title' => $title,
'quantity' => (int)Arr::get($cartItem, 'quantity', 1),
'line_total' => number_format($subtotal / 100, 2, '.', ''),
'product_image' => $imageUrl ?: '',
];
}
$newCart['cart_contents'] = $cartContents;
$cart->cart = $newCart;
return $cart;
}
public function getProviderSettingsResponse()
{
if (!defined('FLUENTCART_VERSION')) {
return [];
}
return [
'fct_recovered_statuses' => $this->getWinOrderStatuses(),
];
}
public function getProviderSettingsDefaults()
{
return [
'fct_recovered_statuses' => ['completed', 'processing'],
];
}
public function getSettingsFields()
{
if (!$this->isAvailable()) {
return [];
}
$statuses = [
['id' => 'completed', 'label' => __('Completed', 'fluent-crm')],
['id' => 'processing', 'label' => __('Processing', 'fluent-crm')],
['id' => 'on-hold', 'label' => __('On Hold', 'fluent-crm')],
];
return [
'fct_recovered_statuses' => [
'name' => 'fct_recovered_statuses',
'label' => __('Mark Cart as Recovered when FluentCart Order Status Changes to:', 'fluent-crm'),
'type' => 'checkbox-group',
'options' => $statuses,
'inline_help' => __('Automatically mark a cart as recovered when the corresponding FluentCart order status changes to the selected status.', 'fluent-crm'),
]
];
}
/**
* Check if a FluentCart order status counts as a successful recovery.
*
* @param string $orderStatus
* @return bool
*/
public function isWinOrderStatus($orderStatus)
{
$recoveredStatuses = $this->getWinOrderStatuses();
$result = in_array($orderStatus, $recoveredStatuses, true);
return apply_filters('fluent_crm/ab_cart_is_win_status', $result, $orderStatus, $this);
}
private function getWinOrderStatuses()
{
$settings = AbCartHelper::getSettings();
return Arr::get($settings, 'fct_recovered_statuses', ['completed', 'processing']);
}
}
@@ -0,0 +1,752 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart\Drivers\FluentCart;
use FluentCart\Api\StoreSettings;
use FluentCart\App\Helpers\AddressHelper;
use FluentCart\App\Models\Cart;
use FluentCrm\App\Modules\AbandonCart\AbandonCartModel;
use FluentCrm\App\Modules\AbandonCart\AbCartHelper;
use FluentCrm\App\Models\FunnelSubscriber;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\Framework\Support\Arr;
class FluentCartTrackingInit
{
public function register()
{
// Funnel Automations
(new FluentCartAutomationTrigger())->register();
// Checkout Frontend - inject tracking script
add_action('fluent_cart/after_checkout_page', [$this, 'addAbandonScript']);
// AJAX handler for GDPR opt-out
add_action('wp_ajax_fc_ab_fct_cart_skip', [$this, 'handleAjaxOptOut']);
add_action('wp_ajax_nopriv_fc_ab_fct_cart_skip', [$this, 'handleAjaxOptOut']);
// Sync abandoned cart when FluentCart saves checkout data
add_filter('fluent_cart/checkout/after_patch_checkout_data_fragments', function ($fragments, $data) {
$this->maybeSyncCart(Arr::get($data, 'cart'));
return $fragments;
}, 99, 2);
// Sync abandoned cart when cart amounts change (item add/remove, coupon)
add_action('fluent_cart/checkout/cart_amount_updated', function ($data) {
$this->maybeSyncCart(Arr::get($data, 'cart'));
}, 99);
// Sync abandoned cart on form data change (alternative save path)
add_action('fluent_cart/checkout/form_data_changed', function ($data) {
$this->maybeSyncCart(Arr::get($data, 'cart'));
}, 99);
// Cart recovery URL handler
// URL: example.com/?fluentcrm=1&route=general&handler=fc_cart_fluent_cart&fc_ab_hash=xyz
add_action('fluent_crm/handle_frontend_for_fc_cart_fluent_cart', function ($data) {
add_action('template_redirect', function () use ($data) {
$this->maybeRestoreCart($data);
}, 1);
});
// Order lifecycle - link cart to order when created
add_action('fluent_cart/order_created', [$this, 'handleOrderCreated'], 1);
// Order paid - mark cart as recovered
add_action('fluent_cart/order_paid', [$this, 'handleOrderPaid'], 1);
// Order status changes
add_action('fluent_cart/order_status_changed', [$this, 'handleOrderStatusChanged'], 10);
// Push contextual smart codes for this provider
add_filter('fluent_crm_funnel_context_smart_codes', [$this, 'pushContextCodes'], 1, 2);
// Parse the context codes
add_filter('fluent_crm/smartcode_group_callback_ab_cart_fluent_cart', [$this, 'parseSmartCodes'], 10, 4);
}
public function addAbandonScript()
{
if (!AbCartHelper::willCartTrack()) {
return;
}
if (isset($_COOKIE['fc_ab_cart_skip_track']) && $_COOKIE['fc_ab_cart_skip_track'] == 'yes') {
return;
}
wp_enqueue_script(
'fluent_crm-abandon-cart-fct',
FLUENTCRM_PLUGIN_URL . 'app/Modules/AbandonCart/Drivers/FluentCart/assets/fc-cart-abandon-fluent-cart.js',
[],
FLUENTCRM_PLUGIN_VERSION,
true
);
wp_localize_script('fluent_crm-abandon-cart-fct', 'fc_ab_fct_cart', [
'nonce' => wp_create_nonce('fc_ab_fct_cart_nonce'),
'__gdpr_message' => AbCartHelper::getGDPRMessage(),
]);
}
public function handleAjaxOptOut()
{
$nonce = Arr::get($_REQUEST, '_nonce');
if (!wp_verify_nonce($nonce, 'fc_ab_fct_cart_nonce')) {
wp_send_json([
'message' => __('Security check failed. Invalid nonce.', 'fluent-crm')
], 403);
}
$record = $this->getCurrentRecord();
if ($record) {
$record->optOut();
}
$cookieDays = (int)apply_filters('fluent_crm/ab_cart_opt_out_cookie_validity', 7);
setcookie('fc_ab_cart_skip_track', 'yes', time() + (86400 * $cookieDays), COOKIEPATH, COOKIE_DOMAIN);
wp_send_json([
'message' => __('You have opted out from cart tracking', 'fluent-crm')
]);
}
public function maybeSyncCart($fctCart)
{
if (!$fctCart || !AbCartHelper::willCartTrack()) {
return;
}
if (!$fctCart->email || empty($fctCart->cart_data)) {
return;
}
$billingEmail = $fctCart->email;
if (isset($_COOKIE['fc_ab_cart_skip_track']) && $_COOKIE['fc_ab_cart_skip_track'] == 'yes') {
$record = $this->getCurrentRecord($billingEmail, $fctCart->cart_hash);
if ($record && $record->status !== 'opt_out') {
$record->status = 'opt_out';
$record->save();
}
return;
}
$checkoutData = $fctCart->checkout_data ?: [];
// Calculate totals for the table columns (FluentCart stores amounts in cents).
// $subtotal is the items' price BEFORE any discount.
$subtotal = $fctCart->getItemsSubtotal();
// Coupon and per-item manual discounts are stored on each cart item as
// discount_total (manual_discount + coupon_discount). The old code only read
// custom_checkout_data.discount_total, which is empty for normal frontend
// coupons, so the coupon was never reflected in the cart total. Sum the per-item
// discounts plus any checkout-level (manual/upgrade/prorate) discounts.
$itemsDiscountTotal = array_sum(array_map(function ($item) {
return (int)Arr::get($item, 'discount_total', 0);
}, $fctCart->cart_data ?: []));
$discountTotal = $itemsDiscountTotal
+ (int)Arr::get($checkoutData, 'manual_discount.amount', 0)
+ (int)Arr::get($checkoutData, 'upgrade_discount.amount', 0)
+ (int)Arr::get($checkoutData, 'prorate_credit.amount', 0);
$shippingTotal = (int)$fctCart->getShippingTotal();
$taxTotal = (int)Arr::get($checkoutData, 'tax_data.tax_total', 0);
// Use FluentCart's authoritative total so the displayed Cart Total matches the
// checkout exactly (coupons, fees, shipping and tax all included).
$total = (int)$fctCart->getEstimatedTotal();
if ($total <= 0) {
$record = $this->getCurrentRecord($billingEmail);
if ($record) {
$record->delete();
setcookie('fc_ab_fct_cart_token', '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN);
}
return;
}
$currency = '';
if (class_exists('\FluentCart\Api\CurrencySettings')) {
$currency = \FluentCart\Api\CurrencySettings::get('currency') ?: 'USD';
}
$contact = FluentCrmApi('contacts')->getContact($billingEmail);
$fullName = $fctCart->full_name ?? trim($fctCart->first_name . ' ' . $fctCart->last_name);
if (!$fullName && $contact) {
$fullName = trim($contact->first_name . ' ' . $contact->last_name);
}
// Build a per-coupon breakdown (code + discounted value) for the cart details view.
// FluentCart stores the applied codes on $fctCart->coupons and the amount each code
// saved (in cents) on checkout_data.__per_coupon_discounts, keyed by code.
$perCouponDiscounts = Arr::get($checkoutData, '__per_coupon_discounts', []);
$couponDetails = [];
foreach (($fctCart->coupons ?: []) as $couponCode) {
$couponAmount = (int)Arr::get($perCouponDiscounts, $couponCode, 0);
$couponDetails[] = [
'code' => $couponCode,
'discount' => number_format($couponAmount / 100, 2, '.', ''),
];
}
// Snapshot the FluentCart data as-is for easy restore
$data = [
'cart_hash' => $fctCart->cart_hash,
'full_name' => $fullName,
'email' => $billingEmail,
'provider' => 'fluent_cart',
'user_id' => $fctCart->user_id,
'contact_id' => $contact ? $contact->id : null,
'order_id' => $fctCart->order_id,
'subtotal' => number_format($subtotal / 100, 2, '.', ''),
'shipping' => number_format($shippingTotal / 100, 2, '.', ''),
'discounts' => number_format($discountTotal / 100, 2, '.', ''),
'tax' => number_format($taxTotal / 100, 2, '.', ''),
'fees' => 0,
'total' => number_format($total / 100, 2, '.', ''),
'currency' => $currency,
'cart' => [
'cart_data' => $fctCart->cart_data ?: [],
'checkout_data' => $checkoutData,
'coupons' => $fctCart->coupons ?: [],
'coupons_detail' => $couponDetails,
'utm_data' => $fctCart->utm_data ?: [],
'cart_group' => $fctCart->cart_group,
'customer_data' => $this->buildCustomerData($checkoutData),
],
];
$record = $this->getCurrentRecord($billingEmail, $fctCart->cart_hash);
if (!$record) {
$data['status'] = 'draft';
$record = AbandonCartModel::create($data);
} else {
$record->fill($data);
$record->save();
}
$cookieDays = (int)apply_filters('fluent_crm/ab_cart_cookie_validity', 30);
setcookie('fc_ab_fct_cart_token', $record->checkout_key, time() + (86400 * $cookieDays), COOKIEPATH, COOKIE_DOMAIN);
}
private function buildCustomerData($checkoutData)
{
$formData = Arr::get($checkoutData, 'form_data', []);
return [
'billingAddress' => [
'first_name' => Arr::get($formData, 'billing_first_name', ''),
'last_name' => Arr::get($formData, 'billing_last_name', ''),
'address_1' => Arr::get($formData, 'billing_address_1', ''),
'address_2' => Arr::get($formData, 'billing_address_2', ''),
'postcode' => Arr::get($formData, 'billing_postcode', ''),
'city' => Arr::get($formData, 'billing_city', ''),
'state' => Arr::get($formData, 'billing_state', ''),
'country' => Arr::get($formData, 'billing_country', ''),
'phone' => Arr::get($formData, 'billing_phone', ''),
],
];
}
private function getCurrentRecord($billingEmail = null, $cartHash = null)
{
if ($cartHash) {
// Try to find by cart hash first if available
$record = AbandonCartModel::where('cart_hash', $cartHash)
->where('provider', 'fluent_cart')
->whereIn('status', ['pending', 'opt_out', 'draft', 'processing'])
->first();
if ($record) {
return $record;
}
}
// First try from the cookie
$existingToken = Arr::get($_COOKIE, 'fc_ab_fct_cart_token');
if ($existingToken) {
$record = AbandonCartModel::where('checkout_key', $existingToken)
->where('provider', 'fluent_cart')
->whereIn('status', ['pending', 'opt_out', 'draft', 'processing'])
->first();
if ($record) {
return $record;
}
}
// Try with billing email
if ($billingEmail) {
$record = AbandonCartModel::where('email', $billingEmail)
->where('provider', 'fluent_cart')
->whereIn('status', ['pending', 'opt_out', 'draft', 'processing'])
->first();
if ($record) {
return $record;
}
}
// If user logged in, try with user id
$userId = get_current_user_id();
if ($userId) {
$record = AbandonCartModel::where('user_id', $userId)
->where('provider', 'fluent_cart')
->whereIn('status', ['pending', 'opt_out', 'draft', 'processing'])
->first();
if ($record) {
return $record;
}
}
return null;
}
public function handleOrderCreated($eventData)
{
$order = Arr::get($eventData, 'order');
if (!$order) {
return;
}
$token = sanitize_text_field(Arr::get($_COOKIE, 'fc_ab_fct_cart_token', ''));
if (!$token) {
return;
}
$abCart = AbCartHelper::getAbCartByDataProps([
'checkout_key' => $token
], ['processing', 'draft']);
if (!$abCart || $abCart->provider !== 'fluent_cart') {
return;
}
$abCart->order_id = $order->id;
$abCart->save();
}
public function handleOrderPaid($eventData)
{
$order = Arr::get($eventData, 'order');
$customer = Arr::get($eventData, 'customer');
if (!$order || !$customer) {
return;
}
$abCart = AbandonCartModel::query()->where('order_id', $order->id)->where('provider', 'fluent_cart')->first();
if (!$abCart) {
$this->cancelAutomationsByCustomer($customer);
return;
}
$this->markCartAsRecovered($abCart, $order);
}
public function handleOrderStatusChanged($eventData)
{
$order = Arr::get($eventData, 'order');
$newStatus = Arr::get($eventData, 'new_status');
if (!$order || !$newStatus) {
return;
}
$abCartId = $order->getMeta('_fc_ab_cart_id');
if (!$abCartId) {
return;
}
$abCart = AbandonCartModel::find($abCartId);
if (!$abCart || $abCart->provider !== 'fluent_cart') {
return;
}
$driver = new FluentCartDriver();
if ($driver->isWinOrderStatus($newStatus)) {
if ($abCart->status !== 'recovered') {
$this->markCartAsRecovered($abCart, $order);
}
return;
}
$lostStatuses = ['failed', 'canceled'];
if (in_array($newStatus, $lostStatuses, true)) {
$this->handleCartLost($abCart, $order);
}
}
private function markCartAsRecovered($abCart, $order)
{
$deletableStatuses = ['draft', 'opt_out', 'pending'];
if (in_array($abCart->status, $deletableStatuses, true)) {
$abCart->deleteCart();
$this->deleteOtherCarts($abCart, $order);
return;
}
$recoverableStatuses = ['processing', 'lost', 'cancelled'];
if (!in_array($abCart->status, $recoverableStatuses, true)) {
return;
}
$settings = AbCartHelper::getSettings();
$subscriber = $abCart->subscriber;
if ($subscriber) {
if ($attachLists = Arr::get($settings, 'lists_on_cart_abandoned', [])) {
$subscriber->detachLists($attachLists);
}
if ($attachTags = Arr::get($settings, 'tags_on_cart_abandoned', [])) {
$subscriber->detachTags($attachTags);
}
}
$orderTotal = $order->total_amount ?? 0;
$oldStatus = $abCart->status;
$abCart->status = 'recovered';
$abCart->order_id = $order->id;
$abCart->total = $orderTotal / 100;
$abCart->recovered_at = current_time('mysql');
$abCart->save();
do_action('fluent_crm/ab_cart_fluent_cart_recovered', $abCart, $order, $oldStatus);
$this->deleteOtherCarts($abCart, $order);
$this->handleCartRecoveredAutomations($abCart);
}
private function handleCartLost($abCart, $order)
{
if ($abCart->status == 'lost') {
return;
}
$oldStatus = $abCart->status;
$abCart->status = 'lost';
$abCart->save();
do_action('fluent_crm/ab_cart_fluent_cart_lost', $abCart, $order, $oldStatus);
if ($abCart->automation_id) {
$subscriber = $abCart->subscriber;
if ($subscriber) {
$settings = AbCartHelper::getSettings();
if ($attachLists = Arr::get($settings, 'lists_on_cart_lost', [])) {
$subscriber->attachLists($attachLists);
}
if ($attachTags = Arr::get($settings, 'tags_on_cart_lost', [])) {
$subscriber->attachTags($attachTags);
}
FunnelSubscriber::where('subscriber_id', $subscriber->id)
->where('source_ref_id', $abCart->id)
->whereHas('funnel', function ($q) {
$q->where('trigger_name', 'fc_ab_cart_simulation_fluent_cart');
})
->where('funnel_id', $abCart->automation_id)
->update([
'status' => 'cancelled',
'notes' => __('Automatically cancelled because the cart has been lost', 'fluent-crm')
]);
}
}
}
private function handleCartRecoveredAutomations($abCart)
{
if (!$abCart->automation_id) {
return;
}
$contact = $abCart->subscriber;
if (!$contact) {
return;
}
$this->cancelAutomations($contact);
}
private function cancelAutomations($subscriber)
{
FunnelSubscriber::where('subscriber_id', $subscriber->id)
->whereHas('funnel', function ($q) {
$q->where('trigger_name', 'fc_ab_cart_simulation_fluent_cart');
})
->whereIn('status', ['active', 'pending', 'paused'])
->update([
'status' => 'cancelled',
'notes' => __('Automatically cancelled because a cart has been recovered', 'fluent-crm')
]);
}
private function cancelAutomationsByCustomer($customer)
{
$subscriberIds = Subscriber::select(['id'])
->where('email', $customer->email)
->when($customer->user_id, function ($q) use ($customer) {
return $q->orWhere('user_id', $customer->user_id);
})
->pluck('id')
->toArray();
if (!$subscriberIds) {
return;
}
FunnelSubscriber::whereIn('subscriber_id', $subscriberIds)
->whereHas('funnel', function ($q) {
$q->where('trigger_name', 'fc_ab_cart_simulation_fluent_cart');
})
->whereIn('status', ['active', 'pending', 'paused'])
->update([
'status' => 'cancelled',
'notes' => __('Automatically cancelled because a cart has been recovered', 'fluent-crm')
]);
}
private function deleteOtherCarts($abCart, $order)
{
$customerEmail = $abCart->email;
$customerId = 0;
if (method_exists($order, 'getAttribute')) {
$customerId = $order->customer_id ?? 0;
}
$query = AbandonCartModel::where('provider', 'fluent_cart')
->where('id', '!=', $abCart->id)
->whereIn('status', ['processing', 'draft']);
$query->where(function ($q) use ($customerEmail, $customerId) {
$q->where('email', $customerEmail);
if ($customerId) {
// Look up user_id from the FluentCart customer
$userId = fluentCrmDb()->table('fct_customers')
->where('id', $customerId)
->value('user_id');
if ($userId) {
$q->orWhere('user_id', $userId);
}
}
});
$otherCarts = $query->get();
foreach ($otherCarts as $cart) {
$cart->deleteCart();
}
}
public function maybeRestoreCart($data)
{
$cartHash = sanitize_text_field(Arr::get($data, 'fc_ab_hash', ''));
$abandonCart = null;
if ($cartHash) {
$abandonCart = AbandonCartModel::where('checkout_key', $cartHash)->first();
}
if (!$abandonCart || $abandonCart->status != 'processing' || $abandonCart->provider != 'fluent_cart') {
do_action('fluent_crm/ab_cart_restore_failed', $abandonCart);
$checkoutUrl = home_url();
if (class_exists('\FluentCart\Api\StoreSettings')) {
$checkoutUrl = (new \FluentCart\Api\StoreSettings())->getCheckoutPage() ?: $checkoutUrl;
}
wp_redirect($checkoutUrl);
exit();
}
// Set tracking cookie
$cookieDays = (int)apply_filters('fluent_crm/ab_cart_cookie_validity', 30);
setcookie('fc_ab_fct_cart_token', $abandonCart->checkout_key, time() + (86400 * $cookieDays), COOKIEPATH, COOKIE_DOMAIN);
$abandonCart->click_counts = $abandonCart->click_counts + 1;
$abandonCart->save();
// Restore the FluentCart cart from our snapshot
$snapshot = $abandonCart->cart;
$fctCartHash = $abandonCart->cart_hash;
if ($fctCartHash) {
$fctCart = \FluentCart\App\Models\Cart::where('cart_hash', $fctCartHash)->first();
if ($fctCart) {
// just redirect to checkout if the cart still exists in FluentCart
$checkoutUrl = add_query_arg([
'fct_cart_hash' => $fctCart->cart_hash,
], (new StoreSettings())->getCheckoutPage());
wp_redirect($checkoutUrl);
exit();
}
}
$newCart = new Cart();
$newCart->cart_hash = $abandonCart->cart_hash ?: Cart::generateCartHash();
// Write the snapshot back directly
$newCart->cart_data = Arr::get($snapshot, 'cart_data', []);
$newCart->checkout_data = Arr::get($snapshot, 'checkout_data', []);
$newCart->coupons = Arr::get($snapshot, 'coupons', []);
$newCart->email = $abandonCart->email;
$newCart->utm_data = Arr::get($snapshot, 'utm_data', []);
$newCart->cart_group = 'instant';
$newCart->ip_address = AddressHelper::getIpAddress();
$newCart->user_agent = AddressHelper::getUserAgent();
$fullName = $abandonCart->full_name;
if ($fullName) {
// Try to split full name into first and last name for better compatibility
$nameParts = explode(' ', $fullName, 2);
$newCart->first_name = $nameParts[0];
$newCart->last_name = isset($nameParts[1]) ? $nameParts[1] : '';
}
$newCart->save();
\FluentCart\Api\Cookie\Cookie::setCartHash($newCart->cart_hash);
$abandonCart->cart_hash = $newCart->cart_hash;
$abandonCart->save();
wp_redirect(add_query_arg(['fct_cart_hash' => $newCart->cart_hash], (new \FluentCart\Api\StoreSettings())->getCheckoutPage()));
exit();
}
public function pushContextCodes($codes, $context)
{
if ($context != 'fc_ab_cart_simulation_fluent_cart') {
return $codes;
}
$smartCodes = [
'key' => 'ab_cart_fluent_cart',
'title' => 'Abandoned Cart - FluentCart',
'shortcodes' => [
'{{ab_cart_fluent_cart.billing_email}}' => __('Cart Billing Email', 'fluent-crm'),
'{{ab_cart_fluent_cart.cart_items_table}}' => __('Cart Items', 'fluent-crm'),
'##ab_cart_fluent_cart.recovery_url##' => __('Cart Recovery URL', 'fluent-crm'),
'{{ab_cart_fluent_cart.cart_total}}' => __('Cart Total', 'fluent-crm'),
'{{ab_cart_fluent_cart.subtotal}}' => __('Cart Subtotal (only products)', 'fluent-crm'),
'{{ab_cart_fluent_cart.shipping_total}}' => __('Cart Shipping Total', 'fluent-crm'),
'{{ab_cart_fluent_cart.discount_total}}' => __('Cart Discount Total', 'fluent-crm'),
'{{ab_cart_fluent_cart.coupon_codes}}' => __('Applied Coupon Codes', 'fluent-crm'),
'{{ab_cart_fluent_cart.tax_total}}' => __('Cart Tax Total', 'fluent-crm'),
'{{ab_cart_fluent_cart.billing_full_name}}' => __('Billing Full Name', 'fluent-crm'),
'{{ab_cart_fluent_cart.billing_address}}' => __('Billing Address', 'fluent-crm'),
'{{ab_cart_fluent_cart.shipping_address}}' => __('Shipping Address', 'fluent-crm'),
'{{ab_cart_fluent_cart.billing_city}}' => __('Billing City', 'fluent-crm'),
'{{ab_cart_fluent_cart.billing_state}}' => __('Billing State', 'fluent-crm'),
'{{ab_cart_fluent_cart.billing_postcode}}' => __('Billing Postcode', 'fluent-crm'),
'{{ab_cart_fluent_cart.billing_country}}' => __('Billing Country', 'fluent-crm'),
'{{ab_cart_fluent_cart.billing_phone}}' => __('Billing Phone', 'fluent-crm'),
]
];
$codes[] = $smartCodes;
return $codes;
}
public function parseSmartCodes($code, $valueKey, $defaultValue, $subscriber)
{
$abCart = null;
if ($subscriber->funnel_subscriber_id) {
$funnelSub = FunnelSubscriber::find($subscriber->funnel_subscriber_id);
if ($funnelSub) {
$abCart = AbandonCartModel::find($funnelSub->source_ref_id);
}
}
if (!$abCart) {
$abCart = AbandonCartModel::where('email', $subscriber->email)
->where('provider', 'fluent_cart')
->whereIn('status', ['processing', 'opt_out', 'lost'])
->orderBy('id', 'DESC')
->first();
}
if (!$abCart && defined('FLUENTCRM_PREVIEWING_EMAIL')) {
$abCart = AbandonCartModel::where('provider', 'fluent_cart')
->orderBy('id', 'DESC')
->first();
}
if (!$abCart) {
if (defined('FLUENTCRM_PREVIEWING_EMAIL')) {
return __('Dynamic Text will be available on real email', 'fluent-crm');
}
return $defaultValue;
}
$formatPrice = function ($amount) use ($abCart) {
$driver = new FluentCartDriver();
return $driver->formatPrice($amount, $abCart->currency);
};
switch ($valueKey) {
case 'billing_email':
return $abCart->email;
case 'cart_total':
return $formatPrice($abCart->total);
case 'subtotal':
return $formatPrice($abCart->subtotal);
case 'shipping_total':
return $abCart->shipping ? $formatPrice($abCart->shipping) : $defaultValue;
case 'discount_total':
return ($abCart->discounts > 0) ? $formatPrice($abCart->discounts) : $defaultValue;
case 'coupon_codes':
$couponCodes = Arr::get($abCart->cart, 'coupons', []);
return $couponCodes ? implode(', ', $couponCodes) : $defaultValue;
case 'tax_total':
return $abCart->tax ? $formatPrice($abCart->tax) : $defaultValue;
case 'billing_full_name':
return $abCart->full_name ?: $defaultValue;
case 'billing_address':
case 'shipping_address':
$prefix = ($valueKey === 'shipping_address') ? 'shipping_' : 'billing_';
$formData = Arr::get($abCart->cart, 'checkout_data.form_data', []);
return implode(', ', array_filter([
Arr::get($formData, $prefix . 'address_1'),
Arr::get($formData, $prefix . 'address_2'),
Arr::get($formData, $prefix . 'city'),
Arr::get($formData, $prefix . 'state'),
Arr::get($formData, $prefix . 'postcode'),
])) ?: $defaultValue;
case 'billing_city':
case 'billing_state':
case 'billing_postcode':
case 'billing_country':
case 'billing_phone':
return Arr::get($abCart->cart, 'checkout_data.form_data.' . $valueKey, $defaultValue);
case 'recovery_url':
return add_query_arg([
'fluentcrm' => 1,
'route' => 'general',
'handler' => 'fc_cart_fluent_cart',
'fc_ab_hash' => $abCart->checkout_key
], home_url());
case 'cart_items_table':
return $abCart->getCartItemsHtml();
default:
return apply_filters('fluent_crm/ab_cart_smart_code_default_value', $defaultValue, $valueKey, $abCart);
}
}
}
@@ -0,0 +1,136 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* @var $cartItems array
* @var $currency string
*/
$formatPrice = function ($amount) use ($currency) {
if (class_exists('\FluentCart\Api\CurrencySettings')) {
return \FluentCart\Api\CurrencySettings::getPriceHtml((int)$amount, $currency ?: null);
}
// Fallback: amount is in cents
$symbol = $currency ?: '$';
return $symbol . number_format((float)$amount / 100, 2);
};
?>
<style>
.fc-abandoned-cart-table *,
.fc-abandoned-cart-table {
box-sizing: border-box;
}
@media (max-width: 767px) {
.fc-abandoned-cart-table table thead {
display: none;
}
.fc-abandoned-cart-table table {
display: block !important;
border: none !important;
}
.fc-abandoned-cart-table table tbody {
display: block !important;
width: 100%;
}
.fc-abandoned-cart-table table thead tr th:last-child,
.fc-abandoned-cart-table table thead tr th:nth-child(3),
.fc-abandoned-cart-table table thead tr td:first-child {
width: 100% !important;
}
.fc-abandoned-cart-table table tbody tr td:first-child img {
margin-top: 6px;
margin-bottom: 6px;
}
.fc-abandoned-cart-table table tbody tr {
display: block !important;
flex-direction: column !important;
margin-bottom: 10px;
border: 1px solid rgb(214, 218, 225);
border-radius: 4px;
}
.fc-abandoned-cart-table table tbody tr td:first-child {
border-top: none;
}
.fc-abandoned-cart-table table tbody tr td {
display: flex !important;
width: 100% !important;
border-right: none !important;
gap: 6px;
padding: 0 5px 0 0 !important;
}
.fc-abandoned-cart-table table tbody tr td .table-head {
display: inline-block !important;
margin-right: 6px;
flex: none !important;
}
}
</style>
<div class="fc-abandoned-cart-table">
<table
style="border-spacing: 0;border-collapse: separate;width: 100%;border: 1px solid #D6DAE1;border-radius: 8px;">
<thead>
<tr>
<th style="border-right:1px solid #e9ecf0;background: #EAECF0;padding: 8px 12px;color: #323232;line-height: 26px;font-weight: 700;font-size: 14px;width: 100px;border-top-left-radius: 6px;"><?php esc_html_e('Image', 'fluent-crm'); ?></th>
<th style="min-width: 140px;border-right:1px solid #e9ecf0;background: #EAECF0;padding: 8px 12px;color: #323232;line-height: 26px;font-weight: 700;font-size: 14px;"><?php esc_html_e('Item', 'fluent-crm'); ?></th>
<th style="border-right:1px solid #e9ecf0;background: #EAECF0;padding: 8px 12px;color: #323232;line-height: 26px;font-weight: 700;font-size: 14px;width: 60px;"><?php esc_html_e('Qty', 'fluent-crm'); ?></th>
<th style="border-right:1px solid #e9ecf0;background: #EAECF0;padding: 8px 12px;color: #323232;line-height: 26px;font-weight: 700;font-size: 14px;width: 100px;border-top-right-radius: 6px;"><?php esc_html_e('Price', 'fluent-crm'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($cartItems as $cartItem) {
$imageUrl = \FluentCrm\Framework\Support\Arr::get($cartItem, 'featured_media', '');
if (!$imageUrl) {
$postId = \FluentCrm\Framework\Support\Arr::get($cartItem, 'post_id');
if ($postId) {
$imageUrl = get_the_post_thumbnail_url($postId, 'thumbnail');
}
}
$title = \FluentCrm\Framework\Support\Arr::get($cartItem, 'post_title', '');
$subTitle = \FluentCrm\Framework\Support\Arr::get($cartItem, 'variation_title', '');
if ($subTitle && $title != $subTitle) {
$title .= ' - ' . $subTitle;
}
$quantity = (int)\FluentCrm\Framework\Support\Arr::get($cartItem, 'quantity', 1);
$subtotal = (int)\FluentCrm\Framework\Support\Arr::get($cartItem, 'subtotal', 0);
$discount = (int)\FluentCrm\Framework\Support\Arr::get($cartItem, 'discount_total', 0);
$lineTotal = $subtotal - $discount;
?>
<tr>
<td style="padding: 8px 12px;border-top: 1px solid #e9ecf0;border-right: 1px solid #e9ecf0;">
<div class="table-head"
style="display: none;width: 100px;min-width: 100px;flex:none;background: rgb(234, 236, 240);font-weight: 600;font-size: 14px;padding: 10px 12px;line-height: 1rem;"><?php esc_html_e('Image', 'fluent-crm'); ?></div>
<?php if ($imageUrl): ?>
<img
style="width: 50px;height: 50px;object-fit: contain;display: block;margin-top: 4px;margin-bottom: 4px;"
src="<?php echo esc_url($imageUrl); ?>" alt="<?php echo esc_attr($title); ?>">
<?php endif; ?>
</td>
<td style="padding: 8px 12px;overflow-wrap: break-word;border-top: 1px solid #e9ecf0;border-right: 1px solid #e9ecf0;">
<div class="table-head"
style="display: none;width: 100px;min-width: 100px;flex:none;background: rgb(234, 236, 240);font-weight: 600;font-size: 14px;padding: 10px 12px;line-height: 1rem;"><?php esc_html_e('Item', 'fluent-crm'); ?></div><?php echo esc_html($title); ?>
</td>
<td style="padding: 8px 12px;border-top: 1px solid #e9ecf0;border-right: 1px solid #e9ecf0;">
<div class="table-head"
style="display: none;width: 100px;min-width: 100px;flex:none;background: rgb(234, 236, 240);font-weight: 600;font-size: 14px;padding: 10px 12px;line-height: 1rem;"><?php esc_html_e('Qty', 'fluent-crm'); ?></div><?php echo esc_html($quantity); ?>
</td>
<td style="padding: 8px 12px;border-top: 1px solid #e9ecf0;">
<div class="table-head" style="display: none;width: 100px;min-width: 100px;flex:none;background: rgb(234, 236, 240);font-weight: 600;font-size: 14px;padding: 10px 12px;line-height: 1rem;"><?php esc_html_e('Price', 'fluent-crm'); ?></div><?php echo wp_kses_post($formatPrice($lineTotal)); ?>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
@@ -0,0 +1,76 @@
(function () {
'use strict';
if (typeof fc_ab_fct_cart === 'undefined') {
return;
}
var gdprShown = false;
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function showGDPR() {
if (gdprShown || !fc_ab_fct_cart.__gdpr_message) {
return;
}
gdprShown = true;
var gdprDiv = document.createElement('div');
gdprDiv.id = 'fc_ab_cart_gdpr';
gdprDiv.style.cssText = 'padding: 10px; margin: 10px 0; font-size: 13px; color: #666; background: #f9f9f9; border-radius: 4px;';
gdprDiv.innerHTML = fc_ab_fct_cart.__gdpr_message;
var section = document.getElementById('billing_personal_information_section');
if (section) {
section.appendChild(gdprDiv);
}
document.addEventListener('click', function (e) {
if (!e.target.closest('#fc_ab_opt_out, .fc-ab-cart-opt-out')) {
return;
}
e.preventDefault();
var ajaxUrl = window.fluentcart_checkout_vars && window.fluentcart_checkout_vars.ajaxurl;
if (!ajaxUrl) {
return;
}
var params = new URLSearchParams();
params.append('action', 'fc_ab_fct_cart_skip');
params.append('_nonce', fc_ab_fct_cart.nonce);
var xhr = new XMLHttpRequest();
xhr.open('POST', ajaxUrl, true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
var el = document.getElementById('fc_ab_cart_gdpr');
if (el) {
el.style.display = 'none';
}
}
};
xhr.send(params.toString());
});
}
// Use capture phase since blur doesn't bubble
document.addEventListener('blur', function (e) {
if (e.target.id === 'billing_email' && e.target.value && isValidEmail(e.target.value.trim())) {
showGDPR();
}
}, true);
// Check if email is already filled (e.g., logged-in user)
setTimeout(function () {
var emailField = document.getElementById('billing_email');
if (emailField && emailField.value && isValidEmail(emailField.value.trim())) {
showGDPR();
}
}, 2000);
})();
@@ -0,0 +1,80 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
use FluentCrm\App\Modules\AbandonCart\Drivers\DriverManager;
use FluentCrm\App\Http\Controllers\Controller;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Http\Request\Request;
use FluentCrm\Framework\Support\Arr;
class SettingsController extends Controller
{
public function getSettings(Request $request)
{
$settings = AbCartHelper::getSettings();
$returnData = [
'settings' => $settings
];
$availables = DriverManager::getAvailable();
// Collect provider-specific options from each available driver
foreach ($availables as $driver) {
$providerOptions = $driver->getProviderSettingsResponse();
if ($providerOptions) {
$returnData[$driver->getProviderSlug() . 'Options'] = $providerOptions;
}
}
$returnData['available_providers'] = array_map(function ($driver) {
return [
'slug' => $driver->getProviderSlug(),
'label' => $driver->getProviderLabel(),
'settings_fields' => $driver->getSettingsFields()
];
}, array_values($availables));
return $returnData;
}
public function saveSettings(Request $request)
{
$prevSettings = AbCartHelper::getSettings();
$settings = (array) $request->get('settings', []);
$settings = Arr::only($settings, array_keys($prevSettings));
do_action_ref_array('fluent_crm/abandon_cart_before_settings_save', [&$settings, $prevSettings]);
if (is_wp_error($settings)) {
return $this->sendError([
'message' => $settings->get_error_message()
], 422);
}
$isEnabled = Arr::get($settings, 'enabled') === 'yes';
if ($isEnabled) {
AbandonCartMigrator::migrate();
}
/*
* Adding this to experimental settings so we don't have to do extra query
*/
$experiments = Helper::getExperimentalSettings();
$experiments['abandoned_cart'] = $isEnabled ? 'yes' : 'no';
update_option('_fluentcrm_experimental_settings', $experiments, 'yes');
update_option('_fc_ab_cart_settings', $settings);
return [
'message' => __('Settings has been saved successfully', 'fluent-crm'),
'reload' => $prevSettings['enabled'] !== $settings['enabled'],
'settings' => $settings
];
}
}