Initial commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,481 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\CLI;
|
||||
|
||||
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\Funnel\FunnelProcessor;
|
||||
|
||||
class SimulateFunnelCommand
|
||||
{
|
||||
/*
|
||||
* Fast-forward a subscriber through an automation funnel, skipping wait times.
|
||||
* Real actions will fire (tags applied, emails sent, etc.) — only delays are shortened.
|
||||
*
|
||||
* Usage:
|
||||
* wp fluent_crm simulate_funnel --funnel_id=123 --email=john@example.com
|
||||
* wp fluent_crm simulate_funnel --funnel_id=123 --subscriber_id=456
|
||||
* wp fluent_crm simulate_funnel --funnel_id=123 --email=john@example.com --sleep=1 --max_steps=50
|
||||
* wp fluent_crm simulate_funnel --funnel_id=123 --email=john@example.com --sleep=0
|
||||
*
|
||||
* --sleep=0 runs one step at a time (step mode). Run the command again to advance to the next step.
|
||||
*/
|
||||
public function handle($args, $assoc_args)
|
||||
{
|
||||
$funnelId = \WP_CLI\Utils\get_flag_value($assoc_args, 'funnel_id');
|
||||
$subscriberId = \WP_CLI\Utils\get_flag_value($assoc_args, 'subscriber_id');
|
||||
$email = \WP_CLI\Utils\get_flag_value($assoc_args, 'email');
|
||||
$sleepSeconds = intval(\WP_CLI\Utils\get_flag_value($assoc_args, 'sleep', 2));
|
||||
$maxSteps = max(1, intval(\WP_CLI\Utils\get_flag_value($assoc_args, 'max_steps', 100)));
|
||||
$stepMode = $sleepSeconds === 0;
|
||||
|
||||
if ($sleepSeconds < 0) {
|
||||
$sleepSeconds = 0;
|
||||
}
|
||||
|
||||
if (!$funnelId) {
|
||||
\WP_CLI::error('--funnel_id is required');
|
||||
}
|
||||
|
||||
$funnel = Funnel::find(intval($funnelId));
|
||||
if (!$funnel) {
|
||||
\WP_CLI::error('Funnel not found');
|
||||
}
|
||||
|
||||
if ($subscriberId) {
|
||||
$subscriber = Subscriber::find(intval($subscriberId));
|
||||
} elseif ($email) {
|
||||
$subscriber = Subscriber::where('email', sanitize_email($email))->first();
|
||||
} else {
|
||||
\WP_CLI::error('--subscriber_id or --email is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$subscriber) {
|
||||
\WP_CLI::error('Subscriber not found');
|
||||
}
|
||||
|
||||
\WP_CLI::line('---');
|
||||
\WP_CLI::line(sprintf('Funnel: %s (#%d) - Status: %s', $funnel->title, $funnel->id, $funnel->status));
|
||||
\WP_CLI::line(sprintf('Subscriber: %s (#%d) - Status: %s', $subscriber->email, $subscriber->id, $subscriber->status));
|
||||
if ($stepMode) {
|
||||
\WP_CLI::line('Mode: step-by-step (--sleep=0)');
|
||||
} else {
|
||||
\WP_CLI::line(sprintf('Wait times will be reduced to %d second(s)', $sleepSeconds));
|
||||
}
|
||||
\WP_CLI::line('---');
|
||||
|
||||
// Print funnel step map
|
||||
$this->printFunnelSteps($funnel->id);
|
||||
|
||||
if ($funnel->status !== 'published') {
|
||||
\WP_CLI::warning('This funnel is not published. Proceeding anyway...');
|
||||
}
|
||||
|
||||
// Check existing enrollment
|
||||
$funnelSub = FunnelSubscriber::where('funnel_id', $funnel->id)
|
||||
->where('subscriber_id', $subscriber->id)
|
||||
->first();
|
||||
|
||||
if ($funnelSub) {
|
||||
if (in_array($funnelSub->status, ['completed', 'cancelled'])) {
|
||||
\WP_CLI::line(sprintf('Subscriber already %s this funnel.', $funnelSub->status));
|
||||
\WP_CLI::confirm('Re-enroll and start fresh?');
|
||||
$this->resetFunnelEnrollment($funnel->id, $subscriber->id, $funnelSub->id);
|
||||
$funnelSub = null;
|
||||
} elseif (in_array($funnelSub->status, ['active', 'waiting'])) {
|
||||
$nextSeq = $funnelSub->next_sequence_id ? FunnelSequence::find($funnelSub->next_sequence_id) : null;
|
||||
$nextLabel = $nextSeq ? ($nextSeq->title ?: $nextSeq->action_name) : 'unknown';
|
||||
\WP_CLI::line(sprintf('Subscriber is already in this funnel (status: %s, next: %s).', $funnelSub->status, $nextLabel));
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
|
||||
fwrite(STDOUT, 'Resume or Restart? (resume/restart): ');
|
||||
$choice = strtolower(trim(fgets(STDIN)));
|
||||
|
||||
if ($choice === 'restart') {
|
||||
$this->resetFunnelEnrollment($funnel->id, $subscriber->id, $funnelSub->id);
|
||||
$funnelSub = null;
|
||||
\WP_CLI::line('Restarting from the beginning...');
|
||||
} else {
|
||||
\WP_CLI::line('Resuming from current position...');
|
||||
}
|
||||
} else {
|
||||
\WP_CLI::line(sprintf('Current enrollment status: %s', $funnelSub->status));
|
||||
}
|
||||
}
|
||||
|
||||
// In auto-advance mode, minimize wait times so we don't actually wait days
|
||||
if (!$stepMode) {
|
||||
$filterDelay = max(1, $sleepSeconds);
|
||||
add_filter('fluent_crm/funnel_seq_delay_in_seconds', function () use ($filterDelay) {
|
||||
return $filterDelay;
|
||||
}, 99999, 4);
|
||||
}
|
||||
|
||||
$processor = new FunnelProcessor();
|
||||
|
||||
$firedHooks = [];
|
||||
|
||||
// Enroll if not already
|
||||
if (!$funnelSub) {
|
||||
\WP_CLI::line('Enrolling subscriber into funnel...');
|
||||
|
||||
$hooksBefore = $this->snapshotHooks();
|
||||
$processor->startSequences($subscriber, $funnel);
|
||||
$firedHooks = array_merge($firedHooks, $this->diffHooks($hooksBefore));
|
||||
|
||||
$funnelSub = FunnelSubscriber::where('funnel_id', $funnel->id)
|
||||
->where('subscriber_id', $subscriber->id)
|
||||
->first();
|
||||
|
||||
if (!$funnelSub) {
|
||||
\WP_CLI::error('Failed to enroll — funnel may have no sequences');
|
||||
}
|
||||
|
||||
$this->showExecutedMetrics($funnel->id, $subscriber->id, 'Enrollment');
|
||||
|
||||
// In step mode, stop after enrollment — next run will resume
|
||||
if ($stepMode) {
|
||||
$this->showStepModeNextUp($funnelSub);
|
||||
$this->askAndShowFiredHooks($firedHooks);
|
||||
$this->showFinalStatus($funnel->id, $subscriber->id, $funnelSub->id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// In step mode, process exactly one batch then stop
|
||||
if ($stepMode) {
|
||||
$lastMetricId = (int) FunnelMetric::where('funnel_id', $funnel->id)
|
||||
->where('subscriber_id', $subscriber->id)
|
||||
->max('id');
|
||||
|
||||
$hooksBefore = $this->snapshotHooks();
|
||||
$this->processOneStep($processor, $funnelSub);
|
||||
$firedHooks = array_merge($firedHooks, $this->diffHooks($hooksBefore));
|
||||
|
||||
// Show what was processed in this step
|
||||
$newMetrics = FunnelMetric::where('funnel_id', $funnel->id)
|
||||
->where('subscriber_id', $subscriber->id)
|
||||
->where('id', '>', $lastMetricId)
|
||||
->orderBy('id', 'ASC')
|
||||
->get();
|
||||
|
||||
if ($newMetrics->count()) {
|
||||
\WP_CLI::line(sprintf('Processed %d action(s):', $newMetrics->count()));
|
||||
foreach ($newMetrics as $metric) {
|
||||
$seq = FunnelSequence::find($metric->sequence_id);
|
||||
if ($seq) {
|
||||
\WP_CLI::line(sprintf(' > [%s] %s', $seq->action_name, $seq->title ?: ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$funnelSub = FunnelSubscriber::find($funnelSub->id);
|
||||
$this->showStepModeNextUp($funnelSub);
|
||||
$this->askAndShowFiredHooks($firedHooks);
|
||||
$this->showFinalStatus($funnel->id, $subscriber->id, $funnelSub->id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-forward remaining steps
|
||||
$step = 0;
|
||||
|
||||
while ($step < $maxSteps) {
|
||||
$shouldBreak = $this->checkTerminalStatus($funnelSub);
|
||||
if ($shouldBreak) {
|
||||
break;
|
||||
}
|
||||
|
||||
$step++;
|
||||
|
||||
// Show what's about to execute
|
||||
$nextSeq = $funnelSub->next_sequence_id ? FunnelSequence::find($funnelSub->next_sequence_id) : null;
|
||||
if ($nextSeq) {
|
||||
\WP_CLI::line(sprintf('[Step %d] %s: %s', $step, $nextSeq->action_name, $nextSeq->title ?: ''));
|
||||
}
|
||||
|
||||
// Force execution time to now
|
||||
FunnelSubscriber::where('id', $funnelSub->id)->update([
|
||||
'next_execution_time' => current_time('mysql'),
|
||||
]);
|
||||
$funnelSub->next_execution_time = current_time('mysql');
|
||||
|
||||
// Process the next step
|
||||
$hooksBefore = $this->snapshotHooks();
|
||||
$processor->processFunnelAction($funnelSub);
|
||||
$firedHooks = array_merge($firedHooks, $this->diffHooks($hooksBefore));
|
||||
|
||||
sleep($sleepSeconds);
|
||||
|
||||
// Reload for next iteration
|
||||
$funnelSub = FunnelSubscriber::find($funnelSub->id);
|
||||
}
|
||||
|
||||
if ($step >= $maxSteps) {
|
||||
\WP_CLI::warning(sprintf('Reached max steps limit (%d). Use --max_steps to increase.', $maxSteps));
|
||||
}
|
||||
|
||||
$this->askAndShowFiredHooks($firedHooks);
|
||||
$this->showFinalStatus($funnel->id, $subscriber->id, $funnelSub ? $funnelSub->id : null);
|
||||
}
|
||||
|
||||
private function resetFunnelEnrollment($funnelId, $subscriberId, $funnelSubId)
|
||||
{
|
||||
FunnelMetric::where('funnel_id', $funnelId)
|
||||
->where('subscriber_id', $subscriberId)
|
||||
->delete();
|
||||
FunnelSubscriber::where('id', $funnelSubId)->delete();
|
||||
}
|
||||
|
||||
private function showExecutedMetrics($funnelId, $subscriberId, $label)
|
||||
{
|
||||
$metrics = FunnelMetric::where('funnel_id', $funnelId)
|
||||
->where('subscriber_id', $subscriberId)
|
||||
->orderBy('id', 'ASC')
|
||||
->get();
|
||||
|
||||
if ($metrics->count()) {
|
||||
\WP_CLI::line(sprintf('%s processed %d step(s):', $label, $metrics->count()));
|
||||
foreach ($metrics as $metric) {
|
||||
$seq = FunnelSequence::find($metric->sequence_id);
|
||||
if ($seq) {
|
||||
\WP_CLI::line(sprintf(' > [%s] %s', $seq->action_name, $seq->title ?: ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function processOneStep($processor, $funnelSub)
|
||||
{
|
||||
$funnelSub = FunnelSubscriber::find($funnelSub->id);
|
||||
|
||||
$shouldBreak = $this->checkTerminalStatus($funnelSub);
|
||||
if ($shouldBreak) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force execution time to now so processFunnelAction picks it up
|
||||
FunnelSubscriber::where('id', $funnelSub->id)->update([
|
||||
'next_execution_time' => current_time('mysql'),
|
||||
]);
|
||||
$funnelSub->next_execution_time = current_time('mysql');
|
||||
|
||||
// Use the real processor — SequencePoints resolves next batch,
|
||||
// processSequencePoints executes it
|
||||
$processor->processFunnelAction($funnelSub);
|
||||
}
|
||||
|
||||
private function showStepModeNextUp($funnelSub)
|
||||
{
|
||||
if (!$funnelSub) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($funnelSub->status === 'active' && $funnelSub->next_sequence_id) {
|
||||
$upNext = FunnelSequence::find($funnelSub->next_sequence_id);
|
||||
\WP_CLI::line(sprintf(
|
||||
'Up next: [%s] %s',
|
||||
$upNext ? $upNext->action_name : '?',
|
||||
$upNext ? ($upNext->title ?: '') : ''
|
||||
));
|
||||
\WP_CLI::line('Run the command again to advance.');
|
||||
}
|
||||
}
|
||||
|
||||
private function checkTerminalStatus($funnelSub)
|
||||
{
|
||||
if (!$funnelSub) {
|
||||
\WP_CLI::error('Funnel subscriber record not found');
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($funnelSub->status === 'completed') {
|
||||
\WP_CLI::success('Funnel completed!');
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($funnelSub->status === 'cancelled') {
|
||||
\WP_CLI::warning('Funnel cancelled (subscriber may not be in a processable status)');
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($funnelSub->status === 'waiting') {
|
||||
$seq = $funnelSub->next_sequence_id ? FunnelSequence::find($funnelSub->next_sequence_id) : null;
|
||||
\WP_CLI::warning(sprintf(
|
||||
'Blocked on benchmark: %s — cannot auto-advance past goals.',
|
||||
$seq ? ($seq->title ?: $seq->action_name) : 'unknown'
|
||||
));
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($funnelSub->status === 'pending') {
|
||||
\WP_CLI::warning('Subscriber is pending (needs double opt-in). Cannot auto-advance.');
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($funnelSub->status !== 'active' || !$funnelSub->next_execution_time) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function snapshotHooks()
|
||||
{
|
||||
global $wp_actions;
|
||||
return $wp_actions ?: [];
|
||||
}
|
||||
|
||||
private function diffHooks($before)
|
||||
{
|
||||
global $wp_actions;
|
||||
$after = $wp_actions ?: [];
|
||||
$fired = [];
|
||||
|
||||
foreach ($after as $hook => $count) {
|
||||
$prevCount = isset($before[$hook]) ? $before[$hook] : 0;
|
||||
if ($count > $prevCount) {
|
||||
// Only include fluentcrm-related hooks
|
||||
if (strpos($hook, 'fluentcrm') !== false || strpos($hook, 'fluent_crm') !== false) {
|
||||
$fired[$hook] = $count - $prevCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $fired;
|
||||
}
|
||||
|
||||
private function askAndShowFiredHooks($firedHooks)
|
||||
{
|
||||
if (empty($firedHooks)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
|
||||
fwrite(STDOUT, sprintf('Show fired hooks? (%d hooks) (yes/no): ', count($firedHooks)));
|
||||
$answer = strtolower(trim(fgets(STDIN)));
|
||||
|
||||
if ($answer !== 'yes' && $answer !== 'y') {
|
||||
return;
|
||||
}
|
||||
|
||||
\WP_CLI::line('Fired hooks:');
|
||||
foreach ($firedHooks as $hook => $count) {
|
||||
$suffix = $count > 1 ? sprintf(' (x%d)', $count) : '';
|
||||
\WP_CLI::line(sprintf(' > %s%s', $hook, $suffix));
|
||||
}
|
||||
}
|
||||
|
||||
private function showFinalStatus($funnelId, $subscriberId, $funnelSubId)
|
||||
{
|
||||
$funnelSub = $funnelSubId ? FunnelSubscriber::find($funnelSubId) : null;
|
||||
$totalMetrics = FunnelMetric::where('funnel_id', $funnelId)
|
||||
->where('subscriber_id', $subscriberId)
|
||||
->count();
|
||||
|
||||
\WP_CLI::line('---');
|
||||
\WP_CLI::line(sprintf('Final status: %s', $funnelSub ? $funnelSub->status : 'unknown'));
|
||||
\WP_CLI::line(sprintf('Total actions executed: %d', $totalMetrics));
|
||||
}
|
||||
|
||||
private function printFunnelSteps($funnelId)
|
||||
{
|
||||
$sequences = FunnelSequence::where('funnel_id', $funnelId)
|
||||
->orderBy('sequence', 'ASC')
|
||||
->get();
|
||||
|
||||
if ($sequences->isEmpty()) {
|
||||
\WP_CLI::line('No steps in this funnel.');
|
||||
\WP_CLI::line('---');
|
||||
return;
|
||||
}
|
||||
|
||||
// Group children by parent_id and condition_type
|
||||
$topLevel = [];
|
||||
$children = []; // $children[$parentId][$conditionType][]
|
||||
foreach ($sequences as $seq) {
|
||||
if (!$seq->parent_id) {
|
||||
$topLevel[] = $seq;
|
||||
} else {
|
||||
$children[$seq->parent_id][$seq->condition_type][] = $seq;
|
||||
}
|
||||
}
|
||||
|
||||
\WP_CLI::line('Funnel steps:');
|
||||
$this->printSequenceList($topLevel, $children, ' ');
|
||||
\WP_CLI::line('---');
|
||||
}
|
||||
|
||||
private function printSequenceList($sequences, $children, $indent)
|
||||
{
|
||||
$count = count($sequences);
|
||||
foreach ($sequences as $i => $seq) {
|
||||
$label = $this->formatSequenceLabel($seq);
|
||||
$isLast = ($i === $count - 1);
|
||||
$connector = $isLast ? '└─' : '├─';
|
||||
\WP_CLI::line($indent . $connector . ' ' . $label);
|
||||
|
||||
// If conditional/ab-test, print branches
|
||||
if ($seq->type === 'conditional' && isset($children[$seq->id])) {
|
||||
$childIndent = $indent . ($isLast ? ' ' : '│ ');
|
||||
$branches = $children[$seq->id];
|
||||
|
||||
if (isset($branches['yes'])) {
|
||||
\WP_CLI::line($childIndent . '├─ [YES]:');
|
||||
$this->printSequenceList($branches['yes'], $children, $childIndent . '│ ');
|
||||
}
|
||||
if (isset($branches['no'])) {
|
||||
\WP_CLI::line($childIndent . '└─ [NO]:');
|
||||
$this->printSequenceList($branches['no'], $children, $childIndent . ' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function formatSequenceLabel($seq)
|
||||
{
|
||||
$type = $seq->type ?: 'action';
|
||||
$title = $seq->title ?: $seq->action_name;
|
||||
|
||||
if ($seq->action_name === 'fluentcrm_wait_times') {
|
||||
$wait = $this->formatWaitTime($seq->settings);
|
||||
return sprintf('(%s) %s — %s', $type, $title, $wait);
|
||||
}
|
||||
|
||||
if ($seq->action_name === 'end_this_funnel') {
|
||||
return sprintf('(%s) End Funnel', $type);
|
||||
}
|
||||
|
||||
return sprintf('(%s) %s', $type, $title);
|
||||
}
|
||||
|
||||
private function formatWaitTime($settings)
|
||||
{
|
||||
if (!is_array($settings)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$waitType = $settings['wait_type'] ?? '';
|
||||
|
||||
if ($waitType === 'timestamp_wait') {
|
||||
return 'until ' . ($settings['wait_date_time'] ?? '?');
|
||||
}
|
||||
|
||||
if ($waitType === 'to_day') {
|
||||
$day = $settings['wait_day_of_week'] ?? '?';
|
||||
$time = $settings['wait_time_of_day'] ?? '';
|
||||
return sprintf('next %s%s', $day, $time ? ' at ' . $time : '');
|
||||
}
|
||||
|
||||
if ($waitType === 'by_custom_field') {
|
||||
return 'until custom field date';
|
||||
}
|
||||
|
||||
$amount = $settings['wait_time_amount'] ?? '?';
|
||||
$unit = $settings['wait_time_unit'] ?? 'days';
|
||||
return sprintf('%s %s', $amount, $unit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
/**
|
||||
* ActivationHandler Class
|
||||
*
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class ActivationHandler
|
||||
{
|
||||
public function handle($network_wide = false)
|
||||
{
|
||||
// Run DB Migrations
|
||||
require_once(FLUENTCRM_PLUGIN_PATH . 'database/FluentCRMDBMigrator.php');
|
||||
|
||||
// Task scheduler for sending emails
|
||||
$this->registerWpCron();
|
||||
|
||||
// Default global settings/options
|
||||
$this->addDefaultGlobalSettings();
|
||||
}
|
||||
|
||||
public function registerWpCron()
|
||||
{
|
||||
add_filter('cron_schedules', function ($schedules) {
|
||||
|
||||
$schedules['fluentcrm_every_minute'] = array(
|
||||
'interval' => 300,
|
||||
'display' => esc_html__('Every Minute (FluentCRM)', 'fluent-crm'),
|
||||
);
|
||||
|
||||
$schedules['fluentcrm_scheduled_five_minute_tasks'] = array(
|
||||
'interval' => 300,
|
||||
'display' => esc_html__('Every 5 Minutes (FluentCRM)', 'fluent-crm'),
|
||||
);
|
||||
|
||||
return $schedules;
|
||||
}, 10, 1);
|
||||
|
||||
if (function_exists('\as_has_scheduled_action')) {
|
||||
if (!as_has_scheduled_action('fluentcrm_scheduled_every_minute_tasks')) {
|
||||
as_schedule_recurring_action(time(), 60, 'fluentcrm_scheduled_every_minute_tasks', [], 'fluent-crm');
|
||||
}
|
||||
}
|
||||
|
||||
$hookName = 'fluentcrm_scheduled_five_minute_tasks';
|
||||
if (!wp_next_scheduled($hookName)) {
|
||||
wp_schedule_event(time(), 'fluentcrm_scheduled_five_minute_tasks', $hookName);
|
||||
}
|
||||
|
||||
$hourlyHook = 'fluentcrm_scheduled_hourly_tasks';
|
||||
if (!wp_next_scheduled($hourlyHook)) {
|
||||
wp_schedule_event(time(), 'hourly', $hourlyHook);
|
||||
}
|
||||
|
||||
$weeklyHook = 'fluentcrm_scheduled_weekly_tasks';
|
||||
if (!wp_next_scheduled($weeklyHook)) {
|
||||
wp_schedule_event(time(), 'weekly', $weeklyHook);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function addDefaultGlobalSettings()
|
||||
{
|
||||
$key = 'fluentcrm-global-settings';
|
||||
|
||||
$defaults = [
|
||||
'campaign' => [
|
||||
'from' => [
|
||||
'name' => '',
|
||||
'email' => ''
|
||||
]
|
||||
],
|
||||
'email' => [
|
||||
'emails_per_second' => 4
|
||||
]
|
||||
];
|
||||
|
||||
$settings = get_option($key) ?: [];
|
||||
|
||||
update_option($key, array_merge($defaults, $settings));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
// php
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Models\ActivityLog;
|
||||
use FluentCrm\App\Models\Lists;
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Models\Tag;
|
||||
|
||||
class ActivityLogHandler
|
||||
{
|
||||
protected $objectTypeContact = 'FluentCrm\App\Models\Subscriber';
|
||||
|
||||
// Call this once (e.g., on plugins_loaded) to attach the hooks
|
||||
public function register()
|
||||
{
|
||||
return;
|
||||
// if (!$this->activityLogEnabled()) {
|
||||
// return;
|
||||
// }
|
||||
// Contact created
|
||||
add_action('fluent_crm/contact_created', [$this, 'onContactCreated'], 10, 2);
|
||||
|
||||
// Tags updated
|
||||
add_action('fluent_crm/contact_added_to_tags', [$this, 'onTagsAdded'], 10, 3);
|
||||
add_action('fluent_crm/contact_removed_from_tags', [$this, 'onTagsRemoved'], 10, 3);
|
||||
|
||||
// Lists updated
|
||||
add_action('fluent_crm/contact_added_to_lists', [$this, 'onListsAdded'], 10, 3);
|
||||
add_action('fluent_crm/contact_removed_from_lists', [$this, 'onListsRemoved'], 10, 3);
|
||||
|
||||
// Bulk delete subscribers
|
||||
add_action('fluentcrm_before_subscribers_deleted', [$this, 'onSubscribersDeleted'], 10,2);
|
||||
}
|
||||
|
||||
public function onContactCreated($contact, $source = 'wp-admin')
|
||||
{
|
||||
$contactId = $this->contactId($contact);
|
||||
if (!$contactId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$email = $this->contactField($contact, 'email');
|
||||
$name = trim($this->contactField($contact, 'first_name') . ' ' . $this->contactField($contact, 'last_name'));
|
||||
|
||||
$description = 'Contact Name: ' . $name . ' | Contact Email: ' . $email;
|
||||
|
||||
$this->log([
|
||||
'object_type' => $this->objectTypeContact,
|
||||
'object_id' => $contactId,
|
||||
'action' => 'created contact',
|
||||
'source' => $source,
|
||||
'description' => $description
|
||||
]);
|
||||
}
|
||||
|
||||
public function onSubscribersDeleted($subscriberIds, $source = 'wp-admin')
|
||||
{
|
||||
$subscriberIds = array_values(array_filter((array) $subscriberIds));
|
||||
if (empty($subscriberIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberIds = array_values(array_filter((array) $subscriberIds));
|
||||
$emails = Subscriber::whereIn('id', $subscriberIds)->take(10)->pluck('email')->toArray();
|
||||
$commaSeparatedEmails = implode(',', $emails);
|
||||
if (count($subscriberIds) > 10) {
|
||||
$commaSeparatedEmails .= '...' . ' (and ' . (count($subscriberIds) - 10) . ' more)';
|
||||
}
|
||||
$description = 'Deleted Contacts Emails: ' . $commaSeparatedEmails;
|
||||
|
||||
$this->log([
|
||||
'object_type' => $this->objectTypeContact,
|
||||
'object_id' => 0,
|
||||
'action' => 'deleted contacts',
|
||||
'source' => $source,
|
||||
'description' => $description
|
||||
]);
|
||||
}
|
||||
|
||||
public function onTagsAdded($contact, $tagIds, $source = 'wp-admin')
|
||||
{
|
||||
$this->logTags($contact, $tagIds, 'added tag to contact', $source);
|
||||
}
|
||||
|
||||
public function onTagsRemoved($contact, $tagIds, $source = 'wp-admin')
|
||||
{
|
||||
$this->logTags($contact, $tagIds, 'removed tag from contact', $source);
|
||||
}
|
||||
|
||||
public function onListsAdded($contact, $listIds, $source = 'wp-admin')
|
||||
{
|
||||
$this->logLists($contact, $listIds, 'added list to contact', $source);
|
||||
}
|
||||
|
||||
public function onListsRemoved($contact, $listIds, $source = 'wp-admin')
|
||||
{
|
||||
$this->logLists($contact, $listIds, 'removed list from contact', $source);
|
||||
}
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------------------
|
||||
| Helpers
|
||||
|----------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
protected function logTags($contact, $tagIds, $action, $source)
|
||||
{
|
||||
$contactId = $this->contactId($contact);
|
||||
if (!$contactId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tagIds = array_values(array_filter((array) $tagIds));
|
||||
$subscriberEmail = Subscriber::where('id', $contactId)->value('email');
|
||||
$commaSeparatedTitles = implode(',', Tag::whereIn('id', $tagIds)->pluck('title')->toArray());
|
||||
$description = 'Tags: ' . $commaSeparatedTitles . ' | Contact: ' . $subscriberEmail;
|
||||
|
||||
$this->log([
|
||||
'object_type' => $this->objectTypeContact,
|
||||
'object_id' => $contactId,
|
||||
'action' => $action,
|
||||
'source' => $source,
|
||||
'description' => $description
|
||||
]);
|
||||
}
|
||||
|
||||
protected function logLists($contact, $listIds, $action, $source)
|
||||
{
|
||||
$contactId = $this->contactId($contact);
|
||||
if (!$contactId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$listIds = array_values(array_filter((array) $listIds));
|
||||
|
||||
$subscriberEmail = Subscriber::where('id', $contactId)->value('email');
|
||||
$commaSeparatedTitles = implode(',', Lists::whereIn('id', $listIds)->pluck('title')->toArray());
|
||||
$description = 'Lists: ' . $commaSeparatedTitles . ' | Contact: ' . $subscriberEmail;
|
||||
|
||||
$this->log([
|
||||
'object_type' => $this->objectTypeContact,
|
||||
'object_id' => $contactId,
|
||||
'action' => $action,
|
||||
'source' => $source,
|
||||
'description' => $description
|
||||
]);
|
||||
}
|
||||
|
||||
protected function log(array $data)
|
||||
{
|
||||
ActivityLog::create([
|
||||
'object_type' => $data['object_type'] ?? 'contact',
|
||||
'object_id' => $data['object_id'] ?? null,
|
||||
'action' => $data['action'] ?? 'unknown',
|
||||
'source' => $data['source'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'activity_by' => $this->currentUserId()
|
||||
]);
|
||||
}
|
||||
|
||||
protected function contactId($contact)
|
||||
{
|
||||
if (is_object($contact)) {
|
||||
// FluentCRM Contact model uses `id`
|
||||
return isset($contact->id) ? (int) $contact->id : null;
|
||||
}
|
||||
if (is_array($contact)) {
|
||||
return isset($contact['id']) ? (int) $contact['id'] : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function contactField($contact, $key)
|
||||
{
|
||||
if (is_object($contact)) {
|
||||
return isset($contact->{$key}) ? $contact->{$key} : '';
|
||||
}
|
||||
if (is_array($contact)) {
|
||||
return isset($contact[$key]) ? $contact[$key] : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
protected function currentUserId(): int
|
||||
{
|
||||
if (function_exists('get_current_user_id')) {
|
||||
return (int) get_current_user_id();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function activityLogEnabled()
|
||||
{
|
||||
$settings = get_option('_fluentcrm_experimental_settings', []);
|
||||
if (isset($settings['activity_log']) && $settings['activity_log'] == 'no') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Services\PermissionManager;
|
||||
use FluentCrm\App\Services\Stats;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
/**
|
||||
* Admin Bar Class
|
||||
*
|
||||
* Used for Quick Access to CRM
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
class AdminBar
|
||||
{
|
||||
public function init()
|
||||
{
|
||||
$contactPermission = PermissionManager::currentUserCan('fcrm_read_contacts');
|
||||
|
||||
/**
|
||||
* Determine whether the FluentCRM admin bar search is enabled or not.
|
||||
*
|
||||
* @return bool False Default is false or disabled.
|
||||
*/
|
||||
if (!is_admin() || !$contactPermission || apply_filters('fluent_crm/disable_adminbar_search', apply_filters('fluent_crm/disable_global_search', false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_action('admin_bar_menu', [$this, 'addAdminBarSearch'], 999);
|
||||
}
|
||||
|
||||
public function addAdminBarSearch($adminBar)
|
||||
{
|
||||
wp_enqueue_script(
|
||||
'fluentcrm_adminbar_search',
|
||||
fluentCrmMix('/admin/js/adminbar-search.js'),
|
||||
['jquery']
|
||||
);
|
||||
|
||||
$urlBase = fluentcrm_menu_url_base();
|
||||
|
||||
$currentScreen = get_current_screen();
|
||||
$editingUserVars = null;
|
||||
if ($currentScreen && $currentScreen->id == 'user-edit') {
|
||||
$userId = (int) Arr::get($_REQUEST, 'user_id');
|
||||
$user = get_user_by('ID', $userId);
|
||||
|
||||
if ($userId && $user) {
|
||||
$crmProfile = Subscriber::where('email', $user->user_email)
|
||||
->orWhere('user_id', $user->ID)
|
||||
->first();
|
||||
if ($crmProfile) {
|
||||
$crmProfileUrl = $urlBase . 'subscribers/' . $crmProfile->id;
|
||||
$editingUserVars = [
|
||||
'user_id' => $userId,
|
||||
'crm_profile_id' => $crmProfile->id,
|
||||
'crm_profile_url' => $crmProfileUrl
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wp_localize_script('fluentcrm_adminbar_search', 'fcrm_adminbar_search_vars', [
|
||||
'rest' => $this->getRestInfo(),
|
||||
'links' => (new Stats)->getQuickLinks(),
|
||||
'subscriber_base' => $urlBase . 'subscribers/',
|
||||
'edit_user_vars' => $editingUserVars,
|
||||
'trans' => [
|
||||
'Search Contacts' => __('Search Contacts', 'fluent-crm'),
|
||||
'Type and press enter' => __('Type and press enter', 'fluent-crm'),
|
||||
'Type to search contacts' => __('Type to search contacts', 'fluent-crm'),
|
||||
'Quick Links' => __('Quick Links', 'fluent-crm'),
|
||||
'Sorry no contact found' => __('Sorry no contact found', 'fluent-crm'),
|
||||
'Load More' => __('Load More', 'fluent-crm'),
|
||||
'Close' => __('Close', 'fluent-crm')
|
||||
]
|
||||
]);
|
||||
|
||||
$args = [
|
||||
'parent' => 'top-secondary',
|
||||
'id' => 'fcrm_adminbar_search',
|
||||
'title' => __('Search Contacts', 'fluent-crm'),
|
||||
'href' => '#',
|
||||
'meta' => false
|
||||
];
|
||||
|
||||
$adminBar->add_node($args);
|
||||
}
|
||||
|
||||
protected function getRestInfo()
|
||||
{
|
||||
$app = FluentCrm();
|
||||
|
||||
$ns = $app->config->get('app.rest_namespace');
|
||||
$v = $app->config->get('app.rest_version');
|
||||
|
||||
return [
|
||||
'base_url' => esc_url_raw(rest_url()),
|
||||
'url' => rest_url($ns . '/' . $v),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'namespace' => $ns,
|
||||
'version' => $v
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,381 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Services\AutoSubscribe;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
/**
|
||||
* AutoSubscribeHandler Class
|
||||
*
|
||||
* Used to handle the auto-subscribe functionality for different WordPress Events.
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class AutoSubscribeHandler
|
||||
{
|
||||
|
||||
public function register()
|
||||
{
|
||||
add_action('user_register', array($this, 'userRegistrationHandler'), 99, 1);
|
||||
add_action('comment_post', array($this, 'handleCommentPost'), 99, 3);
|
||||
add_action('profile_update', array($this, 'syncUserUpdate'), 10, 3);
|
||||
add_action('delete_user', array($this, 'maybeDeleteContact'), 10, 3);
|
||||
add_action('woocommerce_customer_save_address', array($this, 'syncWooAddressUpdate'), 10, 2);
|
||||
|
||||
add_action('wp_login', array($this, 'maybeAddCountryToProfile'), 99, 2);
|
||||
}
|
||||
|
||||
public function userRegistrationHandler($userId)
|
||||
{
|
||||
if (is_multisite()) {
|
||||
if (is_network_admin()) {
|
||||
return false;
|
||||
}
|
||||
if (function_exists('WP_Ultimo')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$settings = (new AutoSubscribe())->getRegistrationSettings();
|
||||
|
||||
if (Arr::get($settings, 'status') != 'yes') {
|
||||
|
||||
$user = get_user_by('ID', $userId);
|
||||
$contact = Subscriber::where('email', $user->user_email)->first();
|
||||
if ($contact && $contact->user_id != $user->ID) {
|
||||
fluentCrmDb()->table('fc_subscribers')
|
||||
->where('id', $contact->id)
|
||||
->update([
|
||||
'user_id' => $user->ID
|
||||
]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriberData = FunnelHelper::prepareUserData($userId);
|
||||
if ($listId = Arr::get($settings, 'target_list')) {
|
||||
$subscriberData['lists'] = [$listId];
|
||||
}
|
||||
|
||||
if ($tags = Arr::get($settings, 'target_tags')) {
|
||||
$subscriberData['tags'] = $tags;
|
||||
}
|
||||
|
||||
$isDoubleOptin = Arr::get($settings, 'double_optin') == 'yes';
|
||||
|
||||
if ($isDoubleOptin) {
|
||||
$subscriberData['status'] = 'pending';
|
||||
} else {
|
||||
$subscriberData['status'] = 'subscribed';
|
||||
}
|
||||
|
||||
$contact = FunnelHelper::createOrUpdateContact($subscriberData);
|
||||
|
||||
if (!$contact) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($contact->status == 'pending' && $subscriberData['status'] == 'pending') {
|
||||
$contact->sendDoubleOptinEmail();
|
||||
}
|
||||
|
||||
add_action('updated_user_meta', function ($meta_id, $userId, $meta_key, $_meta_value) use ($contact) {
|
||||
if ($userId == $contact->user_id && ($meta_key == 'first_name' || $meta_key == 'last_name') && $_meta_value) {
|
||||
if ($contact->{$meta_key} != $_meta_value) {
|
||||
fluentCrmDb()->table('fc_subscribers')
|
||||
->where('id', $contact->id)
|
||||
->update([
|
||||
$meta_key => $_meta_value
|
||||
]);
|
||||
}
|
||||
}
|
||||
}, 10, 4);
|
||||
|
||||
}
|
||||
|
||||
public function addSubscribeCheckbox($buttonHtml)
|
||||
{
|
||||
|
||||
$settings = (new AutoSubscribe())->getCommentSettings();
|
||||
|
||||
/**
|
||||
* Determine the settings for the comment form subscribe feature in FluentCRM.
|
||||
*
|
||||
* This filter allows modification of the settings used for the comment form subscribe feature in FluentCRM.
|
||||
*
|
||||
* @param array $settings The current settings for the comment form subscribe feature.
|
||||
* @return array The modified settings for the comment form subscribe feature.
|
||||
* @since 2.7.0
|
||||
*
|
||||
*/
|
||||
$settings = apply_filters('fluent_crm/comment_form_subscribe_settings', $settings);
|
||||
|
||||
if (Arr::get($settings, 'status') != 'yes') {
|
||||
return $buttonHtml;
|
||||
}
|
||||
|
||||
if (Arr::get($settings, 'show_only_new') == 'yes') {
|
||||
if ($userId = get_current_user_id()) {
|
||||
$user = get_user_by('ID', $userId);
|
||||
$contact = Subscriber::where('user_id', $userId)->orWhere('email', $user->user_email)->first();
|
||||
if ($contact && $contact->status == 'subscribed') {
|
||||
return $buttonHtml;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$label = Arr::get($settings, 'checkbox_label');
|
||||
if (!$label) {
|
||||
$label = __('Subscribe to newsletter', 'fluent-crm');
|
||||
}
|
||||
|
||||
$checkedTag = '';
|
||||
|
||||
if (Arr::get($settings, 'auto_checked') == 'yes') {
|
||||
$checkedTag = 'checked="true"';
|
||||
}
|
||||
|
||||
$html = '<p class="comment-form-fc-consent comment-form-cookies-consent"><input ' . $checkedTag . ' id="wp-comment-fc-consent" name="wp-comment-fc-consent" type="checkbox" value="yes"><label for="wp-comment-fc-consent">' . $label . '</label></p>';
|
||||
|
||||
return $html . $buttonHtml;
|
||||
}
|
||||
|
||||
public function handleCommentPost($commentId, $isApproved, $commentData)
|
||||
{
|
||||
// is this a spam comment?
|
||||
if ($isApproved === 'spam') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (defined('WC_PLUGIN_FILE') && Arr::get($commentData, 'comment_type') == 'review') {
|
||||
do_action('fluentcrm_woo_review_comment_post', $commentId, $isApproved, $commentData);
|
||||
}
|
||||
|
||||
$isChecked = Arr::get($_REQUEST, 'wp-comment-fc-consent') == 'yes';
|
||||
if (!$isChecked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriberData = [
|
||||
'full_name' => Arr::get($commentData, 'comment_author'),
|
||||
'email' => Arr::get($commentData, 'comment_author_email'),
|
||||
'ip_address' => Arr::get($commentData, 'comment_author_IP')
|
||||
];
|
||||
|
||||
if ($userId = Arr::get($commentData, 'user_id')) {
|
||||
$subscriberData['user_id'] = $userId;
|
||||
}
|
||||
|
||||
$subscriberData = array_filter($subscriberData);
|
||||
|
||||
$settings = (new AutoSubscribe())->getCommentSettings();
|
||||
|
||||
if ($listId = Arr::get($settings, 'target_list')) {
|
||||
$subscriberData['lists'] = [$listId];
|
||||
}
|
||||
|
||||
if ($tags = Arr::get($settings, 'target_tags')) {
|
||||
$subscriberData['tags'] = $tags;
|
||||
}
|
||||
|
||||
$isDoubleOptin = Arr::get($settings, 'double_optin') == 'yes';
|
||||
|
||||
if ($isDoubleOptin) {
|
||||
$subscriberData['status'] = 'pending';
|
||||
}
|
||||
|
||||
$contact = FunnelHelper::createOrUpdateContact($subscriberData);
|
||||
|
||||
if (!$contact) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$contact->country) {
|
||||
// get CF Country from request header: CF-IPCountry
|
||||
$countryCode = sanitize_text_field($_SERVER['HTTP_CF_IPCOUNTRY'] ?? '');
|
||||
if ($countryCode && preg_match('/^[A-Z]{2}$/', $countryCode) && $countryCode !== 'XX') {
|
||||
$contact->country = $countryCode;
|
||||
$contact->save();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if ($contact->status == 'pending') {
|
||||
$contact->sendDoubleOptinEmail();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function syncUserUpdate($userId, $oldData, $newData = [])
|
||||
{
|
||||
|
||||
if (is_multisite() && is_network_admin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($newData['user_pass'])) {
|
||||
$user = get_user_by('ID', $userId);
|
||||
(new Cleanup())->handleUserPasswordChanged($user);
|
||||
}
|
||||
|
||||
if (!Helper::isUserSyncEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if user email has been changed
|
||||
$user = get_user_by('ID', $userId);
|
||||
|
||||
if ($user->user_email != $oldData->user_email) {
|
||||
// email has been changed
|
||||
$oldSubscriber = Subscriber::where('email', $oldData->user_email)->first();
|
||||
|
||||
// check if a contact is exist with the new email id
|
||||
$newSubscriber = Subscriber::where('email', $user->user_email)->first();
|
||||
|
||||
if ($newSubscriber) {
|
||||
fluentCrmDb()->table('fc_subscribers')
|
||||
->where('id', $oldSubscriber->id)
|
||||
->update([
|
||||
'user_id' => ''
|
||||
]);
|
||||
$oldSubscriber = false;
|
||||
}
|
||||
|
||||
if ($oldSubscriber) {
|
||||
$updateData = [
|
||||
'email' => $user->user_email,
|
||||
'hash' => md5($user->user_email),
|
||||
'updated_at' => current_time('mysql'),
|
||||
'user_id' => $user->ID
|
||||
];
|
||||
|
||||
if ($user->first_name) {
|
||||
$updateData['first_name'] = $user->first_name;
|
||||
}
|
||||
|
||||
if ($user->last_name) {
|
||||
$updateData['last_name'] = $user->last_name;
|
||||
}
|
||||
|
||||
return fluentCrmDb()->table('fc_subscribers')
|
||||
->where('id', $oldSubscriber->id)
|
||||
->update($updateData);
|
||||
}
|
||||
}
|
||||
|
||||
// we just have to change the first name and lastname
|
||||
$updateData = Helper::getWPMapUserInfo($user);
|
||||
|
||||
unset($updateData['email']);
|
||||
|
||||
if (!$updateData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$updateData['updated_at'] = current_time('mysql');
|
||||
|
||||
return fluentCrmDb()->table('fc_subscribers')
|
||||
->where('email', $user->user_email)
|
||||
->update($updateData);
|
||||
}
|
||||
|
||||
public function maybeDeleteContact($userId, $reassignId, $user)
|
||||
{
|
||||
if (is_multisite() && is_network_admin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Helper::isContactDeleteOnUserDeleteEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = Subscriber::where('user_id', $userId)->first();
|
||||
if (!$subscriber) {
|
||||
$subscriber = Subscriber::where('email', $user->user_email)->first();
|
||||
}
|
||||
|
||||
if (!$subscriber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Helper::deleteContacts([$subscriber->id]);
|
||||
}
|
||||
|
||||
public function syncWooAddressUpdate($userId, $addressType)
|
||||
{
|
||||
if ($addressType != 'billing') {
|
||||
return;
|
||||
}
|
||||
|
||||
$customer = new \WC_Customer($userId);
|
||||
|
||||
if (!$customer || !$customer->get_id()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = get_user_by('ID', $userId);
|
||||
$contact = Subscriber::where('email', $user->user_email)->first();
|
||||
|
||||
$addressData = $customer->get_billing();
|
||||
|
||||
$updateData = [
|
||||
'user_id' => $userId,
|
||||
'address_line_1' => $addressData['address_1'],
|
||||
'address_line_2' => $addressData['address_2'],
|
||||
'city' => $addressData['city'],
|
||||
'state' => $addressData['state'],
|
||||
'country' => $addressData['country'],
|
||||
'postal_code' => $addressData['postcode']
|
||||
];
|
||||
|
||||
if ($contact) {
|
||||
$contact->fill($updateData);
|
||||
$dirty = $contact->getDirty();
|
||||
if ($dirty) {
|
||||
fluentCrmDb()->table('fc_subscribers')
|
||||
->where('id', $contact->id)
|
||||
->update($dirty);
|
||||
$contact = Subscriber::find($contact->id);
|
||||
do_action('fluent_crm/contact_updated', $contact, $dirty);
|
||||
}
|
||||
} else {
|
||||
FluentCrmApi('contacts')->createOrUpdate($updateData);
|
||||
}
|
||||
}
|
||||
|
||||
public function maybeAddCountryToProfile($userLogin, $wpUser)
|
||||
{
|
||||
// get CF Country from request header: CF-IPCountry
|
||||
$countryCode = sanitize_text_field($_SERVER['HTTP_CF_IPCOUNTRY'] ?? '');
|
||||
|
||||
if (!$countryCode || !preg_match('/^[A-Z]{2}$/', $countryCode) || $countryCode === 'XX') {
|
||||
return;
|
||||
}
|
||||
|
||||
$contact = Subscriber::where('email', $wpUser->user_email)->first();
|
||||
if (!$contact || $contact->country) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'country' => $countryCode
|
||||
];
|
||||
|
||||
if (empty($contact->user_id) || (int) $contact->user_id === (int) $wpUser->ID) {
|
||||
$updateData['user_id'] = $wpUser->ID;
|
||||
}
|
||||
|
||||
fluentCrmDb()->table('fc_subscribers')
|
||||
->where('id', $contact->id)
|
||||
->update($updateData);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
/**
|
||||
* CampaignGuard Class
|
||||
*
|
||||
* Used to handle concurrent requests for the same campaign.
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
class CampaignGuard
|
||||
{
|
||||
const FORBIDDEN_CODE = 403;
|
||||
|
||||
public function checkIsActive($campaign)
|
||||
{
|
||||
if (!$campaign) {
|
||||
$this->send('The campaign is not available anymore.');
|
||||
}
|
||||
|
||||
$status = $campaign->status;
|
||||
|
||||
if (!in_array($status, ['draft', 'pending', 'incomplete', 'purged', 'scheduled'])) {
|
||||
$message = __('The campaign has been locked and not modifiable due to it\'s current status', 'fluent-crm');
|
||||
$message .= ": <strong>{$status}</strong>.";
|
||||
$this->send($message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
public function checkIsWorking($campaign)
|
||||
{
|
||||
if (!$campaign) {
|
||||
$this->send('The campaign is not available anymore.');
|
||||
}
|
||||
|
||||
$status = $campaign->status;
|
||||
|
||||
if ($status == 'working') {
|
||||
$message = __("The campaign has been locked and not deletable due to it's current status", "fluent-crm");
|
||||
$message .= ": <strong>{$status}</strong>.";
|
||||
$this->send($message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
protected function send($message)
|
||||
{
|
||||
FluentCrm('response')->sendError([
|
||||
'status' => self::FORBIDDEN_CODE,
|
||||
'message' => "<p style='font-weight:500;color:#606266;'>{$message}</p>"
|
||||
], self::FORBIDDEN_CODE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
use FluentCrm\App\Models\CampaignUrlMetric;
|
||||
use FluentCrm\App\Models\Company;
|
||||
use FluentCrm\App\Models\CompanyNote;
|
||||
use FluentCrm\App\Models\FunnelMetric;
|
||||
use FluentCrm\App\Models\FunnelSubscriber;
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Models\SubscriberMeta;
|
||||
use FluentCrm\App\Models\SubscriberNote;
|
||||
use FluentCrm\App\Models\SubscriberPivot;
|
||||
use FluentCrm\App\Services\BlockParser;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\App\Models\Meta;
|
||||
|
||||
|
||||
/**
|
||||
* Cleanup Class
|
||||
*
|
||||
* Used to handle cleanup related assets for subscribers, campaigns and automations.
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class Cleanup
|
||||
{
|
||||
/**
|
||||
* Cleanup related data of a subscriber.
|
||||
*
|
||||
* @param array $subscriberIds
|
||||
*/
|
||||
public function deleteSubscribersAssets($subscriberIds)
|
||||
{
|
||||
CampaignEmail::whereIn('subscriber_id', $subscriberIds)->delete();
|
||||
CampaignUrlMetric::whereIn('subscriber_id', $subscriberIds)->delete();
|
||||
SubscriberMeta::whereIn('subscriber_id', $subscriberIds)->delete();
|
||||
SubscriberNote::whereIn('subscriber_id', $subscriberIds)->delete();
|
||||
SubscriberPivot::whereIn('subscriber_id', $subscriberIds)->delete();
|
||||
FunnelMetric::whereIn('subscriber_id', $subscriberIds)->delete();
|
||||
FunnelSubscriber::whereIn('subscriber_id', $subscriberIds)->delete();
|
||||
|
||||
if (defined('FLUENTCAMPAIGN_DIR_FILE')) {
|
||||
\FluentCampaign\App\Models\SequenceTracker::whereIn('subscriber_id', $subscriberIds)->delete();
|
||||
}
|
||||
|
||||
if (Helper::isExperimentalEnabled('company_module')) {
|
||||
Company::whereIn('owner_id', $subscriberIds)
|
||||
->update([
|
||||
'owner_id' => NULL
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup related data of a campaign.
|
||||
*
|
||||
* @param int $campaignId
|
||||
*/
|
||||
public function deleteCampaignAssets($campaignId)
|
||||
{
|
||||
// Idempotent backstop — Campaign::deleteCampaignData() already removes
|
||||
// these in the normal delete flow, but we keep this here so any
|
||||
// future caller that fires fluent_crm/campaign_deleted without
|
||||
// running deleteCampaignData() first still gets a clean teardown.
|
||||
CampaignEmail::where('campaign_id', $campaignId)->delete();
|
||||
CampaignUrlMetric::where('campaign_id', $campaignId)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup related data of a list.
|
||||
*
|
||||
* @param int $listId
|
||||
*/
|
||||
public function deleteListAssets($listId)
|
||||
{
|
||||
SubscriberPivot::where('object_type', 'FluentCrm\App\Models\Lists')->where('object_id', $listId)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup related data of a tag.
|
||||
*
|
||||
* @param int $listId
|
||||
*/
|
||||
public function deleteTagAssets($listId)
|
||||
{
|
||||
SubscriberPivot::where('object_type', 'FluentCrm\App\Models\Tag')->where('object_id', $listId)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel Future Emails.
|
||||
*
|
||||
* @param \FluentCrm\App\Models\Subscriber $subscriber
|
||||
*/
|
||||
public function handleUnsubscribe($subscriber)
|
||||
{
|
||||
// Per-statement try/catch: a row-lock deadlock against the mailer
|
||||
// workers on the CampaignEmail update should not also block the
|
||||
// FunnelSubscriber / SequenceTracker cancellations. The next status
|
||||
// transition (or a manual retry) will reconcile any rows we miss.
|
||||
try {
|
||||
CampaignEmail::where('subscriber_id', $subscriber->id)
|
||||
->whereIn('status', ['pending', 'scheduled', 'draft', 'processing', 'scheduling'])
|
||||
->update([
|
||||
'status' => 'cancelled'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Helper::debugLog('handleUnsubscribe', 'CampaignEmail cancel deferred: ' . $e->getMessage(), 'extended');
|
||||
}
|
||||
|
||||
try {
|
||||
FunnelSubscriber::where('subscriber_id', $subscriber->id)
|
||||
->where('status', 'active')
|
||||
->whereDoesntHave('funnel', function ($query) {
|
||||
$query->where('trigger_name', 'fluent_crm/subscriber_status_changed');
|
||||
})
|
||||
->update([
|
||||
'status' => 'cancelled'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Helper::debugLog('handleUnsubscribe', 'FunnelSubscriber cancel deferred: ' . $e->getMessage(), 'extended');
|
||||
}
|
||||
|
||||
if (defined('FLUENTCAMPAIGN')) {
|
||||
try {
|
||||
\FluentCampaign\App\Models\SequenceTracker::where('subscriber_id', $subscriber->id)
|
||||
->where('status', 'active')
|
||||
->update([
|
||||
'status' => 'cancelled'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Helper::debugLog('handleUnsubscribe', 'SequenceTracker cancel deferred: ' . $e->getMessage(), 'extended');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the future emails email_address of a provided contact.
|
||||
*
|
||||
* @param \FluentCrm\App\Models\Subscriber $subscriber
|
||||
*/
|
||||
public function handleContactEmailChanged($subscriber)
|
||||
{
|
||||
CampaignEmail::where('subscriber_id', $subscriber->id)
|
||||
->whereIn('status', ['draft', 'scheduled'])
|
||||
->update([
|
||||
'email_address' => $subscriber->email
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param $userId int
|
||||
* @param $resign int|null
|
||||
* @param $deletedUser \WP_User
|
||||
* @return bool
|
||||
*/
|
||||
public function handleUserDelete($userId, $resign, $deletedUser)
|
||||
{
|
||||
$settings = Helper::getComplianceSettings();
|
||||
if ($settings['delete_contact_on_user'] !== 'yes') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = Subscriber::where('user_id', $userId)->first();
|
||||
|
||||
if (!$subscriber && $deletedUser) {
|
||||
$subscriber = Subscriber::where('email', $deletedUser->user_email)->first();
|
||||
}
|
||||
|
||||
if (!$subscriber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// delete the subscriber now;
|
||||
Helper::deleteContacts([$subscriber->id]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function attachCrmExporter($exporters)
|
||||
{
|
||||
$settings = Helper::getComplianceSettings();
|
||||
if ($settings['personal_data_export'] !== 'yes') {
|
||||
return $exporters;
|
||||
}
|
||||
|
||||
$exporters['fluent-crm'] = [
|
||||
'exporter_friendly_name' => __('FluentCRM Data', 'fluent-crm'),
|
||||
'callback' => [$this, 'exportPersonalDataWP'],
|
||||
];
|
||||
|
||||
return $exporters;
|
||||
|
||||
}
|
||||
|
||||
public function exportPersonalDataWP($user_email, $page = 1)
|
||||
{
|
||||
$subscriber = Subscriber::where('email', $user_email)->first();
|
||||
|
||||
if (!$subscriber) {
|
||||
return [
|
||||
'data' => [],
|
||||
'done' => true
|
||||
];
|
||||
}
|
||||
|
||||
$customerFields = $subscriber->custom_fields();
|
||||
$mainFields = $subscriber->toArray();
|
||||
|
||||
$data = [
|
||||
'group_id' => 'fluent-crm-contact',
|
||||
'group_label' => __('FluentCRM Data', 'fluent-crm'),
|
||||
'item_id' => 'crm-contact',
|
||||
'data' => []
|
||||
];
|
||||
|
||||
foreach ($mainFields as $fieldKey => $fieldValue) {
|
||||
if ($fieldValue) {
|
||||
$data['data'][] = [
|
||||
'name' => $fieldKey,
|
||||
'value' => $fieldValue
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($customerFields as $fieldKey => $customerField) {
|
||||
$data['data'][] = [
|
||||
'name' => $fieldKey,
|
||||
'value' => $customerField
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => [$data],
|
||||
'done' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public function handleCompanyDelete($id)
|
||||
{
|
||||
/*
|
||||
* Remove Company ID from all connected subscribers
|
||||
*/
|
||||
Subscriber::where('company_id', $id)->update([
|
||||
'company_id' => NULL
|
||||
]);
|
||||
|
||||
fluentCrmDb()->table('fc_subscriber_pivot')
|
||||
->where('object_id', $id)
|
||||
->where('object_type', 'FluentCrm\App\Models\Company')
|
||||
->delete();
|
||||
|
||||
// Delete company notes
|
||||
CompanyNote::where('subscriber_id', $id)->delete();
|
||||
}
|
||||
|
||||
public function handleUserPasswordChanged($user)
|
||||
{
|
||||
$contact = Subscriber::where('email', $user->user_email)
|
||||
->first();
|
||||
|
||||
if (!$contact) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$exist = SubscriberMeta::where('subscriber_id', $contact->id)
|
||||
->where('key', '_secure_managed_hash')
|
||||
->first();
|
||||
|
||||
if (!$exist) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hash = md5(wp_generate_uuid4() . '_' . $contact->id . '_' . '_' . time() . '__' . $contact->id);
|
||||
$exist->value = $hash;
|
||||
$exist->updated_at = current_time('mysql');
|
||||
$exist->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function archiveCampaignAssets($campaign)
|
||||
{
|
||||
if ($campaign->type != 'campaign' || fluentcrm_get_campaign_meta($campaign->id, '_cached_email_body', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We will create email body and then cache it for future use
|
||||
$rawTemplates = [
|
||||
'raw_html',
|
||||
'visual_builder',
|
||||
'raw_classic'
|
||||
];
|
||||
|
||||
if (in_array($campaign->design_template, $rawTemplates)) {
|
||||
$emailBody = $campaign->email_body;
|
||||
} else {
|
||||
$emailBody = (new BlockParser())->parse($campaign->email_body);
|
||||
}
|
||||
|
||||
fluentcrm_update_campaign_meta($campaign->id, '_cached_email_body', $emailBody);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function maybeRemoveOldScheuledActionLogs()
|
||||
{
|
||||
$group_slug = 'fluent-crm';
|
||||
$days_old = 7;
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Get the timestamp for 7 days ago
|
||||
$cutoff_date = gmdate('Y-m-d H:i:s', strtotime("-{$days_old} days"));
|
||||
|
||||
// Get the group ID
|
||||
$group_id = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT group_id FROM {$wpdb->prefix}actionscheduler_groups WHERE slug = %s",
|
||||
$group_slug
|
||||
));
|
||||
|
||||
if (!$group_id) {
|
||||
return false; // Group not found
|
||||
}
|
||||
|
||||
// Delete old actions and their associated logs
|
||||
$deleted = $wpdb->query($wpdb->prepare("
|
||||
DELETE a, l
|
||||
FROM {$wpdb->prefix}actionscheduler_actions a
|
||||
LEFT JOIN {$wpdb->prefix}actionscheduler_logs l ON a.action_id = l.action_id
|
||||
WHERE a.group_id = %d
|
||||
AND a.status IN ('complete', 'failed')
|
||||
AND a.scheduled_date_gmt < %s", $group_id, $cutoff_date));
|
||||
|
||||
// Clean up orphaned claims
|
||||
$wpdb->query("
|
||||
DELETE c
|
||||
FROM {$wpdb->prefix}actionscheduler_claims c
|
||||
LEFT JOIN {$wpdb->prefix}actionscheduler_actions a ON c.claim_id = a.claim_id
|
||||
WHERE a.action_id IS NULL");
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
public function SyncSubscriberDeleteSettings($fromKey, $value)
|
||||
{
|
||||
if ($fromKey == 'compliance_settings') {
|
||||
$option = Meta::where('key', 'user_syncing_settings')
|
||||
->where('object_type', 'option')
|
||||
->first();
|
||||
|
||||
if ($option) {
|
||||
$settings = $option->value;
|
||||
|
||||
if ($settings['delete_contact_on_user_delete'] != $value) {
|
||||
$settings['delete_contact_on_user_delete'] = $value;
|
||||
$option->value = $settings;
|
||||
$option->save();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$complianceSettings = get_option('_fluentcrm_compliance_settings');
|
||||
if ($complianceSettings) {
|
||||
$complianceSettings['delete_contact_on_user'] = $value;
|
||||
update_option('_fluentcrm_compliance_settings', $complianceSettings, 'no');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Models\Meta;
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
/**
|
||||
* ContactActivityLogger Class
|
||||
*
|
||||
* Logs Contact's activity based on different WordPress Events.
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class ContactActivityLogger
|
||||
{
|
||||
public function register()
|
||||
{
|
||||
// Login Tracker
|
||||
add_action('wp_login', array($this, 'trackLogin'), 10, 2);
|
||||
|
||||
// Global Tracker
|
||||
add_action('fluent_crm/track_activity_by_subscriber', array($this, 'trackActivityBySubscriber'));
|
||||
|
||||
|
||||
add_action('fluent_crm/email_opened_anonymously', [$this, 'trackEmailOpenAnonymously'], 10, 1);
|
||||
|
||||
add_action('fluent_crm/anonymous_email_url_clicked', [$this, 'trackEmailClickAnonymously'], 10, 2);
|
||||
}
|
||||
|
||||
public function trackLogin($username, $user)
|
||||
{
|
||||
update_user_meta($user->ID, '_last_login', current_time('mysql'));
|
||||
$this->trackActivityByUser($user, 'login');
|
||||
}
|
||||
|
||||
public function trackActivityByUser($user, $type = '')
|
||||
{
|
||||
if (is_numeric($user)) {
|
||||
$user = get_user_by('ID', $user);
|
||||
}
|
||||
if (!$user || empty($user->user_email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriber = Subscriber::where('email', $user->user_email)->first();
|
||||
|
||||
if (!$subscriber) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->trackActivityBySubscriber($subscriber);
|
||||
|
||||
if ($type == 'login') {
|
||||
fluentcrm_update_subscriber_meta($subscriber->id, '_last_login', current_time('mysql'));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function trackActivityBySubscriber($subscriber)
|
||||
{
|
||||
if (!$subscriber) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_numeric($subscriber)) {
|
||||
$subscriber = Subscriber::where('id', $subscriber)->first();
|
||||
}
|
||||
|
||||
if (!$subscriber) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($subscriber->last_activity && strtotime($subscriber->last_activity) > (current_time('timestamp') - 3600)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'last_activity' => current_time('mysql')
|
||||
];
|
||||
|
||||
if (!$subscriber->ip && fluentCrmWillTrackIp()) {
|
||||
$ip = FluentCrm('request')->getIp(fluentCrmWillAnonymizeIp());
|
||||
if ($ip != '127.0.0.1') {
|
||||
$data['ip'] = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
return fluentCrmDb()->table('fc_subscribers')
|
||||
->where('id', $subscriber->id)
|
||||
->update($data);
|
||||
|
||||
}
|
||||
|
||||
public function trackEmailOpenAnonymously($campaignEmaillModel)
|
||||
{
|
||||
if (!$campaignEmaillModel->campaign_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if the campaign exist
|
||||
global $wpdb;
|
||||
$exists = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT 1 FROM {$wpdb->prefix}fc_campaigns WHERE id = %d LIMIT 1",
|
||||
$campaignEmaillModel->campaign_id
|
||||
)
|
||||
);
|
||||
|
||||
if (!$exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$existingMetaModel = fluentcrm_get_campaign_meta($campaignEmaillModel->campaign_id, '_ano_open_count', false);
|
||||
if ($existingMetaModel) {
|
||||
global $wpdb;
|
||||
$wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$wpdb->prefix}fc_meta SET value = value + 1 WHERE id = %d",
|
||||
$existingMetaModel->id
|
||||
));
|
||||
} else {
|
||||
// we creating new one
|
||||
Meta::create([
|
||||
'key' => '_ano_open_count',
|
||||
'value' => 1,
|
||||
'object_id' => $campaignEmaillModel->campaign_id,
|
||||
'object_type' => 'FluentCrm\App\Models\Campaign'
|
||||
]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function trackEmailClickAnonymously($url, $campaign)
|
||||
{
|
||||
$existingMetaModel = fluentcrm_get_campaign_meta($campaign->id, '_ano_url_clicks', false);
|
||||
|
||||
$url = (string)$url;
|
||||
|
||||
if ($existingMetaModel) {
|
||||
$urls = is_array($existingMetaModel->value) ? $existingMetaModel->value : [];
|
||||
if (isset($urls[$url])) {
|
||||
$urls[$url] = (int)$urls[$url] + 1;
|
||||
} else {
|
||||
$urls[$url] = 1;
|
||||
}
|
||||
|
||||
$existingMetaModel->value = $urls;
|
||||
$existingMetaModel->save();
|
||||
} else {
|
||||
Meta::create([
|
||||
'key' => '_ano_url_clicks',
|
||||
'value' => [
|
||||
$url => 1
|
||||
],
|
||||
'object_id' => $campaign->id,
|
||||
'object_type' => 'FluentCrm\App\Models\Campaign'
|
||||
]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
/**
|
||||
* DeactivationHandler Class
|
||||
*
|
||||
* FluentCRM Deactivation Handler Class.
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class DeactivationHandler
|
||||
{
|
||||
public function handle()
|
||||
{
|
||||
if (function_exists('\as_unschedule_all_actions')) {
|
||||
as_unschedule_all_actions('fluentcrm_scheduled_every_minute_tasks');
|
||||
as_unschedule_all_actions('fluent_crm_ascheduler_runs_daily');
|
||||
}
|
||||
|
||||
wp_clear_scheduled_hook('fluentcrm_scheduled_minute_tasks');
|
||||
wp_clear_scheduled_hook('fluentcrm_scheduled_hourly_tasks');
|
||||
wp_clear_scheduled_hook('fluentcrm_scheduled_weekly_tasks');
|
||||
wp_clear_scheduled_hook('fluentcrm_scheduled_five_minute_tasks');
|
||||
wp_clear_scheduled_hook('fluentcrm_scheduled_daily_tasks');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Services\Sanitize;
|
||||
use FluentCrm\App\Services\Libs\Emogrifier\Emogrifier;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
/**
|
||||
* EmailDesignTemplates Class
|
||||
*
|
||||
* For handling email design templates
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class EmailDesignTemplates
|
||||
{
|
||||
|
||||
public function register()
|
||||
{
|
||||
add_filter('fluent_crm/email-design-template-block_editor', [$this, 'addBlockEditorTemplate'], 10, 3);
|
||||
add_filter('fluent_crm/email-design-template-simple', [$this, 'addBlockEditorTemplate'], 10, 3);
|
||||
add_filter('fluent_crm/email-design-template-plain', [$this, 'addBlockEditorTemplate'], 10, 3);
|
||||
add_filter('fluent_crm/email-design-template-classic', [$this, 'addBlockEditorTemplate'], 10, 3);
|
||||
|
||||
|
||||
add_filter('fluent_crm/email-design-template-raw_classic', [$this, 'addRawClassicTemplate'], 10, 3);
|
||||
add_filter('fluent_crm/email-design-template-web_preview', [$this, 'addWebPreviewTemplate'], 10, 3);
|
||||
}
|
||||
|
||||
public function addBlockEditorTemplate($emailBody, $templateData, $campaign)
|
||||
{
|
||||
$templateData = $this->filterTemplateData($templateData);
|
||||
$templateData['email_body'] = $emailBody;
|
||||
|
||||
$view = FluentCrm('view');
|
||||
$emailBody = $view->make('emails.block_editor.Template', $templateData);
|
||||
$emailBody = $emailBody->__toString();
|
||||
|
||||
$emogrifier = new Emogrifier($emailBody);
|
||||
$emogrifier->disableInvisibleNodeRemoval();
|
||||
return $emogrifier->emogrify();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $emailBody
|
||||
* @param array $templateData
|
||||
* @param \FluentCrm\App\Models\Campaign $campaign
|
||||
* @return string
|
||||
*/
|
||||
public function addPlainTemplate($emailBody, $templateData, $campaign)
|
||||
{
|
||||
$templateData = $this->filterTemplateData($templateData);
|
||||
|
||||
$view = FluentCrm('view');
|
||||
$emailBody = $view->make('emails.plain.Template', $templateData);
|
||||
$emailBody = $emailBody->__toString();
|
||||
|
||||
$emogrifier = new Emogrifier($emailBody);
|
||||
$emogrifier->disableInvisibleNodeRemoval();
|
||||
return $emogrifier->emogrify();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $emailBody
|
||||
* @param array $templateData
|
||||
* @param \FluentCrm\App\Models\Campaign $campaign
|
||||
* @return string
|
||||
*/
|
||||
public function addSimpleTemplate($emailBody, $templateData, $campaign)
|
||||
{
|
||||
if (empty($templateData['config']['body_bg_color'])) {
|
||||
$templateData['config']['body_bg_color'] = '#FAFAFA';
|
||||
}
|
||||
|
||||
if (empty($templateData['config']['content_bg_color'])) {
|
||||
$templateData['config']['content_bg_color'] = '#ffffff';
|
||||
}
|
||||
|
||||
$templateData = $this->filterTemplateData($templateData);
|
||||
|
||||
$view = FluentCrm('view');
|
||||
$emailBody = $view->make('emails.simple.Template', $templateData);
|
||||
$emailBody = $emailBody->__toString();
|
||||
$emogrifier = new Emogrifier($emailBody);
|
||||
$emogrifier->disableInvisibleNodeRemoval();
|
||||
return $emogrifier->emogrify();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $emailBody
|
||||
* @param array $templateData
|
||||
* @param \FluentCrm\App\Models\Campaign $campaign
|
||||
* @return string
|
||||
*/
|
||||
public function addClassicTemplate($emailBody, $templateData, $campaign)
|
||||
{
|
||||
if (empty($templateData['config']['content_bg_color'])) {
|
||||
$templateData['config']['content_bg_color'] = '#ffffff';
|
||||
}
|
||||
|
||||
$templateData = $this->filterTemplateData($templateData);
|
||||
|
||||
$view = FluentCrm('view');
|
||||
$emailBody = $view->make('emails.classic.Template', $templateData);
|
||||
$emailBody = $emailBody->__toString();
|
||||
|
||||
$emogrifier = new Emogrifier($emailBody);
|
||||
$emogrifier->disableInvisibleNodeRemoval();
|
||||
return $emogrifier->emogrify();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $emailBody
|
||||
* @param array $templateData
|
||||
* @param \FluentCrm\App\Models\Campaign $campaign
|
||||
* @return string
|
||||
*/
|
||||
public function addRawClassicTemplate($emailBody, $templateData, $campaign)
|
||||
{
|
||||
$templateData = $this->filterTemplateData($templateData);
|
||||
|
||||
$configDefault = [
|
||||
'content_width' => '',
|
||||
'content_padding' => '',
|
||||
'headings_font_family' => '',
|
||||
'text_color' => '',
|
||||
'link_color' => '',
|
||||
'body_bg_color' => '',
|
||||
'content_bg_color' => '',
|
||||
'footer_text_color' => '',
|
||||
'content_font_family' => "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'",
|
||||
'paragraph_color' => '',
|
||||
'paragraph_font_size' => '',
|
||||
'paragraph_font_family' => '',
|
||||
'paragraph_line_height' => '',
|
||||
'headings_color' => ''
|
||||
];
|
||||
|
||||
$templateData['config'] = wp_parse_args($templateData['config'], $configDefault);
|
||||
|
||||
$view = FluentCrm('view');
|
||||
$emailBody = $view->make('emails.raw_classic.Template', $templateData);
|
||||
$emailBody = $emailBody->__toString();
|
||||
$emogrifier = new Emogrifier($emailBody);
|
||||
$emogrifier->disableInvisibleNodeRemoval();
|
||||
return $emogrifier->emogrify();
|
||||
}
|
||||
|
||||
public function addWebPreviewTemplate($emailBody, $templateData, $campaign)
|
||||
{
|
||||
$templateData = $this->filterTemplateData($templateData);
|
||||
|
||||
$configDefault = [
|
||||
'content_width' => '',
|
||||
'content_padding' => '',
|
||||
'headings_font_family' => '',
|
||||
'text_color' => '',
|
||||
'link_color' => '',
|
||||
'body_bg_color' => '',
|
||||
'content_bg_color' => '',
|
||||
'footer_text_color' => '',
|
||||
'content_font_family' => "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'",
|
||||
'paragraph_color' => '',
|
||||
'paragraph_font_size' => '',
|
||||
'paragraph_font_family' => '',
|
||||
'paragraph_line_height' => '',
|
||||
'headings_color' => ''
|
||||
];
|
||||
|
||||
$templateData['config'] = wp_parse_args($templateData['config'], $configDefault);
|
||||
|
||||
$view = FluentCrm('view');
|
||||
$emailBody = $view->make('emails.web_preview.Template', $templateData);
|
||||
$emailBody = $emailBody->__toString();
|
||||
$emogrifier = new Emogrifier($emailBody);
|
||||
$emogrifier->disableInvisibleNodeRemoval();
|
||||
return $emogrifier->emogrify();
|
||||
}
|
||||
|
||||
private function filterTemplateData($templateData)
|
||||
{
|
||||
$footerConfig = Arr::get($templateData, 'footer_config', []);
|
||||
$disableFooter = Arr::get($footerConfig, 'disable_footer');
|
||||
if ($disableFooter !== 'yes' && $disableFooter !== 'no') {
|
||||
$disableFooter = Arr::get($templateData, 'config.disable_footer');
|
||||
}
|
||||
|
||||
if ($disableFooter == 'yes') {
|
||||
$templateData['footer_text'] = '';
|
||||
} else {
|
||||
$style = 'font-size: 13px; color: #202020;';
|
||||
if ($footerConfig) {
|
||||
$fontSize = Arr::get($footerConfig, 'font_size', 13) . 'px';
|
||||
$color = sanitize_hex_color(Arr::get($footerConfig, 'font_color', '#202020')) ?: '#202020';
|
||||
$backgroundColor = Arr::get($footerConfig, 'background_color', 'transparent');
|
||||
$paddingRaw = Arr::get($footerConfig, 'footer_padding');
|
||||
$safeBackgroundColor = sanitize_hex_color($backgroundColor);
|
||||
if ($backgroundColor === 'transparent') {
|
||||
$safeBackgroundColor = 'transparent';
|
||||
}
|
||||
|
||||
$safePadding = 20;
|
||||
if ($paddingRaw !== null && $paddingRaw !== '') {
|
||||
$safePadding = min(80, max(0, intval($paddingRaw)));
|
||||
}
|
||||
|
||||
$style = "font-size: {$fontSize}; color: {$color};";
|
||||
if ($safeBackgroundColor) {
|
||||
$style .= " background-color: {$safeBackgroundColor};";
|
||||
}
|
||||
$style .= " padding: {$safePadding}px;";
|
||||
$templateData['footer_text'] = Sanitize::sanitizeFooterHtml($footerConfig['footer_content'] ?? '');
|
||||
}
|
||||
|
||||
if($templateData['footer_text']) {
|
||||
$templateData['footer_text'] = "<div style='{$style}'>{$templateData['footer_text']}</div>";
|
||||
}
|
||||
}
|
||||
|
||||
return $templateData;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Models\EventTracker;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class EventTrackingHandler
|
||||
{
|
||||
public function register()
|
||||
{
|
||||
add_filter('fluentcrm_ajax_options_event_tracking_keys', [$this, 'getEventTrackingKeyOptions'], 10, 1);
|
||||
add_action('fluentcrm_contacts_filter_event_tracking', [$this, 'applyEventTrackingFilter'], 10, 2);
|
||||
add_action('fluent_crm/track_event_activity', [$this, 'trackEventActivity'], 10, 2);
|
||||
|
||||
add_filter('fluent_crm/subscriber_info_widgets', [$this, 'addSubscriberInfoWidgets'], 10, 2);
|
||||
add_filter('fluent_crm/subscriber_info_widget_event_tracking', [$this, 'addSubscriberInfoWidgets'], 10, 2);
|
||||
|
||||
add_filter('fluentcrm_advanced_filter_options', [$this, 'addEventTrackingFilterOptions'], 10, 1);
|
||||
|
||||
add_filter('fluent_crm/event_tracking_condition_groups', [$this, 'addEventTrackingConditionOptions'], 10, 1);
|
||||
|
||||
add_filter('fluentcrm_automation_condition_groups', function ($groups) {
|
||||
if (!Helper::isExperimentalEnabled('event_tracking')) {
|
||||
return $groups;
|
||||
}
|
||||
|
||||
$groups['event_tracking'] = [
|
||||
'label' => __('Event Tracking', 'fluent-crm'),
|
||||
'value' => 'event_tracking',
|
||||
'children' => $this->getConditionItems()
|
||||
];
|
||||
|
||||
return $groups;
|
||||
});
|
||||
}
|
||||
|
||||
public function getEventTrackingKeyOptions($options = [])
|
||||
{
|
||||
$items = EventTracker::select(['event_key'])
|
||||
->groupBy('event_key')
|
||||
->orderBy('event_key', 'ASC')
|
||||
->get();
|
||||
|
||||
$formattedItems = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$formattedItems[] = [
|
||||
'id' => $item->event_key,
|
||||
'title' => $item->event_key
|
||||
];
|
||||
}
|
||||
|
||||
return $formattedItems;
|
||||
}
|
||||
|
||||
public function applyEventTrackingFilter($query, $filters)
|
||||
{
|
||||
if (!Helper::isExperimentalEnabled('event_tracking')) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
foreach ($filters as $filter) {
|
||||
if (!array_key_exists('value', $filter) || $filter['value'] === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relation = 'trackingEvents';
|
||||
|
||||
$filterProp = $filter['property'];
|
||||
|
||||
if ($filterProp == 'event_tracking_key') {
|
||||
$operator = $filter['operator'];
|
||||
$values = $filter['value'];
|
||||
if ($operator == 'not_in') {
|
||||
$query->whereDoesntHave($relation, function ($q) use ($values) {
|
||||
$q->whereIn('event_key', $values);
|
||||
});
|
||||
} else {
|
||||
$query->whereHas($relation, function ($q) use ($values) {
|
||||
$q->whereIn('event_key', $values);
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($filterProp == 'event_tracking_title') {
|
||||
$operator = $filter['operator'];
|
||||
|
||||
if ($operator == '=') {
|
||||
$query->whereHas($relation, function ($q) use ($filter) {
|
||||
$q->where('title', $filter['value']);
|
||||
});
|
||||
} else if ($operator == '!=') {
|
||||
$query->whereDoesntHave($relation, function ($q) use ($filter) {
|
||||
$q->where('title', $filter['value']);
|
||||
});
|
||||
} else if ($operator == 'contains') {
|
||||
$query->whereHas($relation, function ($q) use ($filter) {
|
||||
$q->where('title', 'LIKE', '%' . $filter['value'] . '%');
|
||||
});
|
||||
} else if ($operator == 'not_contains') {
|
||||
$query->whereDoesntHave($relation, function ($q) use ($filter) {
|
||||
$q->where('title', 'LIKE', '%' . $filter['value'] . '%');
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($filterProp == 'event_tracking_value') {
|
||||
|
||||
$eventKey = Arr::get($filter, 'extra_value');
|
||||
if (!$eventKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$operator = $filter['operator'];
|
||||
|
||||
if ($operator == '=') {
|
||||
$query->whereHas($relation, function ($q) use ($filter, $eventKey) {
|
||||
$q->where('value', $filter['value'])
|
||||
->where('event_key', $eventKey);
|
||||
});
|
||||
} else if ($operator == '!=') {
|
||||
$query->whereDoesntHave($relation, function ($q) use ($filter, $eventKey) {
|
||||
$q->where('value', $filter['value'])
|
||||
->where('event_key', $eventKey);
|
||||
});
|
||||
} else if (in_array($operator, ['<', '>'])) {
|
||||
|
||||
$query->whereHas($relation, function ($q) use ($filter, $eventKey, $operator) {
|
||||
$q->where('value', $operator, (int)$filter['value'])
|
||||
->where('event_key', $eventKey);
|
||||
});
|
||||
} else if ($operator == 'contains') {
|
||||
$query->whereHas($relation, function ($q) use ($filter, $eventKey) {
|
||||
$q->where('value', 'LIKE', '%' . $filter['value'] . '%')
|
||||
->where('event_key', $eventKey);
|
||||
});
|
||||
} else if ($operator == 'not_contains') {
|
||||
$query->whereDoesntHave($relation, function ($q) use ($filter, $eventKey) {
|
||||
$q->where('value', 'LIKE', '%' . $filter['value'] . '%')
|
||||
->where('event_key', $eventKey);
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($filterProp == 'event_tracking_key_count') {
|
||||
|
||||
$eventKey = Arr::get($filter, 'extra_value');
|
||||
if (!$eventKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$operator = $filter['operator'];
|
||||
|
||||
if ($operator == '=') {
|
||||
$query->whereHas($relation, function ($q) use ($filter, $eventKey) {
|
||||
$q->where('counter', $filter['value'])
|
||||
->where('event_key', $eventKey);
|
||||
});
|
||||
} else if ($operator == '!=') {
|
||||
$query->whereDoesntHave($relation, function ($q) use ($filter, $eventKey) {
|
||||
$q->where('counter', $filter['value'])
|
||||
->where('event_key', $eventKey);
|
||||
});
|
||||
} else if (in_array($operator, ['<', '>'])) {
|
||||
|
||||
$query->whereHas($relation, function ($q) use ($filter, $eventKey, $operator) {
|
||||
$q->where('counter', $operator, (int)$filter['value'])
|
||||
->where('event_key', $eventKey);
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function trackEventActivity($data, $repeatable = true)
|
||||
{
|
||||
return FluentCrmApi('event_tracker')->track($data, $repeatable);
|
||||
}
|
||||
|
||||
public function addSubscriberInfoWidgets($widgets, $subscriber)
|
||||
{
|
||||
if (!Helper::isExperimentalEnabled('event_tracking')) {
|
||||
return $widgets;
|
||||
}
|
||||
|
||||
$events = EventTracker::where('subscriber_id', $subscriber->id)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->paginate();
|
||||
|
||||
if ($events->isEmpty()) {
|
||||
return $widgets;
|
||||
}
|
||||
|
||||
$html = '<div class="fc_scrolled_lists"><ul class="fcrm_event_tracking_lists">';
|
||||
foreach ($events as $event) {
|
||||
$html .= '<li>';
|
||||
$html .= '<p class="fcrm_event_tracking_title">' . esc_html($event->title) . '</p>';
|
||||
if ($event->value) {
|
||||
$html .= '<p class="fcrm_event_tracking_value">' . wp_kses_post($event->value) . '</p>';
|
||||
}
|
||||
$html .= '<div class="fcrm_event_tracking_footer">';
|
||||
$html .= '<div class="fcrm_event_tracking_badge">' . esc_attr($event->event_key) . '<span class="fcrm_event_tracking_count">(' . esc_html($event->counter) . ')</span></div>';
|
||||
$html .= '<span class="fcrm_event_tracking_date">' . $event->updated_at . '</span>';
|
||||
$html .= '</div>';
|
||||
$html .= '</li>';
|
||||
}
|
||||
$html .= '</ul></div>';
|
||||
|
||||
$widgets['event_tracking'] = [
|
||||
'title' => __('Event Tracking', 'fluent-crm'),
|
||||
'content' => $html,
|
||||
'has_pagination' => $events->total() > $events->perPage(),
|
||||
'total' => $events->total(),
|
||||
'per_page' => $events->perPage(),
|
||||
'current_page' => $events->currentPage()
|
||||
];
|
||||
|
||||
return $widgets;
|
||||
}
|
||||
|
||||
public function addEventTrackingFilterOptions($groups)
|
||||
{
|
||||
if (!Helper::isExperimentalEnabled('event_tracking')) {
|
||||
return $groups;
|
||||
}
|
||||
|
||||
$groups['event_tracking'] = [
|
||||
'label' => __('Event Tracking', 'fluent-crm'),
|
||||
'value' => 'event_tracking',
|
||||
'children' => $this->getConditionItems()
|
||||
];
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
public function addEventTrackingConditionOptions($items)
|
||||
{
|
||||
if (!Helper::isExperimentalEnabled('event_tracking')) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
'label' => __('Event Tracking', 'fluent-crm'),
|
||||
'value' => 'event_tracking',
|
||||
'children' => $this->getConditionItems()
|
||||
],
|
||||
[
|
||||
'label' => __('Contact Segment', 'fluent-crm'),
|
||||
'value' => 'segment',
|
||||
'children' => [
|
||||
[
|
||||
'label' => __('Type', 'fluent-crm'),
|
||||
'value' => 'contact_type',
|
||||
'type' => 'selections',
|
||||
'component' => 'options_selector',
|
||||
'option_key' => 'contact_types',
|
||||
'is_multiple' => false,
|
||||
'is_singular_value' => true
|
||||
],
|
||||
[
|
||||
'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,
|
||||
]
|
||||
],
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
private function getConditionItems()
|
||||
{
|
||||
return [
|
||||
[
|
||||
'label' => __('Event Key', 'fluent-crm'),
|
||||
'value' => 'event_tracking_key',
|
||||
'type' => 'selections',
|
||||
'component' => 'ajax_selector',
|
||||
'option_key' => 'event_tracking_keys',
|
||||
'is_multiple' => true,
|
||||
'custom_operators' => [
|
||||
'in' => 'in',
|
||||
'not_in' => 'not in'
|
||||
],
|
||||
'creatable' => true,
|
||||
'experimental_cache' => true,
|
||||
'help' => __('Match one or more tracking events for your contacts.', 'fluent-crm')
|
||||
],
|
||||
[
|
||||
'label' => __('Event Occurrence Count', 'fluent-crm'),
|
||||
'value' => 'event_tracking_key_count',
|
||||
'type' => 'composite_optioned_compare',
|
||||
'help' => __('The provided value for your selected event will be matched with the event occurrence count', 'fluent-crm'),
|
||||
'ajax_selector' => [
|
||||
'label' => __('For Event Key', 'fluent-crm'),
|
||||
'option_key' => 'event_tracking_keys',
|
||||
'experimental_cache' => true,
|
||||
'is_multiple' => false,
|
||||
'placeholder' => __('Select Event Key', 'fluent-crm')
|
||||
],
|
||||
'value_config' => [
|
||||
'label' => __('Event Count', 'fluent-crm'),
|
||||
'type' => 'input_text',
|
||||
'data_type' => 'number',
|
||||
'placeholder' => __('Event Value', 'fluent-crm')
|
||||
],
|
||||
'custom_operators' => [
|
||||
'=' => 'equal',
|
||||
'!=' => 'not equal',
|
||||
'>' => 'greater than',
|
||||
'<' => 'less than'
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => __('Event Value', 'fluent-crm'),
|
||||
'value' => 'event_tracking_value',
|
||||
'type' => 'composite_optioned_compare',
|
||||
'help' => __('The compare value will be matched with selected event & last recorded value of the selected event key', 'fluent-crm'),
|
||||
'ajax_selector' => [
|
||||
'label' => __('For Event Key', 'fluent-crm'),
|
||||
'option_key' => 'event_tracking_keys',
|
||||
'experimental_cache' => true,
|
||||
'is_multiple' => false,
|
||||
'placeholder' => __('Select Event Key', 'fluent-crm')
|
||||
],
|
||||
'value_config' => [
|
||||
'label' => __('Compare Value', 'fluent-crm'),
|
||||
'type' => 'input_text',
|
||||
'placeholder' => __('Event Value', 'fluent-crm'),
|
||||
'data_type' => 'number',
|
||||
],
|
||||
'custom_operators' => [
|
||||
'=' => 'equal',
|
||||
'!=' => 'not equal',
|
||||
'contains' => 'includes',
|
||||
'not_contains' => 'does not include',
|
||||
'>' => 'greater than',
|
||||
'<' => 'less than'
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => __('Event Title', 'fluent-crm'),
|
||||
'value' => 'event_tracking_title',
|
||||
'type' => 'text',
|
||||
'help' => __('Match by tracking event title', 'fluent-crm')
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
class FluentBlockPatternHandler
|
||||
{
|
||||
public function shouldUnregisterAllPatterns($shouldUnregister, $context = '', $data = [])
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function addCustomPatternCategories($categories)
|
||||
{
|
||||
$categories[] = [
|
||||
'name' => 'fcrm-email',
|
||||
'label' => __('FluentCRM Email', 'fluent-crm'),
|
||||
'description' => __('Reusable email sections for FluentCRM editor.', 'fluent-crm')
|
||||
];
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
public function addCustomPatterns($patterns)
|
||||
{
|
||||
$patterns[] = [
|
||||
'name' => 'fcrm/intro-cta',
|
||||
'title' => __('Intro + CTA', 'fluent-crm'),
|
||||
'categories' => ['fcrm-email'],
|
||||
'keywords' => ['intro', 'cta'],
|
||||
'content' => '<!-- wp:group {"style":{"spacing":{"padding":{"top":"24px","right":"24px","bottom":"24px","left":"24px"}}},"layout":{"type":"constrained"}} --><div class="wp-block-group has-theme-palette-color-8-background-color has-background" style="padding-top:24px;padding-right:24px;padding-bottom:24px;padding-left:24px"><!-- wp:heading {"level":3} --><h3>' . esc_html__('Welcome to our newsletter', 'fluent-crm') . '</h3><!-- /wp:heading --><!-- wp:paragraph --><p>' . esc_html__('Share your main message here in one short paragraph.', 'fluent-crm') . '</p><!-- /wp:paragraph --><!-- wp:buttons --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#">' . esc_html__('Get Started', 'fluent-crm') . '</a></div><!-- /wp:button --></div><!-- /wp:buttons --></div><!-- /wp:group -->'
|
||||
];
|
||||
|
||||
$patterns[] = [
|
||||
'name' => 'fcrm/two-button-row',
|
||||
'title' => __('Two Button Row', 'fluent-crm'),
|
||||
'categories' => ['fcrm-email'],
|
||||
'keywords' => ['buttons', 'actions'],
|
||||
'content' => '<!-- wp:paragraph {"align":"center"} --><p class="has-text-align-center">' . esc_html__('Choose an action:', 'fluent-crm') . '</p><!-- /wp:paragraph --><!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#">' . esc_html__('Primary Action', 'fluent-crm') . '</a></div><!-- /wp:button --><!-- wp:button {"className":"is-style-outline"} --><div class="wp-block-button is-style-outline"><a class="wp-block-button__link wp-element-button" href="#">' . esc_html__('Secondary Action', 'fluent-crm') . '</a></div><!-- /wp:button --></div><!-- /wp:buttons -->'
|
||||
];
|
||||
|
||||
$patterns[] = [
|
||||
'name' => 'fcrm/feature-list',
|
||||
'title' => __('Feature List', 'fluent-crm'),
|
||||
'categories' => ['fcrm-email'],
|
||||
'keywords' => ['list', 'features'],
|
||||
'content' => '<!-- wp:heading {"level":4} --><h4>' . esc_html__('Why people choose us', 'fluent-crm') . '</h4><!-- /wp:heading --><!-- wp:list --><ul class="wp-block-list"><!-- wp:list-item --><li>' . esc_html__('Fast setup', 'fluent-crm') . '</li><!-- /wp:list-item --><!-- wp:list-item --><li>' . esc_html__('Simple workflow', 'fluent-crm') . '</li><!-- /wp:list-item --><!-- wp:list-item --><li>' . esc_html__('Better conversion', 'fluent-crm') . '</li><!-- /wp:list-item --></ul><!-- /wp:list -->'
|
||||
];
|
||||
|
||||
$patterns[] = [
|
||||
'name' => 'fcrm/event-reminder',
|
||||
'title' => __('Event Reminder', 'fluent-crm'),
|
||||
'categories' => ['fcrm-email'],
|
||||
'keywords' => ['event', 'reminder'],
|
||||
'content' => '<!-- wp:group {"style":{"spacing":{"padding":{"top":"20px","right":"20px","bottom":"20px","left":"20px"}}},"layout":{"type":"constrained"}} --><div class="wp-block-group" style="padding-top:20px;padding-right:20px;padding-bottom:20px;padding-left:20px"><!-- wp:heading {"level":4} --><h4>' . esc_html__('Reminder: Upcoming Event', 'fluent-crm') . '</h4><!-- /wp:heading --><!-- wp:paragraph --><p>' . esc_html__('Date: Monday, 10:00 AM', 'fluent-crm') . '<br>' . esc_html__('Location: Online', 'fluent-crm') . '</p><!-- /wp:paragraph --><!-- wp:buttons --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#">' . esc_html__('Add to Calendar', 'fluent-crm') . '</a></div><!-- /wp:button --></div><!-- /wp:buttons --></div><!-- /wp:group -->'
|
||||
];
|
||||
|
||||
$patterns[] = [
|
||||
'name' => 'fcrm/simple-footer-note',
|
||||
'title' => __('Simple Footer Note', 'fluent-crm'),
|
||||
'categories' => ['fcrm-email'],
|
||||
'keywords' => ['footer', 'note'],
|
||||
'content' => '<!-- wp:separator --><hr class="wp-block-separator has-alpha-channel-opacity"/><!-- /wp:separator --><!-- wp:paragraph {"align":"center","fontSize":"small"} --><p class="has-text-align-center has-small-font-size">' . esc_html__('Need help? Reply to this email and our team will assist you.', 'fluent-crm') . '</p><!-- /wp:paragraph -->'
|
||||
];
|
||||
|
||||
return $patterns;
|
||||
}
|
||||
}
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Models\Tag;
|
||||
|
||||
class FluentConditionalContentBlockHandler
|
||||
{
|
||||
|
||||
const BLOCK_NAME = 'fluent-crm/conditional-content';
|
||||
|
||||
const DEFAULT_CONDITION = 'show_if_tag_exist';
|
||||
|
||||
/**
|
||||
* Legacy condition keys kept for backward compatibility.
|
||||
*/
|
||||
private $legacyMap = [
|
||||
'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',
|
||||
];
|
||||
|
||||
public function register()
|
||||
{
|
||||
add_action('init', [$this, 'registerBlock']);
|
||||
add_action('enqueue_block_editor_assets', [$this, 'enqueueEditorAssets']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the block with a render callback.
|
||||
* The JS save() still writes HTML into post_content for storage,
|
||||
* but the render_callback fully controls frontend output.
|
||||
*/
|
||||
public function registerBlock()
|
||||
{
|
||||
if (!function_exists('register_block_type')) {
|
||||
return;
|
||||
}
|
||||
|
||||
register_block_type(self::BLOCK_NAME, [
|
||||
'api_version' => 3,
|
||||
'editor_script' => 'fluent-crm-conditional-content-block',
|
||||
'attributes' => [
|
||||
'condition_type' => [
|
||||
'type' => 'string',
|
||||
'default' => self::DEFAULT_CONDITION,
|
||||
],
|
||||
'tag_ids' => [
|
||||
'type' => 'array',
|
||||
'default' => [],
|
||||
],
|
||||
],
|
||||
'supports' => [
|
||||
'align' => ['wide', 'full'],
|
||||
'anchor' => true,
|
||||
'html' => false,
|
||||
],
|
||||
'render_callback' => [$this, 'renderBlock'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function enqueueEditorAssets()
|
||||
{
|
||||
// The iframe editor already registers its own conditional block implementation.
|
||||
if (isset($_REQUEST['fluent_crm_block_editor'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$handle = 'fluent-crm-conditional-content-block';
|
||||
|
||||
wp_register_script(
|
||||
$handle,
|
||||
fluentCrmMix('public/conditional-content-block.js'),
|
||||
['wp-blocks', 'wp-block-editor', 'wp-components', 'wp-element', 'wp-i18n'],
|
||||
FLUENTCRM_PLUGIN_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
$tags = Tag::select(['id', 'title'])->orderBy('title', 'ASC')->get();
|
||||
|
||||
wp_localize_script($handle, 'fcrmConditionalContentConfig', [
|
||||
'hasPro' => defined('FLUENTCAMPAIGN'),
|
||||
'tags' => $tags
|
||||
]);
|
||||
|
||||
wp_set_script_translations($handle, 'fluent-crm');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render callback. Receives block attributes, the rendered inner blocks
|
||||
* as $content, and the WP_Block instance.
|
||||
*
|
||||
* @param array $attributes
|
||||
* @param string $content Inner blocks already rendered.
|
||||
* @param \WP_Block $block
|
||||
* @return string
|
||||
*/
|
||||
public function renderBlock($attributes, $content, $block)
|
||||
{
|
||||
if (!$this->passesCondition($attributes)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// no inner blocks placed at all — nothing to render.
|
||||
// Checking $block->inner_blocks (parsed block data) is the authoritative source of truth
|
||||
// and avoids inspecting the rendered HTML string, which loses semantic information.
|
||||
if (count($block->inner_blocks) === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// inner blocks exist but all rendered to nothing — for example a Query Loop
|
||||
// with no results, a dynamic block gated by its own conditions, or a plugin-restricted
|
||||
// block. Avoids outputting an empty wrapper div in those cases.
|
||||
// trim() on the raw HTML string is intentional: any real element (iframe, video, image,
|
||||
// paragraph, etc.) produces a non-empty string. wp_strip_all_tags() is deliberately
|
||||
// avoided here because it removes HTML tags and would incorrectly treat media-only
|
||||
// content (iframes, videos, images) as empty.
|
||||
if (trim($content) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$wrapperAttributes = get_block_wrapper_attributes([
|
||||
'class' => 'fc-cond-section',
|
||||
]);
|
||||
|
||||
return sprintf(
|
||||
'<div %1$s><div class="fc-cond-blocks">%2$s</div></div>',
|
||||
$wrapperAttributes,
|
||||
$content
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the current visitor passes the condition.
|
||||
*/
|
||||
private function passesCondition($attrs)
|
||||
{
|
||||
$condition = $this->normalizeConditionType(
|
||||
isset($attrs['condition_type']) ? $attrs['condition_type'] : self::DEFAULT_CONDITION
|
||||
);
|
||||
|
||||
$tagIds = isset($attrs['tag_ids']) && is_array($attrs['tag_ids'])
|
||||
? array_values(array_filter(array_map('intval', $attrs['tag_ids'])))
|
||||
: [];
|
||||
|
||||
switch ($condition) {
|
||||
case 'show_if_user_logged_in':
|
||||
return is_user_logged_in();
|
||||
|
||||
case 'show_if_user_not_logged_in':
|
||||
return !is_user_logged_in();
|
||||
|
||||
case 'show_if_tag_exist':
|
||||
if (empty($tagIds)) {
|
||||
return false;
|
||||
}
|
||||
return $this->contactHasAnyTag($tagIds);
|
||||
|
||||
case 'show_if_tag_not_exist':
|
||||
if (empty($tagIds)) {
|
||||
return true;
|
||||
}
|
||||
return !$this->contactHasAnyTag($tagIds);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current contact has any of the given tag IDs.
|
||||
*/
|
||||
private function contactHasAnyTag(array $tagIds)
|
||||
{
|
||||
$contact = $this->getCurrentContact();
|
||||
|
||||
if (!$contact) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $contact->hasAnyTagId($tagIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current contact once per request.
|
||||
*/
|
||||
private function getCurrentContact()
|
||||
{
|
||||
static $resolved = null;
|
||||
static $cached = false;
|
||||
|
||||
if ($cached) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$cached = true;
|
||||
|
||||
$contact = fluentcrm_get_current_contact();
|
||||
|
||||
if ($contact) {
|
||||
$resolved = $contact->load('tags');
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map legacy keys to current condition keys, mirroring the JS side.
|
||||
*/
|
||||
private function normalizeConditionType($value)
|
||||
{
|
||||
$value = trim((string)$value);
|
||||
|
||||
if (!$value) {
|
||||
return self::DEFAULT_CONDITION;
|
||||
}
|
||||
|
||||
return isset($this->legacyMap[$value]) ? $this->legacyMap[$value] : $value;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentForm\App\Models\Submission;
|
||||
use FluentForm\App\Modules\Acl\Acl;
|
||||
use FluentForm\App\Services\FormBuilder\ShortCodeParser;
|
||||
|
||||
/**
|
||||
* FormSubmissions Class
|
||||
*
|
||||
* Fluent Forms Integration Class
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class FormSubmissions
|
||||
{
|
||||
public function register()
|
||||
{
|
||||
if (defined('FLUENTFORM')) {
|
||||
add_filter('fluent_crm/form_submission_providers', [$this, 'pushDefaultFormProviders']);
|
||||
add_filter('fluentcrm_get_form_submissions_fluentform', [$this, 'getFluentFormSubmissions'], 10, 2);
|
||||
add_filter('fluent_crm/dynamic_contact_item_view_fluentform', [$this, 'getFluentFormSubmissionDetails'], 10, 2);
|
||||
|
||||
// Smartcodes
|
||||
add_filter('fluentform/editor_shortcodes', function ($smartCodes) {
|
||||
$smartCodes[0]['shortcodes']['{fluentcrm.CONTACT_DATA_KEY}'] = 'FluentCRM Data';
|
||||
return $smartCodes;
|
||||
}, 100, 1);
|
||||
add_filter('fluentform/editor_shortcode_callback_group_fluentcrm', [$this, 'parseEditorCodes'], 10, 3);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function pushDefaultFormProviders($providers)
|
||||
{
|
||||
if (defined('FLUENTFORM')) {
|
||||
$providers['fluentform'] = [
|
||||
'title' => __('Form Submissions (Fluent Forms)', 'fluent-crm'),
|
||||
'name' => __('Fluent Forms', 'fluent-crm')
|
||||
];
|
||||
}
|
||||
return $providers;
|
||||
}
|
||||
|
||||
public function getFluentFormSubmissions($data, $subscriber)
|
||||
{
|
||||
if (!defined('FLUENTFORM')) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$app = fluentCrm();
|
||||
$page = intval($app->request->get('page', 1));
|
||||
$per_page = intval($app->request->get('per_page', 10));
|
||||
|
||||
$query = fluentCrmDb()->table('fluentform_submissions')
|
||||
->select([
|
||||
'fluentform_submissions.id',
|
||||
'fluentform_submissions.form_id',
|
||||
'fluentform_forms.title',
|
||||
'fluentform_submissions.status',
|
||||
'fluentform_submissions.created_at'
|
||||
])
|
||||
->join('fluentform_forms', 'fluentform_forms.id', '=', 'fluentform_submissions.form_id')
|
||||
->where(function ($query) use ($subscriber) {
|
||||
$query->where('fluentform_submissions.response', 'LIKE', '%' . $subscriber->email . '%');
|
||||
if ($subscriber->user_id) {
|
||||
$query->orWhere('fluentform_submissions.user_id', $subscriber->user_id);
|
||||
}
|
||||
});
|
||||
|
||||
$total = $query->count();
|
||||
|
||||
$submissions = $query
|
||||
->limit($per_page)
|
||||
->offset($per_page * ($page - 1))
|
||||
->orderBy('fluentform_submissions.id', 'desc')
|
||||
->get();
|
||||
|
||||
$formattedSubmissions = [];
|
||||
foreach ($submissions as $submission) {
|
||||
$submissionUrl = admin_url('admin.php?page=fluent_forms&route=entries&form_id=' . $submission->form_id . '#/entries/' . $submission->id);
|
||||
$actionUrl = '<a target="_blank" rel="noopener" href="' . $submissionUrl . '">#' . $submission->id . '</a>';
|
||||
|
||||
$badgeClass = 'fcrm_badge';
|
||||
|
||||
if ($submission->status === 'read') {
|
||||
$badgeClass .= ' fcrm_badge_success';
|
||||
} else if ($submission->status === 'unread') {
|
||||
$badgeClass .= ' fcrm_badge_warning';
|
||||
}
|
||||
|
||||
$formattedSubmissions[] = [
|
||||
'__id' => $submission->id,
|
||||
'id' => $actionUrl,
|
||||
'title' => $submission->title,
|
||||
'Status' => '<span class="' . $badgeClass . '">' . $submission->status . '</span>',
|
||||
'Submitted At' => '<a target="_blank" rel="noopener" href="' . $submissionUrl . '">' . $submission->created_at . '</a>',
|
||||
'action' => 'view'
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'data' => $formattedSubmissions,
|
||||
'columns_config' => [
|
||||
'id' => [
|
||||
'label' => __('ID', 'fluent-crm'),
|
||||
'width' => '100px'
|
||||
],
|
||||
'title' => [
|
||||
'label' => __('Form Title', 'fluent-crm')
|
||||
],
|
||||
'Status' => [
|
||||
'label' => __('Status', 'fluent-crm'),
|
||||
'width' => '100px'
|
||||
],
|
||||
'Submitted At' => [
|
||||
'label' => __('Submitted At', 'fluent-crm'),
|
||||
'width' => '180px'
|
||||
],
|
||||
'action' => [
|
||||
'quick_action' => true,
|
||||
'label' => __('Action', 'fluent-crm'),
|
||||
'width' => '100px'
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getFluentFormSubmissionDetails($dataView, $params)
|
||||
{
|
||||
$submissionId = (int)Arr::get($params, '__id');
|
||||
|
||||
if (!$submissionId) {
|
||||
$dataView['content_html'] = '<div class="fc-crm-no-data-view"><p>' . __('No submission found', 'fluent-crm') . '</p></div>';
|
||||
return $dataView;
|
||||
}
|
||||
|
||||
$submission = Submission::with(['form'])->find($submissionId);
|
||||
if (!$submission || !$submission->form) {
|
||||
$dataView['content_html'] = '<div class="fc-crm-no-data-view"><p>' . __('No submission found', 'fluent-crm') . '</p></div>';
|
||||
return $dataView;
|
||||
}
|
||||
|
||||
$form = $submission->form;
|
||||
|
||||
if (!Acl::hasPermission('fluentform_entries_viewer', $form->id)) {
|
||||
$dataView['title'] = __('Permission Denied', 'fluent-crm');
|
||||
$dataView['content_html'] = '<div class="fc-crm-no-data-view"><p>' . __('You do not have permission to view this submission.', 'fluent-crm') . '</p></div>';
|
||||
return $dataView;
|
||||
}
|
||||
|
||||
$submittedData = json_decode($submission->response, true);
|
||||
$html = '<b>Submission Details</b><br/><br/><div>{all_data}</div>';
|
||||
if ($submission->payment_status) {
|
||||
$html .= '<h2>Payment Details</h2>';
|
||||
$html .= '{payment.receipt}';
|
||||
}
|
||||
|
||||
$html .= '<h4>Additional Details:</h4>';
|
||||
$html .= '<ul>';
|
||||
$html .= '<li><strong>Source URL:</strong> {submission.source_url}</li>';
|
||||
$html .= '<li><strong>Serial #:</strong> {submission.serial_number}</li>';
|
||||
$html .= '<li><strong>Browser:</strong> {submission.browser} / {submission.device}</li>';
|
||||
$html .= '<li><strong>Date:</strong> {submission.created_at}</li>';
|
||||
$html .= '</ul>';
|
||||
|
||||
$body = ShortCodeParser::parse(
|
||||
$html,
|
||||
$submission->id,
|
||||
$submittedData,
|
||||
$form,
|
||||
false,
|
||||
true
|
||||
);
|
||||
|
||||
$dataView['title'] = sprintf(__('Submission #%d - %s', 'fluent-crm'), $submission->id, $form->title);
|
||||
$dataView['content_html'] = '<style>.fc-crm-form-submission-view table { width: 100% !important; }</style><div class="fc-crm-form-submission-view">' . $body . '</div>';
|
||||
|
||||
$dataView['footer_content'] = '<a class="el-button fcrm_primary_btn" target="_blank" rel="noopener" href="' . admin_url('admin.php?page=fluent_forms&route=entries&form_id=' . $form->id . '#/entries/' . $submission->id) . '">View in FluentForms</a>';
|
||||
|
||||
return $dataView;
|
||||
|
||||
}
|
||||
|
||||
public function parseEditorCodes($code, $form, $keys)
|
||||
{
|
||||
$contact = FluentCrmApi('contacts')->getCurrentContact(true, true);
|
||||
|
||||
$providedKey = $keys[0];
|
||||
|
||||
// maybe has fallback value
|
||||
$dynamicKey = explode('|', $providedKey);
|
||||
$fallBack = '';
|
||||
if (count($dynamicKey) > 1) {
|
||||
$fallBack = $dynamicKey[1];
|
||||
}
|
||||
$ref = $dynamicKey[0];
|
||||
|
||||
if (!$contact) {
|
||||
return $fallBack;
|
||||
}
|
||||
|
||||
$validMainProps = (new Subscriber)->getFillable();
|
||||
$validMainProps[] = 'id';
|
||||
|
||||
if (in_array($ref, $validMainProps)) {
|
||||
if ($contact->{$ref}) {
|
||||
return $contact->{$ref};
|
||||
}
|
||||
|
||||
return $fallBack;
|
||||
}
|
||||
|
||||
// Maybe it's a custom field
|
||||
$customData = $contact->custom_fields();
|
||||
|
||||
if ($customData && !empty($customData[$ref])) {
|
||||
$value = $customData[$ref];
|
||||
if (is_array($value)) {
|
||||
return implode(',', $value);
|
||||
}
|
||||
|
||||
return $customData[$ref];
|
||||
}
|
||||
|
||||
$listMaps = [
|
||||
'list_ids' => 'id',
|
||||
'list_titles' => 'title',
|
||||
'list_slugs' => 'slug'
|
||||
];
|
||||
|
||||
$tagMaps = [
|
||||
'tag_ids' => 'id',
|
||||
'tag_titles' => 'title',
|
||||
'tag_slugs' => 'slug'
|
||||
];
|
||||
|
||||
|
||||
if (isset($listMaps[$ref])) {
|
||||
$listProps = [];
|
||||
foreach ($contact->lists as $list) {
|
||||
$listProps[] = $list->{$listMaps[$ref]};
|
||||
}
|
||||
if ($listProps) {
|
||||
return trim(implode(', ', $listProps));
|
||||
}
|
||||
} else if (isset($tagMaps[$ref])) {
|
||||
$tagProps = [];
|
||||
foreach ($contact->tags as $tag) {
|
||||
$tagProps[] = $tag->{$tagMaps[$ref]};
|
||||
}
|
||||
if ($tagProps) {
|
||||
return trim(implode(', ', $tagProps));
|
||||
}
|
||||
}
|
||||
|
||||
return $fallBack;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Models\Campaign;
|
||||
use FluentCrm\App\Models\Funnel;
|
||||
use FluentCrm\App\Models\FunnelCampaign;
|
||||
use FluentCrm\App\Models\FunnelSequence;
|
||||
use FluentCrm\App\Models\FunnelSubscriber;
|
||||
use FluentCrm\App\Services\Funnel\Actions\ApplyCompanyAction;
|
||||
use FluentCrm\App\Services\Funnel\Actions\ApplyListAction;
|
||||
use FluentCrm\App\Services\Funnel\Actions\ApplyTagAction;
|
||||
use FluentCrm\App\Services\Funnel\Actions\DetachCompanyAction;
|
||||
use FluentCrm\App\Services\Funnel\Actions\DetachListAction;
|
||||
use FluentCrm\App\Services\Funnel\Actions\DetachTagAction;
|
||||
use FluentCrm\App\Services\Funnel\Actions\SendEmailAction;
|
||||
use FluentCrm\App\Services\Funnel\Actions\WaitTimeAction;
|
||||
use FluentCrm\App\Services\Funnel\Benchmarks\ListAppliedBenchmark;
|
||||
use FluentCrm\App\Services\Funnel\Benchmarks\RemoveFromListBenchmark;
|
||||
use FluentCrm\App\Services\Funnel\Benchmarks\RemoveFromTagBenchmark;
|
||||
use FluentCrm\App\Services\Funnel\Benchmarks\TagAppliedBenchmark;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
use FluentCrm\App\Services\Funnel\FunnelProcessor;
|
||||
use FluentCrm\App\Services\Funnel\SequencePoints;
|
||||
use FluentCrm\App\Services\Funnel\Triggers\FluentFormSubmissionTrigger;
|
||||
use FluentCrm\App\Services\Funnel\Triggers\FluentFormSubscriptionCancelledTrigger;
|
||||
use FluentCrm\App\Services\Funnel\Triggers\FluentFormSubscriptionPaymentReceivedTrigger;
|
||||
use FluentCrm\App\Services\Funnel\Triggers\UserRegistrationTrigger;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\App\Services\PermissionManager;
|
||||
use FluentCrm\App\Services\Sanitize;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
/**
|
||||
* FunnelHandler Class - Automation Funnel Handler
|
||||
*
|
||||
* Automation Funnel Handler Class
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class FunnelHandler
|
||||
{
|
||||
private $settingsKey = 'fluentcrm_funnel_settings';
|
||||
|
||||
private $lockKey = '_fc_funnel_processor_lock';
|
||||
|
||||
private $lockTimeout = 90;
|
||||
|
||||
protected $funnelFired = false;
|
||||
|
||||
private $registeredFunnelTriggers = [];
|
||||
|
||||
private $registeredTriggerFallbacks = [];
|
||||
|
||||
private $funnelItemsRegistered = false;
|
||||
|
||||
public function register()
|
||||
{
|
||||
/*
|
||||
* Core funnel items must register before the early active-trigger pass
|
||||
* so their fluentcrm_funnel_arg_num_* filters are available at init
|
||||
* priority 2. This lets core events fired by other init priority 10
|
||||
* callbacks, such as LifterLMS user registration, enter funnels.
|
||||
*
|
||||
* Pro integrations can register trigger arg-count filters before init priority 2.
|
||||
* Register those ready triggers early so events fired during init priority 10,
|
||||
* such as EDD manual order status updates, are not missed.
|
||||
*
|
||||
* The fallback pass at priority 20 preserves the existing behavior for triggers
|
||||
* whose arg-count filters are not available during the early pass.
|
||||
*/
|
||||
add_action('init', [$this, 'registerFunnelItems'], 1);
|
||||
add_action('init', [$this, 'registerEarlyActiveTriggers'], 2);
|
||||
add_action('init', [$this, 'handle'], 10);
|
||||
add_action('init', [$this, 'registerActiveTriggers'], 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register core funnel actions, benchmarks, triggers, and free Pro placeholders once.
|
||||
*
|
||||
* This runs before registerEarlyActiveTriggers() so core trigger arg-count filters
|
||||
* are present when active funnel listeners are attached before other init@10 callbacks.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function registerFunnelItems()
|
||||
{
|
||||
if ($this->funnelItemsRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->funnelItemsRegistered = true;
|
||||
|
||||
$this->initBlockActions();
|
||||
$this->initBenchMarkBlocks();
|
||||
$this->initTriggers();
|
||||
|
||||
if (!defined('FLUENTCAMPAIGN_DIR_FILE')) {
|
||||
new \FluentCrm\App\Services\Funnel\ProFunnelItems();
|
||||
}
|
||||
}
|
||||
|
||||
public function registerEarlyActiveTriggers()
|
||||
{
|
||||
$this->registerActiveTriggers(true);
|
||||
}
|
||||
|
||||
public function registerActiveTriggers($onlyRegisteredArgFilters = false)
|
||||
{
|
||||
$triggers = get_option($this->settingsKey, []);
|
||||
$triggers = array_unique($triggers);
|
||||
|
||||
if (!$triggers) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($triggers as $triggerName) {
|
||||
if ($this->shouldSkipEddTriggerRegistration($triggerName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($this->registeredFunnelTriggers[$triggerName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
* Early registration is only safe when the trigger's arg-count filter is
|
||||
* already registered. Otherwise the priority 20 pass will register it
|
||||
* after handle() has initialized the core trigger filters.
|
||||
*/
|
||||
$argNumFilterName = 'fluentcrm_funnel_arg_num_' . $triggerName;
|
||||
if ($onlyRegisteredArgFilters && !has_filter($argNumFilterName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$argNum = apply_filters($argNumFilterName, 1);
|
||||
add_action($triggerName, function () use ($triggerName, $argNum) {
|
||||
$this->mapTriggers($triggerName, func_get_args(), $argNum);
|
||||
}, 10, $argNum);
|
||||
|
||||
$this->registeredFunnelTriggers[$triggerName] = true;
|
||||
}
|
||||
|
||||
/*
|
||||
* EDD also exposes edd_complete_purchase after a successful payment.
|
||||
* Keep the existing fallback, but attach it only once and only after the
|
||||
* main EDD payment-status trigger has been registered.
|
||||
*/
|
||||
if (
|
||||
isset($this->registeredFunnelTriggers['edd_update_payment_status']) &&
|
||||
empty($this->registeredTriggerFallbacks['edd_complete_purchase'])
|
||||
) {
|
||||
add_action('edd_complete_purchase', function ($paymentId) {
|
||||
$this->mapTriggers('edd_update_payment_status', [$paymentId, 'complete', 'pending'], 3);
|
||||
});
|
||||
|
||||
$this->registeredTriggerFallbacks['edd_complete_purchase'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip stored EDD automation hooks when the active EDD install is unsupported.
|
||||
*
|
||||
* Existing EDD funnel data should remain stored, but EDD runtime dispatch must
|
||||
* not be registered unless the site is running EDD 3 or newer.
|
||||
*
|
||||
* @param string $triggerName
|
||||
* @return bool
|
||||
*/
|
||||
private function shouldSkipEddTriggerRegistration($triggerName)
|
||||
{
|
||||
if (Helper::isEdd3()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($triggerName, [
|
||||
'edd_update_payment_status',
|
||||
'edd_sl_post_set_status',
|
||||
'edd_recurring_add_subscription_payment',
|
||||
'edd_subscription_status_change',
|
||||
'edd_fc_order_refunded_simulation'
|
||||
], true);
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->registerFunnelItems();
|
||||
|
||||
add_action('fluent_crm_process_automation', function () {
|
||||
if ($this->funnelFired) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->funnelFired = true;
|
||||
|
||||
if (!$this->acquireFunnelProcessorLock()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
(new FunnelProcessor())->followUpSequenceActions();
|
||||
} finally {
|
||||
$this->releaseFunnelProcessorLock();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function mapTriggers($triggerName, $originalArgs, $argNumber)
|
||||
{
|
||||
$triggerNameBase = $triggerName;
|
||||
|
||||
$funnels = Funnel::where('status', 'published')
|
||||
->where('trigger_name', $triggerNameBase)
|
||||
->get();
|
||||
|
||||
foreach ($funnels as $funnel) {
|
||||
ob_start();
|
||||
/**
|
||||
* Automation Funnel Start Trigger from specific action
|
||||
* @param Funnel $funnel
|
||||
* @param array $originalArgs Original Arguments from the trigger
|
||||
*/
|
||||
do_action("fluentcrm_funnel_start_{$triggerName}", $funnel, $originalArgs);
|
||||
$maybeErrors = ob_get_clean();
|
||||
}
|
||||
|
||||
$benchMarks = FunnelSequence::where('type', 'benchmark')
|
||||
->where('action_name', $triggerNameBase)
|
||||
->whereHas('funnel', function ($q) {
|
||||
return $q->where('status', 'published');
|
||||
})
|
||||
->orderBy('id', 'ASC')
|
||||
->get();
|
||||
|
||||
foreach ($benchMarks as $benchMark) {
|
||||
ob_start();
|
||||
/**
|
||||
* Automation Funnel's Benchmark Start Trigger from specific action trigger
|
||||
* @param Funnel $funnel
|
||||
* @param array $originalArgs Original Arguments from the trigger
|
||||
*/
|
||||
do_action("fluentcrm_funnel_benchmark_start_{$triggerName}", $benchMark, $originalArgs);
|
||||
$maybeErrors = ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the funnel-processor lock so two runners can't process the same
|
||||
* queue concurrently. Backed by an atomic conditional UPDATE on wp_options
|
||||
* (Helper::acquireDbLock) on every environment — not wp_cache_add(), which
|
||||
* is not atomic under all object-cache drop-ins (e.g. LiteSpeed) and would
|
||||
* let concurrent runners all acquire the lock. See Helper::acquireDbLock().
|
||||
*/
|
||||
private function acquireFunnelProcessorLock()
|
||||
{
|
||||
return Helper::acquireDbLock($this->lockKey, $this->lockTimeout);
|
||||
}
|
||||
|
||||
private function releaseFunnelProcessorLock()
|
||||
{
|
||||
Helper::releaseDbLock($this->lockKey);
|
||||
}
|
||||
|
||||
public function resetFunnelIndexes()
|
||||
{
|
||||
$funnels = Funnel::select('trigger_name')
|
||||
->where('status', 'published')
|
||||
->groupBy('trigger_name')
|
||||
->get();
|
||||
|
||||
$funnelArrays = [];
|
||||
foreach ($funnels as $funnel) {
|
||||
$funnelArrays[] = $funnel->trigger_name;
|
||||
}
|
||||
|
||||
$sequenceMetrics = FunnelSequence::select('action_name')
|
||||
->where('status', 'published')
|
||||
->where('type', 'benchmark')
|
||||
->whereHas('funnel', function ($q) {
|
||||
return $q->where('status', 'published');
|
||||
})
|
||||
->groupBy('action_name')
|
||||
->get();
|
||||
|
||||
foreach ($sequenceMetrics as $sequenceMetric) {
|
||||
$funnelArrays[] = $sequenceMetric->action_name;
|
||||
}
|
||||
|
||||
update_option($this->settingsKey, array_unique($funnelArrays), 'yes');
|
||||
}
|
||||
|
||||
private function initTriggers()
|
||||
{
|
||||
new UserRegistrationTrigger();
|
||||
new FluentFormSubmissionTrigger();
|
||||
if (defined('FLUENTFORMPRO')) {
|
||||
new FluentFormSubscriptionPaymentReceivedTrigger();
|
||||
new FluentFormSubscriptionCancelledTrigger();
|
||||
}
|
||||
}
|
||||
|
||||
private function initBlockActions()
|
||||
{
|
||||
if (Helper::isCompanyEnabled()) {
|
||||
new ApplyCompanyAction();
|
||||
new DetachCompanyAction();
|
||||
}
|
||||
new ApplyListAction();
|
||||
new ApplyTagAction();
|
||||
new DetachListAction();
|
||||
new DetachTagAction();
|
||||
new WaitTimeAction();
|
||||
new SendEmailAction();
|
||||
}
|
||||
|
||||
private function initBenchMarkBlocks()
|
||||
{
|
||||
new ListAppliedBenchmark();
|
||||
new TagAppliedBenchmark();
|
||||
new RemoveFromListBenchmark();
|
||||
new RemoveFromTagBenchmark();
|
||||
}
|
||||
|
||||
public function resumeSubscriberFunnels($subscriber, $oldStatus)
|
||||
{
|
||||
$funnelSubscribers = FunnelSubscriber::where('status', 'pending')
|
||||
->with(['funnel'])
|
||||
->where('subscriber_id', $subscriber->id)
|
||||
->whereHas('funnel', function ($query) {
|
||||
return $query->where('status', 'published');
|
||||
})
|
||||
->get();
|
||||
|
||||
$funnelProcessorClass = new FunnelProcessor();
|
||||
|
||||
foreach ($funnelSubscribers as $funnelSubscriber) {
|
||||
$funnel = $funnelSubscriber->funnel;
|
||||
|
||||
if (!$funnel || $funnel->status != 'published') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$funnelProcessorClass->resumeFunnelSubscriber($funnel, $subscriber, $funnelSubscriber);
|
||||
}
|
||||
}
|
||||
|
||||
public function saveSequences()
|
||||
{
|
||||
check_ajax_referer('fluentcrm_ajax_nonce', '_nonce');
|
||||
|
||||
$hasPermission = PermissionManager::currentUserCan('fcrm_write_funnels');
|
||||
|
||||
if (!$hasPermission) {
|
||||
wp_send_json([
|
||||
'message' => __('Sorry, You do not have permission to do this action', 'fluent-crm')
|
||||
], 422);
|
||||
}
|
||||
|
||||
$request = FluentCrm('request');
|
||||
$data = $request->all();
|
||||
|
||||
$data['sequences'] = wp_unslash(Arr::get($data, 'sequences'));
|
||||
|
||||
$funnel = FunnelHelper::saveFunnelSequence($data['funnel_id'], $data);
|
||||
|
||||
wp_send_json([
|
||||
'sequences' => FunnelHelper::getFunnelSequences($funnel, true),
|
||||
'message' => __('Sequence successfully updated', 'fluent-crm')
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportFunnel()
|
||||
{
|
||||
check_ajax_referer('fluentcrm_ajax_nonce', '_nonce');
|
||||
|
||||
$permission = 'manage_options';
|
||||
if (!current_user_can($permission)) {
|
||||
die('You do not have permission');
|
||||
}
|
||||
|
||||
$funnelId = intval($_REQUEST['funnel_id']);
|
||||
$funnel = Funnel::findOrFail($funnelId);
|
||||
/**
|
||||
* Determine the funnel editor details based on the funnel's trigger name.
|
||||
*
|
||||
* The dynamic portion of the hook name, `$funnel->trigger_name`, refers to the trigger name of the funnel.
|
||||
*
|
||||
* @param object $funnel The funnel object containing the editor details.
|
||||
* @since 2.0.0
|
||||
*
|
||||
*/
|
||||
$funnel = apply_filters('fluentcrm_funnel_editor_details_' . $funnel->trigger_name, $funnel);
|
||||
|
||||
$funnel->labels = $funnel->getFormattedLabels();
|
||||
|
||||
$funnel->sequences = FunnelHelper::getFunnelSequences($funnel, true);
|
||||
|
||||
$funnel->site_hash = md5(site_url());
|
||||
$funnel->export_date = gmdate('Y-m-d H:i:s');
|
||||
|
||||
header('Content-disposition: attachment; filename=' . sanitize_title($funnel->title, 'funnel', 'display') . '-' . $funnelId . '.json');
|
||||
header('Content-type: application/json');
|
||||
echo json_encode($funnel); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
exit();
|
||||
}
|
||||
|
||||
public function saveEmailAction()
|
||||
{
|
||||
check_ajax_referer('fluentcrm_ajax_nonce', '_nonce');
|
||||
|
||||
$hasPermission = PermissionManager::currentUserCan('fcrm_write_funnels');
|
||||
|
||||
if (!$hasPermission) {
|
||||
wp_send_json([
|
||||
'message' => __('Sorry, You do not have permission to do this action', 'fluent-crm')
|
||||
], 422);
|
||||
}
|
||||
|
||||
$request = FluentCrm('request');
|
||||
$funnelId = $request->get('funnel_id');
|
||||
$funnel = Funnel::findOrFail($funnelId);
|
||||
|
||||
$settings = Helper::parseArrayOrJson($request->get('action_data'));
|
||||
|
||||
$settings['action_name'] = 'send_custom_email';
|
||||
|
||||
$funnelCampaign = Arr::get($settings, 'campaign', []);
|
||||
|
||||
$funnelCampaignId = Arr::get($funnelCampaign, 'id');
|
||||
|
||||
$data = Arr::only($funnelCampaign, array_keys(FunnelCampaign::getMock()));
|
||||
$data['settings']['mailer_settings'] = Arr::get($settings, 'mailer_settings', []);
|
||||
|
||||
$type = 'created';
|
||||
|
||||
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 . ')';
|
||||
FunnelCampaign::where('id', $funnelCampaignId)->update($data);
|
||||
$type = 'updated';
|
||||
} else {
|
||||
$data['parent_id'] = $funnel->id;
|
||||
$data['type'] = 'funnel_email_campaign';
|
||||
$data['title'] = $funnel->title . ' (' . $funnel->id . ')';
|
||||
$campaign = FunnelCampaign::create($data);
|
||||
$funnelCampaignId = $campaign->id;
|
||||
}
|
||||
|
||||
if (Arr::get($funnelCampaign, 'design_template') == 'visual_builder') {
|
||||
$design = Arr::get($funnelCampaign, '_visual_builder_design', []);
|
||||
fluentcrm_update_campaign_meta($funnelCampaignId, '_visual_builder_design', $design);
|
||||
} else {
|
||||
fluentcrm_delete_campaign_meta($funnelCampaignId, '_visual_builder_design');
|
||||
}
|
||||
|
||||
$refCampaign = FunnelCampaign::find($funnelCampaignId);
|
||||
|
||||
wp_send_json([
|
||||
'type' => $type,
|
||||
'reference_campaign' => $funnelCampaignId,
|
||||
'campaign' => Arr::only($refCampaign->toArray(), array_keys(FunnelCampaign::getMock()))
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function saveCampaignEmail()
|
||||
{
|
||||
check_ajax_referer('fluentcrm_ajax_nonce', '_nonce');
|
||||
|
||||
$hasPermission = PermissionManager::currentUserCan('fcrm_manage_emails');
|
||||
|
||||
if (!$hasPermission) {
|
||||
wp_send_json([
|
||||
'message' => __('Sorry, You do not have permission to do this action', 'fluent-crm')
|
||||
], 422);
|
||||
}
|
||||
|
||||
$request = FluentCrm('request');
|
||||
$id = $request->get('campaign_id');
|
||||
|
||||
$data = Helper::parseArrayOrJson($request->get('action_data'));
|
||||
|
||||
if (empty($data)) {
|
||||
wp_send_json([
|
||||
'message' => __('Invalid Data', 'fluent-crm')
|
||||
], 422);
|
||||
}
|
||||
|
||||
$updateData = Arr::only($data, [
|
||||
'title',
|
||||
'slug',
|
||||
'template_id',
|
||||
'email_subject',
|
||||
'email_pre_header',
|
||||
'email_body',
|
||||
'utm_status',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
'scheduled_at',
|
||||
'design_template'
|
||||
]);
|
||||
|
||||
if (!empty($data['settings'])) {
|
||||
$updateData['settings'] = $data['settings'];
|
||||
}
|
||||
|
||||
$updateData = Sanitize::campaign($updateData);
|
||||
|
||||
$campaign = Campaign::findOrFail($id);
|
||||
|
||||
$campaign->fill($updateData)->save();
|
||||
|
||||
$nextStep = Arr::get($data, 'next_step');
|
||||
|
||||
if ($nextStep) {
|
||||
do_action('fluent_crm/update_campaign_compose', $data, $campaign);
|
||||
fluentcrm_update_campaign_meta($id, '_next_config_step', $nextStep);
|
||||
}
|
||||
|
||||
wp_send_json([
|
||||
'campaign' => $campaign
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Services\ExternalIntegrations\BricksBuilderIntegration;
|
||||
|
||||
/**
|
||||
* Integrations Class
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class Integrations
|
||||
{
|
||||
public function register()
|
||||
{
|
||||
// Full-featured integrations with functionality
|
||||
if (defined('FLUENTFORM')) {
|
||||
(new \FluentCrm\App\Services\ExternalIntegrations\FluentForm\FluentFormInit())->init();
|
||||
}
|
||||
|
||||
if(defined('FLUENTCART_VERSION')) {
|
||||
(new \FluentCrm\App\Services\ExternalIntegrations\FluentCart\FluentCart())->init();
|
||||
}
|
||||
|
||||
/*
|
||||
* Oxygen Editor Integration
|
||||
*/
|
||||
if (defined('CT_VERSION')) {
|
||||
require_once FLUENTCRM_PLUGIN_PATH . 'app/Services/ExternalIntegrations/Oxygen/oxy_init.php';
|
||||
}
|
||||
|
||||
(new EventTrackingHandler())->register();
|
||||
|
||||
if(defined('BRICKS_VERSION')) {
|
||||
(new BricksBuilderIntegration())->register();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\App\Models\CustomContactField as CustomFields;
|
||||
|
||||
/**
|
||||
* Integrations Class
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 2.5.94
|
||||
*/
|
||||
class PrefFormHandler
|
||||
{
|
||||
public function handleShortCode($atts, $noContactContent = '')
|
||||
{
|
||||
|
||||
if (isset($_REQUEST['_fc_secure_hash'])) {
|
||||
$hash = sanitize_text_field($_REQUEST['_fc_secure_hash']);
|
||||
if ($hash) {
|
||||
$_COOKIE['fc_hash_secure'] = $hash;
|
||||
}
|
||||
}
|
||||
|
||||
$settings = Helper::getGlobalEmailSettings();
|
||||
|
||||
if (Arr::get($settings, 'pref_form') != 'yes' || empty(Arr::get($settings, 'pref_general'))) {
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
do_action('fluent_crm/rendering_pref_form_shortcode');
|
||||
|
||||
$subscriber = FluentCrmApi('contacts')->getCurrentContact(true, true);
|
||||
|
||||
if (!$subscriber) {
|
||||
return $noContactContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the preference form labels in FluentCRM.
|
||||
*
|
||||
* This filter allows modification of the labels used in the preference form.
|
||||
*
|
||||
* @since 2.5.95
|
||||
*
|
||||
* @param array {
|
||||
* An associative array of labels.
|
||||
*
|
||||
* @type string $first_name Label for the first name field.
|
||||
* @type string $last_name Label for the last name field.
|
||||
* @type string $prefix Label for the title field.
|
||||
* @type string $email Label for the email field.
|
||||
* @type string $phone Label for the phone/mobile field.
|
||||
* @type string $dob Label for the date of birth field.
|
||||
* @type string $address_line_1 Label for the address line 1 field.
|
||||
* @type string $address_line_2 Label for the address line 2 field.
|
||||
* @type string $city Label for the city field.
|
||||
* @type string $state Label for the state field.
|
||||
* @type string $postal_code Label for the ZIP code field.
|
||||
* @type string $country Label for the country field.
|
||||
* @type string $update Label for the update info button.
|
||||
* @type string $address_heading Label for the address information section.
|
||||
* @type string $list_label Label for the mailing list groups section.
|
||||
* }
|
||||
*/
|
||||
$labels = apply_filters('fluent_crm/pref_labels', [
|
||||
'first_name' => __('First Name', 'fluent-crm'),
|
||||
'last_name' => __('Last Name', 'fluent-crm'),
|
||||
'prefix' => __('Title', 'fluent-crm'),
|
||||
'email' => __('Email', 'fluent-crm'),
|
||||
'phone' => __('Phone/Mobile', 'fluent-crm'),
|
||||
'dob' => __('Date of Birth', 'fluent-crm'),
|
||||
'address_line_1' => __('Address Line 1', 'fluent-crm'),
|
||||
'address_line_2' => __('Address Line 2', 'fluent-crm'),
|
||||
'city' => __('City', 'fluent-crm'),
|
||||
'state' => __('State', 'fluent-crm'),
|
||||
'postal_code' => __('ZIP Code', 'fluent-crm'),
|
||||
'country' => __('Country', 'fluent-crm'),
|
||||
'update' => __('Update info', 'fluent-crm'),
|
||||
'address_heading' => __('Address Information', 'fluent-crm'),
|
||||
'list_label' => __('Mailing List Groups', 'fluent-crm'),
|
||||
'custom_fields' => __('Custom Fields', 'fluent-crm')
|
||||
]);
|
||||
|
||||
$formFields = $this->getFormFields($settings, $subscriber, $labels, false);
|
||||
|
||||
$listOptions = [];
|
||||
$lists = Helper::getPublicLists();
|
||||
if ($lists) {
|
||||
foreach ($lists as $list) {
|
||||
$listOptions[strval($list->id)] = $list->title;
|
||||
}
|
||||
|
||||
$formattedLists = [];
|
||||
foreach ($subscriber->lists as $list) {
|
||||
$formattedLists[] = $list->id;
|
||||
}
|
||||
|
||||
$formFields['lists'] = [
|
||||
'type' => 'checkboxes',
|
||||
'name' => 'lists',
|
||||
'container_class' => 'fc_inline_checkboxes',
|
||||
'options' => $listOptions,
|
||||
'value' => $formattedLists,
|
||||
'id' => 'mailing_lists',
|
||||
'label' => Arr::get($labels, 'list_label', 'Mailing List Groups'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the preference form fields against a subscriber or contact data in FluentCRM.
|
||||
*
|
||||
* This filter allows modification of the preference form fields before they are displayed.
|
||||
*
|
||||
* @since 2.5.95
|
||||
*
|
||||
* @param array $formFields The current form fields.
|
||||
* @param object $subscriber The subscriber object.
|
||||
* @return array Modified form fields.
|
||||
*/
|
||||
$formFields = apply_filters('fluent_crm/pref_form_fields', $formFields, $subscriber);
|
||||
|
||||
$formFields[] = [
|
||||
'type' => 'hidden',
|
||||
'atts' => [
|
||||
'name' => 'action',
|
||||
'value' => 'fluent_crm_account_form'
|
||||
]
|
||||
];
|
||||
|
||||
if (isset($_REQUEST['_fc_secure_hash'])) {
|
||||
$hash = sanitize_text_field($_REQUEST['_fc_secure_hash']);
|
||||
if($hash) {
|
||||
$formFields[] = [
|
||||
'type' => 'hidden',
|
||||
'atts' => [
|
||||
'name' => '_fc_hash_secure',
|
||||
'value' => $hash
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'fluentcrm_public_pref',
|
||||
fluentCrmMix('public/public_pref.css'),
|
||||
[],
|
||||
FLUENTCRM_PLUGIN_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_script('fluentcrm_public_pref', fluentCrmMix('public/public_pref.js'), ['jquery'], FLUENTCRM_PLUGIN_VERSION, true);
|
||||
|
||||
wp_localize_script('fluentcrm_public_pref', 'fluentcrm_sub_pref', [
|
||||
'ajaxurl' => admin_url('admin-ajax.php')
|
||||
]);
|
||||
|
||||
return (string) fluentCrm('view')->make('external.pref_form', [
|
||||
'fields' => $formFields,
|
||||
'submitBtn' => [
|
||||
'container_class' => 'fc_pref_submit',
|
||||
'btn_text' => __('Update info', 'fluent-crm'),
|
||||
'atts' => [
|
||||
'type' => 'submit',
|
||||
'id' => 'fluentcrm_preferences_submit',
|
||||
'class' => 'btn fc_pref_submit'
|
||||
]
|
||||
],
|
||||
'subscriber' => $subscriber
|
||||
]);
|
||||
}
|
||||
|
||||
public function handleDynamicContentShortCode($atts, $text = '')
|
||||
{
|
||||
if(!$text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$defaults = [
|
||||
'hide_for_guest' => 'no'
|
||||
];
|
||||
|
||||
$atts = shortcode_atts($defaults, $atts, 'fluentcrm_content');
|
||||
|
||||
$subscriber = FluentCrmApi('contacts')->getCurrentContact(true, true);
|
||||
|
||||
if(!$subscriber) {
|
||||
if($atts['hide_for_guest'] == 'yes') {
|
||||
return '';
|
||||
}
|
||||
return preg_replace_callback('/({{|##)+(.*?)(}}|##)/', function ($matches) {
|
||||
if(isset($matches[2])) {
|
||||
$token = $matches[2];
|
||||
$tokens = explode('|', $token);
|
||||
if(isset($tokens[1])) {
|
||||
return $tokens[1];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}, $text);
|
||||
}
|
||||
|
||||
return \FluentCrm\App\Services\Libs\Parser\Parser::parse($text, $subscriber);
|
||||
}
|
||||
|
||||
public function handleAjax()
|
||||
{
|
||||
if (!isset($_POST['_fc_nonce']) || !wp_verify_nonce($_POST['_fc_nonce'], 'fluent_crm_account_form_fields')) {
|
||||
wp_send_json_error([
|
||||
'message' => __('Sorry, your nonce did not verify.', 'fluent-crm')
|
||||
], 422);
|
||||
}
|
||||
|
||||
$settings = Helper::getGlobalEmailSettings();
|
||||
|
||||
if (Arr::get($settings, 'pref_form') != 'yes' || empty(Arr::get($settings, 'pref_general'))) {
|
||||
wp_send_json_error([
|
||||
'message' => __('Sorry! You cannot update your profile.', 'fluent-crm')
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (isset($_REQUEST['_fc_hash_secure']) && !is_user_logged_in()) {
|
||||
$hash = sanitize_text_field($_REQUEST['_fc_hash_secure']);
|
||||
if ($hash) {
|
||||
$_COOKIE['fc_hash_secure'] = $hash;
|
||||
}
|
||||
}
|
||||
|
||||
$subscriber = FluentCrmApi('contacts')->getCurrentContact(false, true);
|
||||
|
||||
if (!$subscriber) {
|
||||
wp_send_json_error([
|
||||
'message' => __('Sorry! You cannot update your profile.', 'fluent-crm')
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validInputs = $this->getFormFields($settings, $subscriber, [], true);
|
||||
|
||||
$validKeys = array_keys($validInputs);
|
||||
|
||||
$validData = Arr::only($_REQUEST, $validKeys);
|
||||
if (empty($validData['email'])) {
|
||||
$validData['email'] = $subscriber->email;
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
foreach ($validInputs as $key => $input) {
|
||||
if (Arr::get($input, 'required') && empty($validData[$key])) {
|
||||
$errors[] = $key . ' is required';
|
||||
}
|
||||
|
||||
// Handle array values for multi-select and checkboxes
|
||||
if (isset($validData[$key]) && is_array($validData[$key])) {
|
||||
$validData[$key] = array_map('sanitize_text_field', $validData[$key]);
|
||||
} else {
|
||||
$validData[$key] = sanitize_text_field(Arr::get($validData, $key, ''));
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($validData['date_of_birth']) && !$this->isValidDate($validData['date_of_birth'])) {
|
||||
$errors[] = 'date_of_birth';
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
wp_send_json_error([
|
||||
'message' => __('Please fill up all required fields', 'fluent-crm'),
|
||||
'errors' => $errors,
|
||||
'inputs' => $validData
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Handle custom fields
|
||||
$enabledCustomFieldSlugs = Arr::get($settings, 'pref_custom', []);
|
||||
$allCustomFields = (new CustomFields)->getGlobalFields()['fields'];
|
||||
|
||||
if (!empty($allCustomFields) && !empty($enabledCustomFieldSlugs)) {
|
||||
foreach ($allCustomFields as $field) {
|
||||
$fieldKey = $field['slug'];
|
||||
|
||||
// Only process fields that are enabled in pref_custom
|
||||
if (!in_array($fieldKey, $enabledCustomFieldSlugs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($validData[$fieldKey])) {
|
||||
$value = $validData[$fieldKey];
|
||||
|
||||
// Handle different field types
|
||||
switch ($field['type']) {
|
||||
case 'checkbox':
|
||||
if (is_array($value)) {
|
||||
$value = array_map('sanitize_text_field', $value);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
case 'select-multi':
|
||||
|
||||
if (is_array($value)) {
|
||||
$value = array_map('sanitize_text_field', $value);
|
||||
} else {
|
||||
$value = [];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'number':
|
||||
$value = floatval($value);
|
||||
break;
|
||||
|
||||
case 'textarea':
|
||||
$value = isset($_POST[$fieldKey]) ? sanitize_textarea_field($_POST[$fieldKey]) : '';
|
||||
break;
|
||||
|
||||
case 'date':
|
||||
$value = sanitize_text_field($value);
|
||||
break;
|
||||
|
||||
default:
|
||||
$value = sanitize_text_field($value);
|
||||
}
|
||||
|
||||
// Update the meta with proper type
|
||||
$subscriber->updateMeta($field['slug'], $value, 'custom_field');
|
||||
unset($validData[$fieldKey]); // Remove from main data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$subscriber->fill($validData);
|
||||
|
||||
$updateData = $subscriber->getDirty();
|
||||
|
||||
if($updateData) {
|
||||
$subscriber->save();
|
||||
}
|
||||
|
||||
if (isset($_REQUEST['lists'])) {
|
||||
$publicLists = Helper::getPublicLists();
|
||||
$publicListIds = [];
|
||||
foreach ($publicLists as $publicList) {
|
||||
$publicListIds[] = $publicList->id;
|
||||
}
|
||||
|
||||
$selectedListIds = map_deep($_REQUEST['lists'], 'intval');
|
||||
$attachLists = [];
|
||||
$detachLists = [];
|
||||
|
||||
foreach ($subscriber->lists as $list) {
|
||||
if (!in_array($list->id, $publicListIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!in_array($list->id, $selectedListIds)) {
|
||||
$detachLists[] = $list->id;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($selectedListIds as $selectedListId) {
|
||||
if (in_array($selectedListId, $publicListIds)) {
|
||||
$attachLists[] = $selectedListId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($attachLists) {
|
||||
$subscriber->attachLists($attachLists);
|
||||
}
|
||||
|
||||
if ($detachLists) {
|
||||
$subscriber->detachLists($detachLists);
|
||||
}
|
||||
|
||||
} else {
|
||||
$listIds = $subscriber->lists()->get()->pluck('id')->toArray();
|
||||
$subscriber->detachLists($listIds);
|
||||
}
|
||||
|
||||
do_action('fluent_crm/pref_form_self_contact_updated', $subscriber, $_REQUEST);
|
||||
|
||||
if ($updateData) {
|
||||
do_action('fluentcrm_contact_updated', $subscriber, $updateData);
|
||||
do_action('fluent_crm/contact_updated', $subscriber, $updateData);
|
||||
}
|
||||
|
||||
wp_send_json_success([
|
||||
'message' => __('Your information has been updated', 'fluent-crm'),
|
||||
'data' => $validData
|
||||
], 200);
|
||||
}
|
||||
|
||||
private function getFormFields($settings, $subscriber, $labels, $inputOnly = false)
|
||||
{
|
||||
$generalFields = Arr::get($settings, 'pref_general');
|
||||
$customFields = Arr::get($settings, 'pref_custom');
|
||||
|
||||
$formFields = [];
|
||||
|
||||
if (array_intersect($generalFields, ['first_name', 'last_name'])) {
|
||||
|
||||
$nameFields = [];
|
||||
|
||||
if (in_array('prefix', $generalFields)) {
|
||||
$nameFields['prefix'] = [
|
||||
'type' => 'select',
|
||||
'name' => 'prefix',
|
||||
'container_class' => 'fc_name_prefix',
|
||||
'id' => 'fc_name_prefix',
|
||||
'label' => Arr::get($labels, 'prefix', 'Prefix'),
|
||||
'placeholder' => '--',
|
||||
'options' => Helper::getContactPrefixes(true),
|
||||
'value' => $subscriber->prefix
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array('first_name', $generalFields)) {
|
||||
$nameFields['first_name'] = [
|
||||
'type' => 'input',
|
||||
'name' => 'first_name',
|
||||
'id' => 'fc_first_name',
|
||||
'atts' => [
|
||||
'type' => 'text',
|
||||
'placeholder' => __('First Name', 'fluent-crm')
|
||||
],
|
||||
'required' => true,
|
||||
'label' => Arr::get($labels, 'first_name', 'First Name'),
|
||||
'value' => $subscriber->first_name
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array('last_name', $generalFields)) {
|
||||
$nameFields['last_name'] = [
|
||||
'type' => 'input',
|
||||
'name' => 'last_name',
|
||||
'id' => 'fc_last_name',
|
||||
'atts' => [
|
||||
'type' => 'text',
|
||||
'placeholder' => __('Last Name', 'fluent-crm')
|
||||
],
|
||||
'required' => true,
|
||||
'label' => Arr::get($labels, 'last_name', 'Last Name'),
|
||||
'value' => $subscriber->last_name
|
||||
];
|
||||
}
|
||||
|
||||
$formFields['name'] = [
|
||||
'type' => 'container',
|
||||
'container_class' => 'fc_names fc_' . count($nameFields) . '_col',
|
||||
'fields' => $nameFields
|
||||
];
|
||||
}
|
||||
|
||||
$formFields[] = [
|
||||
'type' => 'raw_html',
|
||||
'html' => '<div class="fc_2_col fc_email_phone_date">'
|
||||
];
|
||||
|
||||
$formFields['email'] = [
|
||||
'type' => 'input',
|
||||
'name' => 'email',
|
||||
'id' => 'fc_email',
|
||||
'atts' => [
|
||||
'type' => 'email',
|
||||
'placeholder' => __('Email', 'fluent-crm'),
|
||||
'disabled' => true
|
||||
],
|
||||
'required' => true,
|
||||
'label' => Arr::get($labels, 'email', 'Email'),
|
||||
'value' => $subscriber->email
|
||||
];
|
||||
|
||||
if (in_array('phone', $generalFields)) {
|
||||
$formFields['phone'] = [
|
||||
'type' => 'input',
|
||||
'name' => 'phone',
|
||||
'id' => 'fc_phone',
|
||||
'atts' => [
|
||||
'type' => 'tel',
|
||||
'placeholder' => __('Phone', 'fluent-crm')
|
||||
],
|
||||
'required' => false,
|
||||
'label' => Arr::get($labels, 'phone', 'Phone/Mobile'),
|
||||
'value' => $subscriber->phone
|
||||
];
|
||||
}
|
||||
|
||||
$formFields[] = [
|
||||
'type' => 'raw_html',
|
||||
'html' => '</div>'
|
||||
];
|
||||
|
||||
if (in_array('date_of_birth', $generalFields)) {
|
||||
$formFields['date_of_birth'] = [
|
||||
'type' => 'date_dropdowns',
|
||||
'name' => 'date_of_birth',
|
||||
'id' => 'fc_date_of_birth',
|
||||
'required' => false,
|
||||
'label' => Arr::get($labels, 'dob', 'Date of Birth'),
|
||||
'value' => $subscriber->date_of_birth
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array('address_fields', $generalFields)) {
|
||||
$formFields[] = [
|
||||
'type' => 'raw_html',
|
||||
'html' => '<h4 class="fc_address_info_heading">' . Arr::get($labels, 'address_heading', 'Address Information') . '</h4>'
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter to modify the list of country names for the Preference Form Field in FluentCRM.
|
||||
*
|
||||
* This filter allows you to modify the list of country names used in FluentCRM.
|
||||
*
|
||||
* @since 2.7.0
|
||||
*
|
||||
* @param array An array of country names.
|
||||
*/
|
||||
$countryNames = apply_filters('fluent_crm/countries', []);
|
||||
|
||||
$formattedCountries = [];
|
||||
foreach ($countryNames as $country) {
|
||||
$formattedCountries[$country['code']] = $country['title'];
|
||||
}
|
||||
|
||||
$formFields['address'] = [
|
||||
'type' => 'container',
|
||||
'container_class' => 'fc_addresses fc_2_col',
|
||||
'fields' => [
|
||||
'address_line_1' => [
|
||||
'type' => 'input',
|
||||
'name' => 'address_line_1',
|
||||
'id' => 'fc_address_line_1',
|
||||
'atts' => [
|
||||
'type' => 'text',
|
||||
'placeholder' => __('Address Line 1', 'fluent-crm')
|
||||
],
|
||||
'label' => Arr::get($labels, 'address_line_1', 'Address Line 1'),
|
||||
'value' => $subscriber->address_line_1
|
||||
],
|
||||
'address_line_2' => [
|
||||
'type' => 'input',
|
||||
'name' => 'address_line_2',
|
||||
'id' => 'fc_address_line_2',
|
||||
'atts' => [
|
||||
'type' => 'text',
|
||||
'placeholder' => __('Address Line 2', 'fluent-crm')
|
||||
],
|
||||
'label' => Arr::get($labels, 'address_line_2', 'Address Line 2'),
|
||||
'value' => $subscriber->address_line_2
|
||||
],
|
||||
'city' => [
|
||||
'type' => 'input',
|
||||
'name' => 'city',
|
||||
'id' => 'fc_address_city',
|
||||
'atts' => [
|
||||
'type' => 'text',
|
||||
'placeholder' => __('City', 'fluent-crm')
|
||||
],
|
||||
'label' => Arr::get($labels, 'city', 'City'),
|
||||
'value' => $subscriber->city
|
||||
],
|
||||
'state' => [
|
||||
'type' => 'input',
|
||||
'name' => 'state',
|
||||
'id' => 'fc_address_state',
|
||||
'atts' => [
|
||||
'type' => 'text',
|
||||
'placeholder' => __('State', 'fluent-crm')
|
||||
],
|
||||
'label' => Arr::get($labels, 'state', 'State'),
|
||||
'value' => $subscriber->state
|
||||
],
|
||||
'postal_code' => [
|
||||
'type' => 'input',
|
||||
'name' => 'postal_code',
|
||||
'id' => 'fc_address_postal_code',
|
||||
'atts' => [
|
||||
'type' => 'text',
|
||||
'placeholder' => __('Zip Code', 'fluent-crm')
|
||||
],
|
||||
'label' => Arr::get($labels, 'postal_code', 'Zip Code'),
|
||||
'value' => $subscriber->postal_code
|
||||
],
|
||||
'country' => [
|
||||
'type' => 'select',
|
||||
'name' => 'country',
|
||||
'id' => 'fc_address_country',
|
||||
'placeholder' => __('Select Country', 'fluent-crm'),
|
||||
'label' => Arr::get($labels, 'country', 'Country'),
|
||||
'value' => $subscriber->country,
|
||||
'options' => $formattedCountries
|
||||
],
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// Add custom fields section
|
||||
if (!empty($customFields)) {
|
||||
$allCustomFields = (new CustomFields)->getGlobalFields()['fields'];
|
||||
$enabledCustomFields = [];
|
||||
|
||||
// Filter custom fields based on pref_custom settings
|
||||
foreach ($allCustomFields as $field) {
|
||||
if (in_array($field['slug'], $customFields)) {
|
||||
$enabledCustomFields[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($enabledCustomFields)) {
|
||||
$formFields[] = [
|
||||
'type' => 'raw_html',
|
||||
'html' => '<p class="fc_custom_fields_heading"></p>'
|
||||
];
|
||||
|
||||
// Group fields by their group attribute
|
||||
$groupedFields = [];
|
||||
$ungroupedFields = [];
|
||||
|
||||
foreach ($enabledCustomFields as $field) {
|
||||
if (!empty($field['group'])) {
|
||||
$group = $field['group'];
|
||||
if (!isset($groupedFields[$group])) {
|
||||
$groupedFields[$group] = [];
|
||||
}
|
||||
$groupedFields[$group][] = $field;
|
||||
} else {
|
||||
$ungroupedFields[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
// Add ungrouped fields first
|
||||
if (!empty($ungroupedFields)) {
|
||||
$ungroupedContainer = [
|
||||
'type' => 'container',
|
||||
'container_class' => 'fc_custom_fields fc_2_col',
|
||||
'fields' => []
|
||||
];
|
||||
|
||||
foreach ($ungroupedFields as $field) {
|
||||
$fieldType = $field['type'];
|
||||
$fieldKey = $field['slug'];
|
||||
$fieldConfig = $this->getCustomFieldConfig($field, $subscriber);
|
||||
$ungroupedContainer['fields'][$fieldKey] = $fieldConfig;
|
||||
}
|
||||
|
||||
$formFields['custom_fields_ungrouped'] = $ungroupedContainer;
|
||||
}
|
||||
|
||||
// Create containers for each group
|
||||
foreach ($groupedFields as $groupName => $fields) {
|
||||
$customFieldsContainer = [
|
||||
'type' => 'container',
|
||||
'container_class' => 'fc_custom_fields fc_2_col fc_custom_field_group_box',
|
||||
'fields' => []
|
||||
];
|
||||
|
||||
$customFieldsContainer['fields']['group_heading'] = [
|
||||
'type' => 'raw_html',
|
||||
'html' => '<h5 class="fc_custom_field_group_heading">' . esc_html($groupName) . '</h5>'
|
||||
];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$fieldType = $field['type'];
|
||||
$fieldKey = $field['slug'];
|
||||
$fieldConfig = $this->getCustomFieldConfig($field, $subscriber);
|
||||
$customFieldsContainer['fields'][$fieldKey] = $fieldConfig;
|
||||
}
|
||||
|
||||
$formFields['custom_fields_' . sanitize_title($groupName)] = $customFieldsContainer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!$inputOnly) {
|
||||
return $formFields;
|
||||
}
|
||||
|
||||
return $this->parseInputs($formFields);
|
||||
}
|
||||
|
||||
private function parseInputs($fields)
|
||||
{
|
||||
$inputFields = [];
|
||||
|
||||
$inputTypes = ['hidden', 'input', 'checkboxes', 'select', 'radio', 'date', 'date_dropdowns', 'textarea', 'select-multi', 'custom_date', 'custom_date_time'];
|
||||
|
||||
foreach ($fields as $inputKey => $field) {
|
||||
$type = Arr::get($field, 'type');
|
||||
if ($type == 'container') {
|
||||
$inputFields = array_merge($this->parseInputs($field['fields']), $inputFields);
|
||||
} else if (in_array($type, $inputTypes)) {
|
||||
$inputFields[$inputKey] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $inputFields;
|
||||
}
|
||||
|
||||
private function getCustomFieldConfig($field, $subscriber)
|
||||
{
|
||||
$fieldType = $field['type'];
|
||||
$fieldKey = $field['slug'];
|
||||
|
||||
$fieldConfig = [
|
||||
'type' => 'input',
|
||||
'name' => $fieldKey,
|
||||
'id' => 'fc_' . $fieldKey,
|
||||
'label' => $field['label'],
|
||||
'required' => !empty($field['required']),
|
||||
'value' => $subscriber->getMeta($field['slug'], 'custom_field')
|
||||
];
|
||||
|
||||
// Add field-specific configurations
|
||||
switch ($fieldType) {
|
||||
case 'text':
|
||||
$fieldConfig['type'] = 'input';
|
||||
$fieldConfig['atts'] = [
|
||||
'type' => 'text',
|
||||
'placeholder' => $field['label'],
|
||||
'class' => 'fc_input_control'
|
||||
];
|
||||
break;
|
||||
|
||||
case 'textarea':
|
||||
$fieldConfig['type'] = 'textarea';
|
||||
$fieldConfig['atts'] = [
|
||||
'placeholder' => $field['label'],
|
||||
'class' => 'fc_input_control',
|
||||
'name' => $fieldKey
|
||||
];
|
||||
break;
|
||||
|
||||
case 'number':
|
||||
$fieldConfig['type'] = 'input';
|
||||
$fieldConfig['atts'] = [
|
||||
'type' => 'number',
|
||||
'placeholder' => $field['label'],
|
||||
'class' => 'fc_input_control'
|
||||
];
|
||||
break;
|
||||
|
||||
case 'select-one':
|
||||
$fieldConfig['type'] = 'select';
|
||||
$fieldConfig['options'] = array_combine($field['options'], $field['options']);
|
||||
$fieldConfig['placeholder'] = $field['label'];
|
||||
$fieldConfig['atts'] = [
|
||||
'class' => 'fc_input_control select-one'
|
||||
];
|
||||
break;
|
||||
|
||||
case 'select-multi':
|
||||
$fieldConfig['type'] = 'select-multi';
|
||||
$fieldConfig['options'] = array_combine($field['options'], $field['options']);
|
||||
$fieldConfig['value'] = is_array($fieldConfig['value']) ? $fieldConfig['value'] : [];
|
||||
$fieldConfig['name'] = $fieldKey . '[]';
|
||||
$fieldConfig['atts'] = [
|
||||
'class' => 'fc_input_control select-multi',
|
||||
'multiple' => 'multiple'
|
||||
];
|
||||
break;
|
||||
|
||||
case 'radio':
|
||||
$fieldConfig['type'] = 'radio';
|
||||
$fieldConfig['options'] = array_combine($field['options'], $field['options']);
|
||||
$fieldConfig['atts'] = [
|
||||
'class' => 'fc_input_control'
|
||||
];
|
||||
break;
|
||||
|
||||
case 'checkbox':
|
||||
$fieldConfig['type'] = 'checkboxes';
|
||||
$fieldConfig['options'] = is_array($field['options']) ? $field['options'] : [];
|
||||
$fieldConfig['value'] = is_array($fieldConfig['value']) ? $fieldConfig['value'] : [];
|
||||
$fieldConfig['atts'] = [
|
||||
'class' => 'fc_input_control'
|
||||
];
|
||||
break;
|
||||
|
||||
case 'date':
|
||||
$fieldConfig['type'] = 'custom_date';
|
||||
$fieldConfig['atts'] = [
|
||||
'type' => 'date',
|
||||
'class' => 'fc_date_item fc_input_control',
|
||||
'data-format' => 'YYYY-MM-DD',
|
||||
'placeholder' => $field['label'],
|
||||
'data-template' => 'DD - MM - YYYY'
|
||||
];
|
||||
break;
|
||||
|
||||
case 'date_time':
|
||||
$fieldConfig['type'] = 'custom_date_time';
|
||||
$fieldConfig['atts'] = [
|
||||
'type' => 'text',
|
||||
'class' => 'fc_date_item fc_input_control',
|
||||
'data-format' => 'YYYY-MM-DD HH:mm:ss',
|
||||
'placeholder' => $field['label'],
|
||||
'data-template' => 'DD - MM - YYYY HH:mm'
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
return $fieldConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid calendar date in Y-m-d format.
|
||||
*
|
||||
* @param string $ymd Date string (e.g. 2024-02-31).
|
||||
* @return bool
|
||||
*/
|
||||
private function isValidDate($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]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
|
||||
/**
|
||||
* PurchaseHistory Class
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class PurchaseHistory
|
||||
{
|
||||
/**
|
||||
* Build the commerce summary widget for supported commerce providers.
|
||||
*/
|
||||
public function getCommerceStatWidget($subscriber)
|
||||
{
|
||||
/**
|
||||
* Determine the commerce provider for the purchase history in FluentCRM.
|
||||
*
|
||||
* This filter allows you to modify the commerce provider used in FluentCRM.
|
||||
*
|
||||
* @since 2.8.0
|
||||
*
|
||||
* @param string $commerceProvider The current commerce provider.
|
||||
* @return string The modified commerce provider.
|
||||
*/
|
||||
$commerceProvider = apply_filters('fluentcrm_commerce_provider', '');
|
||||
|
||||
if ($commerceProvider) {
|
||||
/**
|
||||
* Determine the purchase statistics for a specific commerce provider for a specific subscriber in FluentCRM.
|
||||
*
|
||||
* The dynamic portion of the hook name, `$commerceProvider`, refers to the specific commerce provider.
|
||||
*
|
||||
* @since 2.8.0
|
||||
*
|
||||
* @param array $stats An array of purchase statistics.
|
||||
* @param int $subscriber->id The ID of the subscriber.
|
||||
*/
|
||||
$stats = apply_filters('fluent_crm/contact_purchase_stat_' . $commerceProvider, [], $subscriber->id);
|
||||
if (!$stats) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$html = '<ul class="fc_full_listed fcrm_customer_summary_list">';
|
||||
foreach ($stats as $stat) {
|
||||
$html .= '<li><span class="fc_list_sub">' . $stat['title'] . '</span> <span class="fc_list_value">' . $stat['value'] . '</span></li>';
|
||||
}
|
||||
$html .= '</ul>';
|
||||
|
||||
return [
|
||||
'title' => __('Customer Summary', 'fluent-crm'),
|
||||
'content' => $html
|
||||
];
|
||||
}
|
||||
|
||||
if (defined('WC_PLUGIN_FILE')) {
|
||||
$summary = $this->getWooCustomerSummary($subscriber);
|
||||
if ($summary) {
|
||||
return [
|
||||
'title' => __('Customer Summary', 'fluent-crm'),
|
||||
'content' => $summary
|
||||
];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Helper::isEdd3()) {
|
||||
|
||||
$customer = fluentCrmDb()->table('edd_customers')
|
||||
->where('email', $subscriber->email);
|
||||
if ($subscriber->user_id) {
|
||||
$customer = $customer->orWhere('user_id', $subscriber->user_id);
|
||||
}
|
||||
$customer = $customer->first();
|
||||
if (!$customer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$summaryData = [
|
||||
'order_count' => $customer->purchase_count,
|
||||
'lifetime_value' => number_format($customer->purchase_value, 2),
|
||||
'avg_value' => ($customer->purchase_count) ? round($customer->purchase_value / $customer->purchase_count, 2) : 'n/a',
|
||||
'stat_avg_count' => 0,
|
||||
'stat_avg_spend' => 0,
|
||||
'stat_avg_value' => 0,
|
||||
'currency_sign' => edd_currency_symbol(),
|
||||
'first_order_date' => $customer->date_created
|
||||
];
|
||||
|
||||
$html = $this->formatSummaryData($summaryData, true);
|
||||
|
||||
return [
|
||||
'title' => __('Customer Summary', 'fluent-crm'),
|
||||
'content' => $html
|
||||
];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function wooOrders($data, $subscriber)
|
||||
{
|
||||
if (!defined('WC_PLUGIN_FILE')) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$hasRecount = defined('FLUENTCAMPAIGN') && \FluentCampaign\App\Services\Commerce\Commerce::isEnabled('woo');
|
||||
|
||||
$app = fluentCrm();
|
||||
|
||||
if ($hasRecount && $app->request->get('will_recount') == 'yes') {
|
||||
(new \FluentCampaign\App\Services\Integrations\WooCommerce\DeepIntegration)->syncCustomerBySubscriber($subscriber);
|
||||
}
|
||||
|
||||
$page = (int)$app->request->get('page', 1);
|
||||
$per_page = (int)$app->request->get('per_page', 10);
|
||||
|
||||
$sort_by = sanitize_sql_orderby($app->request->get('sort_by', 'id'));
|
||||
$sort_type = sanitize_sql_orderby($app->request->get('sort_type', 'DESC'));
|
||||
|
||||
$valid_columns = ['id', 'date_created', 'total_amount'];
|
||||
$valid_directions = ['ASC', 'DESC'];
|
||||
|
||||
if (!in_array($sort_by, $valid_columns)) {
|
||||
$sort_by = 'id';
|
||||
}
|
||||
if (!in_array(strtoupper($sort_type), $valid_directions)) {
|
||||
$sort_type = 'DESC';
|
||||
}
|
||||
|
||||
$orders = $this->getWooOrders($subscriber, $sort_by, $sort_type);
|
||||
$totalOrders = count($orders);
|
||||
$orders = array_slice($orders, ($page - 1) * $per_page, $per_page);
|
||||
|
||||
$formattedOrders = [];
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$item_count = $order->get_item_count() - $order->get_item_count_refunded();
|
||||
$actionsHtml = '<a target="_blank" href="' . $order->get_edit_order_url() . '">
|
||||
<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>';
|
||||
$date = '<span class="order_id">'.'#' . $order->get_order_number().'</span><span class="order_date">'.wc_format_datetime($order->get_date_created()).'</span>';
|
||||
|
||||
$status = '<span class="fcrm_badge fcrm_badge_'.esc_attr($order->get_status()).'">'. Helper::getStatusText($order->get_status()) .'</span>';
|
||||
|
||||
$formattedOrders[] = [
|
||||
'date' => wp_kses_post($date),
|
||||
'status' => wp_kses_post($status),
|
||||
/* translators: 1: formatted order total (with currency), 2: number of items */
|
||||
'total' => wp_kses_post(sprintf(_n('%1$s for %2$s item', '%1$s for %2$s items', $item_count, 'fluent-crm'), $order->get_formatted_order_total(), $item_count)),
|
||||
'action' => $actionsHtml,
|
||||
];
|
||||
}
|
||||
/**
|
||||
* Determine the WooCommerce purchase history sidebar HTML in FluentCRM.
|
||||
*
|
||||
* This filter allows customization of the HTML content displayed in the WooCommerce purchase sidebar.
|
||||
*
|
||||
* @since 2.7.0
|
||||
*
|
||||
* @param string The current HTML content of the sidebar.
|
||||
* @param object $subscriber The subscriber object containing subscriber data.
|
||||
* @param int $page The current page identifier as an integer.
|
||||
*/
|
||||
$sidebarHtml = apply_filters('fluent_crm/woo_purchase_sidebar_html', '', $subscriber, $page);
|
||||
|
||||
return [
|
||||
'data' => $formattedOrders,
|
||||
'sidebar_html' => $sidebarHtml,
|
||||
'total' => $totalOrders,
|
||||
'has_recount' => $hasRecount,
|
||||
'columns_config' => [
|
||||
'date' => [
|
||||
'label' => __('Date', 'fluent-crm'),
|
||||
'sortable' => true,
|
||||
'key' => 'date_created_gmt'
|
||||
],
|
||||
'status' => [
|
||||
'label' => __('Status', 'fluent-crm'),
|
||||
],
|
||||
'total' => [
|
||||
'label' => __('Total', 'fluent-crm'),
|
||||
'width' => '160px',
|
||||
'sortable' => true,
|
||||
'key' => 'total_amount'
|
||||
],
|
||||
'actions' => [
|
||||
'label' => __('', 'fluent-crm'),
|
||||
'width' => '50px'
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getWooCustomerSummary($subscriber)
|
||||
{
|
||||
$customerQuery = fluentCrmDb()->table('wc_customer_lookup')
|
||||
->where('email', $subscriber->email);
|
||||
|
||||
if ($subscriber->user_id) {
|
||||
$customerQuery = $customerQuery->orWhere('user_id', $subscriber->user_id);
|
||||
}
|
||||
|
||||
$customer = $customerQuery->first();
|
||||
|
||||
if ($customer) {
|
||||
$statuses = wc_get_is_paid_statuses();
|
||||
$statuses = array_map(function ($status) {
|
||||
return 'wc-' . $status;
|
||||
}, $statuses);
|
||||
|
||||
$orderStats = fluentCrmDb()->table('wc_order_stats')
|
||||
->where('customer_id', $customer->customer_id)
|
||||
->whereIn('status', $statuses)
|
||||
->get();
|
||||
|
||||
if ($orderStats->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lifetimeValue = 0;
|
||||
$orderIds = [];
|
||||
|
||||
$firstOrderDate = null;
|
||||
$lastOrderDate = null;
|
||||
|
||||
foreach ($orderStats as $order) {
|
||||
if (!$firstOrderDate) {
|
||||
$firstOrderDate = $order->date_created;
|
||||
}
|
||||
|
||||
if (!$lastOrderDate) {
|
||||
$lastOrderDate = $order->date_created;
|
||||
}
|
||||
|
||||
if (strtotime($order->date_created) < strtotime($firstOrderDate)) {
|
||||
$firstOrderDate = $order->date_created;
|
||||
}
|
||||
|
||||
if (strtotime($order->date_created) > strtotime($lastOrderDate)) {
|
||||
$lastOrderDate = $order->date_created;
|
||||
}
|
||||
|
||||
$lifetimeValue += $order->total_sales;
|
||||
$orderIds[] = $order->order_id;
|
||||
}
|
||||
|
||||
$orderIds = array_unique($orderIds);
|
||||
|
||||
$orderCount = count($orderIds);
|
||||
|
||||
$data_store = \WC_Data_Store::load('report-customers-stats');
|
||||
$stat = $data_store->get_data();
|
||||
|
||||
$avg_value = $orderCount > 0 ? round($lifetimeValue / $orderCount, 2) : 0;
|
||||
|
||||
$summaryData = [
|
||||
'order_count' => $orderCount,
|
||||
'lifetime_value' => $lifetimeValue,
|
||||
'avg_value' => $avg_value,
|
||||
'stat_avg_count' => $stat->avg_orders_count,
|
||||
'stat_avg_spend' => $stat->avg_total_spend,
|
||||
'stat_avg_value' => $stat->avg_avg_order_value,
|
||||
'currency_sign' => get_woocommerce_currency_symbol(),
|
||||
'last_order_date' => $lastOrderDate,
|
||||
'first_order_date' => $firstOrderDate,
|
||||
];
|
||||
|
||||
return $this->formatSummaryData($summaryData, true);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return EDD 3 order history for the subscriber purchase-history panel.
|
||||
*/
|
||||
public function eddOrders($data, $subscriber)
|
||||
{
|
||||
if (!Helper::isEdd3()) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$app = fluentCrm();
|
||||
$page = (int)$app->request->get('page', 1);
|
||||
set_query_var('paged', $page);
|
||||
|
||||
$hasRecount = defined('FLUENTCAMPAIGN') && \FluentCampaign\App\Services\Commerce\Commerce::isEnabled('edd');
|
||||
|
||||
if ($hasRecount && $app->request->get('will_recount') == 'yes') {
|
||||
(new \FluentCampaign\App\Services\Integrations\Edd\DeepIntegration)->syncCustomerBySubscriber($subscriber);
|
||||
}
|
||||
|
||||
$sort_by = sanitize_sql_orderby($app->request->get('sort_by', 'id'));
|
||||
$sort_type = sanitize_sql_orderby($app->request->get('sort_type', 'DESC'));
|
||||
$per_page = (int)$app->request->get('per_page', 10);
|
||||
$customer = new \EDD_Customer($subscriber->email);
|
||||
|
||||
if (!$customer || !$customer->id) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$lasOrderData = '';
|
||||
|
||||
/*
|
||||
* EDD 3 stores orders in the edd_orders table. Legacy edd_payment posts
|
||||
* are intentionally not queried because EDD 2 is no longer supported.
|
||||
*/
|
||||
$totalCount = fluentCrmDb()->table('edd_orders')
|
||||
->where('customer_id', $customer->id)
|
||||
->count();
|
||||
|
||||
if (!$totalCount) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$valid_columns = ['id', 'date_created', 'total'];
|
||||
$valid_directions = ['ASC', 'DESC'];
|
||||
|
||||
if (!in_array($sort_by, $valid_columns)) {
|
||||
$sort_by = 'id';
|
||||
}
|
||||
if (!in_array(strtoupper($sort_type), $valid_directions)) {
|
||||
$sort_type = 'DESC';
|
||||
}
|
||||
|
||||
$orders = fluentCrmDb()->table('edd_orders')
|
||||
->where('customer_id', $customer->id)
|
||||
->orderBy($sort_by, $sort_type)
|
||||
->limit($per_page)
|
||||
->offset(($page - 1) * $per_page)
|
||||
->get();
|
||||
|
||||
$formattedOrders = [];
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$orderActionHtml = '<a target="_blank" href="' . add_query_arg('id', $order->id, admin_url('edit.php?post_type=download&page=edd-payment-history&view=view-order-details')) . '">
|
||||
<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>';
|
||||
$date = '<span class="order_id">'.'#' . $order->id .'</span><span class="order_date">'.date_i18n(get_option('date_format'), strtotime($order->date_created)).'</span>';
|
||||
|
||||
$status = '<span class="fcrm_badge fcrm_badge_'.esc_attr($order->status).'">'. Helper::getStatusText($order->status) .'</span>';
|
||||
|
||||
$formattedOrders[] = [
|
||||
'date' => $date,
|
||||
'status' => $status,
|
||||
'total' => edd_currency_filter(edd_format_amount($order->total)),
|
||||
'action' => $orderActionHtml
|
||||
];
|
||||
}
|
||||
|
||||
if (!$orders->isEmpty()) {
|
||||
$lasOrderData = date_i18n(get_option('date_format'), strtotime($orders[0]->date_created));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the HTML content displayed in the EDD purchase history sidebar for a subscriber in FluentCRM.
|
||||
*
|
||||
* This filter allows customization of the HTML content that appears in the EDD purchase
|
||||
* history sidebar for a given subscriber on a specific page.
|
||||
*
|
||||
* @since 2.7.0
|
||||
*
|
||||
* @param string $beforeHtml The HTML content to be displayed before the purchase history.
|
||||
* @param object $subscriber The subscriber object.
|
||||
* @param int $page The current page identifier as an integer.
|
||||
*/
|
||||
$beforeHtml = apply_filters('fluent_crm/edd_purchase_sidebar_html', '', $subscriber, $page);
|
||||
|
||||
// if (!$beforeHtml && $subscriber->user_id && $page == 1 && $formattedOrders) {
|
||||
// $summaryData = [
|
||||
// 'order_count' => $customer->purchase_count,
|
||||
// 'lifetime_value' => $customer->purchase_value,
|
||||
// 'avg_value' => ($customer->purchase_count) ? round($customer->purchase_value / $customer->purchase_count, 2) : 'n/a',
|
||||
// 'stat_avg_count' => 0,
|
||||
// 'stat_avg_spend' => 0,
|
||||
// 'stat_avg_value' => 0,
|
||||
// 'currency_sign' => edd_currency_symbol(),
|
||||
// 'last_order_date' => $lasOrderData
|
||||
// ];
|
||||
// $beforeHtml = $this->formatSummaryData($summaryData);
|
||||
// }
|
||||
|
||||
return [
|
||||
'data' => $formattedOrders,
|
||||
'total' => $totalCount,
|
||||
'sidebar_html' => $beforeHtml,
|
||||
'after_html' => '<p><a target="_blank" rel="noopener" href="'.admin_url('edit.php?post_type=download&page=edd-customers&view=overview&id='.$customer->id).'">' . esc_html__('View Customer Profile', 'fluent-crm') . '</a></p>',
|
||||
'has_recount' => $hasRecount,
|
||||
'columns_config' => [
|
||||
'order' => [
|
||||
'label' => __('Order', 'fluent-crm'),
|
||||
'width' => '100px',
|
||||
'sortable' => true,
|
||||
'key' => 'id'
|
||||
],
|
||||
'date' => [
|
||||
'label' => __('Date', 'fluent-crm'),
|
||||
'sortable' => true,
|
||||
'key' => 'edd_orders'
|
||||
],
|
||||
'status' => [
|
||||
'label' => __('Status', 'fluent-crm'),
|
||||
'width' => '140px',
|
||||
'sortable' => false
|
||||
],
|
||||
'total' => [
|
||||
'label' => __('Total', 'fluent-crm'),
|
||||
'width' => '120px',
|
||||
'sortable' => true,
|
||||
'key' => 'total'
|
||||
],
|
||||
'action' => [
|
||||
'label' => __('Actions', 'fluent-crm'),
|
||||
'width' => '100px',
|
||||
'sortable' => false
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function payformSubmissions($data, $subscriber)
|
||||
{
|
||||
if (!defined('WPPAYFORM_VERSION')) {
|
||||
return $data;
|
||||
}
|
||||
$app = fluentCrm();
|
||||
$page = intval($app->request->get('page', 1));
|
||||
$per_page = intval($app->request->get('per_page', 10));
|
||||
$query = fluentCrmDb()->table('wpf_submissions')
|
||||
->select([
|
||||
'wpf_submissions.id',
|
||||
'wpf_submissions.form_id',
|
||||
'wpf_submissions.currency',
|
||||
'wpf_submissions.payment_status',
|
||||
'wpf_submissions.payment_total',
|
||||
'wpf_submissions.payment_method',
|
||||
'wpf_submissions.created_at',
|
||||
'posts.post_title',
|
||||
'wpf_subscriptions.recurring_amount',
|
||||
])
|
||||
->join('posts', 'posts.ID', '=', 'wpf_submissions.form_id')
|
||||
->leftJoin('wpf_subscriptions', 'wpf_subscriptions.submission_id', '=', 'wpf_submissions.id')
|
||||
->where(function ($query) use ($subscriber) {
|
||||
$query->where('wpf_submissions.customer_email', '=', $subscriber->email);
|
||||
if ($subscriber->user_id) {
|
||||
$query->orWhere('wpf_submissions.user_id', '=', $subscriber->user_id);
|
||||
}
|
||||
})
|
||||
// ->where('wpf_submissions.payment_total', '>', 0)
|
||||
->limit($per_page)
|
||||
->offset($per_page * ($page - 1))
|
||||
->orderBy('wpf_submissions.id', 'desc');
|
||||
|
||||
$total = $query->count();
|
||||
$submissions = $query->get();
|
||||
$formattedSubmissions = [];
|
||||
foreach ($submissions as $submission) {
|
||||
$submissionUrl = admin_url('admin.php?page=wppayform.php#/edit-form/' . $submission->form_id . '/entries/' . $submission->id . '/view');
|
||||
$actionUrl = '<a target="_blank" href="' . $submissionUrl . '">
|
||||
<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>';
|
||||
$paymentStatus = '<span class="fcrm_badge fcrm_badge_'.esc_attr($submission->payment_status).'">'. \FluentCrm\App\Services\Helper::getStatusText($submission->payment_status) .'</span>';
|
||||
$formattedSubmissions[] = [
|
||||
'id' => '#' . $submission->id,
|
||||
'post_title' => $submission->post_title,
|
||||
'recurring_amount' => $submission->recurring_amount ? wpPayFormFormatMoney($submission->recurring_amount, $subscriber->form_id) : wpPayFormFormatMoney($submission->payment_total, $subscriber->form_id),
|
||||
'payment_status' => $paymentStatus,
|
||||
'payment_method' => $submission->payment_method,
|
||||
'created_at' => $submission->created_at,
|
||||
'action' => $actionUrl
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'data' => $formattedSubmissions,
|
||||
'columns_config' => [
|
||||
'id' => [ 'label' => __('ID', 'fluent-crm'), 'width' => '100px', 'sortable' => false, 'key' => 'id'],
|
||||
'post_title' => [ 'label' => __('Form Title', 'fluent-crm'), 'sortable' => false, 'key' => 'post_title'],
|
||||
'recurring_amount' => [ 'label' => __('Payment Total', 'fluent-crm'), 'sortable' => false, 'key' => 'recurring_amount'],
|
||||
'payment_status' => [ 'label' => __('Payment Status', 'fluent-crm'), 'sortable' => false, 'key' => 'payment_status'],
|
||||
'payment_method' => [ 'label' => __('Payment Method', 'fluent-crm'), 'sortable' => false, 'key' => 'payment_method'],
|
||||
'created_at' => [ 'label' => __('Submitted At', 'fluent-crm'), 'sortable' => false, 'key' => 'created_at']
|
||||
]
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
public function formatSummaryData($data, $bodyOnly = false)
|
||||
{
|
||||
$blocks = [];
|
||||
if (!empty($data['first_order_date'])) {
|
||||
$blocks['Customer Since'] = gmdate(get_option('date_format'), strtotime($data['first_order_date']));
|
||||
}
|
||||
|
||||
if (!empty($data['last_order_date'])) {
|
||||
$blocks['Last Order'] = gmdate(get_option('date_format'), strtotime($data['last_order_date']));
|
||||
}
|
||||
|
||||
$blocks['Order Count (paid)'] = $data['order_count'] . $this->getPercentChangeHtml($data['order_count'], $data['stat_avg_count']);
|
||||
$blocks['Lifetime Value'] = $data['currency_sign'] . $data['lifetime_value'];
|
||||
$blocks['AOV'] = $data['currency_sign'] . $data['avg_value'] . $this->getPercentChangeHtml($data['avg_value'], $data['stat_avg_value']);
|
||||
|
||||
|
||||
$html = '<div class="fc_payment_summary"><h3 class="history_title">' . esc_html__("Customer Summary", "fluent-crm") . '</h3><div class="fc_history_widget">';
|
||||
|
||||
$body = '';
|
||||
|
||||
|
||||
$body .= '<ul class="fc_full_listed">';
|
||||
foreach ($blocks as $title => $block) {
|
||||
$body .= '<li><span class="fc_list_sub">' . $title . '</span><span class="fc_list_value">' . $block . '</span></li>';
|
||||
}
|
||||
|
||||
if (!empty($data['purchased_products'])) {
|
||||
$body .= '<li><b>' . esc_html__("Purchased Products", "fluent-crm") . '</b><hr /><ul class="fc_list">';
|
||||
foreach ($data['purchased_products'] as $product) {
|
||||
$body .= '<li><a target="_blank" rel="nofollow" href="' . esc_url($product->guid) . '">' . esc_html($product->post_title) . '</a></li>';
|
||||
}
|
||||
$body .= '</ul></li>';
|
||||
}
|
||||
|
||||
$body .= '</ul>';
|
||||
|
||||
if ($bodyOnly) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
return $body . '</div></div>';
|
||||
}
|
||||
|
||||
private function getPercentChangeHtml($value, $refValue)
|
||||
{
|
||||
if (!$refValue || !$value) {
|
||||
return '';
|
||||
}
|
||||
$change = $value - $refValue;
|
||||
$percentChange = absint(ceil($change / $refValue * 100));
|
||||
if ($change >= 0) {
|
||||
return '<span class="el-icon-caret-top fc_positive fc_change_ref">' . $percentChange . '%' . '</span>';
|
||||
} else {
|
||||
return '<span class="el-icon-caret-bottom fc_negative fc_change_ref">' . $percentChange . '%' . '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function getWooOrders($subscriber, $sort_by, $sort_type)
|
||||
{
|
||||
$email = $subscriber->email;
|
||||
|
||||
$user = get_user_by('email', $email);
|
||||
|
||||
// check HPOS is enabled or not
|
||||
if (get_option('woocommerce_custom_orders_table_enabled') === 'yes') {
|
||||
// high performance order is enabled
|
||||
if ($user) {
|
||||
$hposOrders = fluentCrmDb()->table('wc_orders')
|
||||
->where('status', '!=', 'trash')
|
||||
->select(['id'])
|
||||
->where(function ($query) use ($user) {
|
||||
$query->where('customer_id', $user->ID)
|
||||
->orWhere(function ($query) use ($user) {
|
||||
$query->where('billing_email', $user->user_email)
|
||||
->where('customer_id', 0);
|
||||
});
|
||||
})
|
||||
->orderBy($sort_by, $sort_type)
|
||||
->get();
|
||||
} else {
|
||||
$hposOrders = fluentCrmDb()->table('wc_orders')
|
||||
->select(['id'])
|
||||
->where('billing_email', $email)
|
||||
->where('customer_id', 0)
|
||||
->orderBy($sort_by, $sort_type)
|
||||
->get();
|
||||
}
|
||||
|
||||
if ($hposOrders->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$orders = [];
|
||||
foreach ($hposOrders as $hposOrder) {
|
||||
$order = wc_get_order($hposOrder->id);
|
||||
if ($order) {
|
||||
$orders[$hposOrder->id] = $order;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($orders);
|
||||
}
|
||||
|
||||
|
||||
$orders = [];
|
||||
// Get all orders by user id
|
||||
$storeUseId = $user ? $user->ID : false;
|
||||
|
||||
if ($storeUseId) {
|
||||
$userOrders = wc_get_orders([
|
||||
'customer_id' => $storeUseId,
|
||||
'limit' => -1,
|
||||
'orderby' => $sort_by,
|
||||
'order' => $sort_type,
|
||||
]);
|
||||
|
||||
// Sort orders by total amount manually
|
||||
if ($sort_by === 'total_amount') {
|
||||
$this->wooSortOrdersByTotalAmount($userOrders, $sort_type);
|
||||
}
|
||||
|
||||
foreach ($userOrders as $order) {
|
||||
$orders[$order->get_id()] = $order;
|
||||
}
|
||||
}
|
||||
|
||||
// get orders by billing email
|
||||
$guestOrders = wc_get_orders([
|
||||
'customer' => $email,
|
||||
'limit' => -1,
|
||||
'orderby' => $sort_by,
|
||||
'order' => $sort_type,
|
||||
]);
|
||||
|
||||
if ($sort_by === 'total_amount') {
|
||||
$this->wooSortOrdersByTotalAmount($userOrders, $sort_type);
|
||||
}
|
||||
|
||||
foreach ($guestOrders as $order) {
|
||||
$userId = $order->get_user_id();
|
||||
if ($userId && $storeUseId != $userId) {
|
||||
continue;
|
||||
}
|
||||
$orders[$order->get_id()] = $order;
|
||||
}
|
||||
|
||||
|
||||
return array_values($orders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts an array of WooCommerce orders by their total amount.
|
||||
*
|
||||
* This method sorts an array of WooCommerce order objects based on the total order amount.
|
||||
* The sorting can be done in either ascending ('ASC') or descending ('DESC') order,
|
||||
* as specified by the $sort_type parameter.
|
||||
*
|
||||
* @param array $orders Array of WooCommerce order objects to be sorted.
|
||||
* @param string $sort_type Specifies the sorting order.
|
||||
* Accepts 'ASC' for ascending or 'DESC' for descending.
|
||||
*
|
||||
* @return void The $orders array is sorted in place.
|
||||
*/
|
||||
public function wooSortOrdersByTotalAmount(&$orders, $sort_type) {
|
||||
usort($orders, function ($a, $b) use ($sort_type) {
|
||||
$a_total = (float)$a->get_total();
|
||||
$b_total = (float)$b->get_total();
|
||||
|
||||
return $sort_type === 'ASC' ? $a_total <=> $b_total : $b_total <=> $a_total;
|
||||
});
|
||||
}
|
||||
|
||||
public function pmproOrders($data, $subscriber)
|
||||
{
|
||||
if (!defined('PMPRO_VERSION')) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
if (!defined('FLUENTCAMPAIGN')) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$customer = fluentCrmDb()->table('users')->where('user_email', $subscriber->email)->first();
|
||||
|
||||
if (!$customer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$subscriber->user_id || $subscriber->user_id != $customer->ID) {
|
||||
$subscriber->user_id = $customer->ID;
|
||||
$subscriber->save();
|
||||
}
|
||||
|
||||
$app = fluentCrm();
|
||||
|
||||
$page = (int)$app->request->get('page', 1);
|
||||
$per_page = (int)$app->request->get('per_page', 10);
|
||||
|
||||
$sort_by = sanitize_sql_orderby($app->request->get('sort_by', 'ID'));
|
||||
$sort_type = sanitize_sql_orderby($app->request->get('sort_type', 'DESC'));
|
||||
|
||||
$valid_columns = ['ID', 'date', 'modified'];
|
||||
$valid_directions = ['ASC', 'DESC'];
|
||||
|
||||
if (!in_array($sort_by, $valid_columns)) {
|
||||
$sort_by = 'ID';
|
||||
}
|
||||
if (!in_array(strtoupper($sort_type), $valid_directions)) {
|
||||
$sort_type = 'DESC';
|
||||
}
|
||||
|
||||
// Fetch the array of MemberOrder OBJECTS
|
||||
$user_order_objects = \MemberOrder::get_orders([
|
||||
'user_id' => $subscriber->user_id,
|
||||
'status' => 'success',
|
||||
'orderby' => $sort_by,
|
||||
'order' => $sort_type
|
||||
]);
|
||||
|
||||
// Create a new, simple array to hold the data for JSON conversion
|
||||
$formattedOrders = [];
|
||||
|
||||
$totalOrders = count($user_order_objects);
|
||||
$orders = array_slice($user_order_objects, ($page - 1) * $per_page, $per_page);
|
||||
|
||||
|
||||
if (!empty($orders)) {
|
||||
// Loop through each PHP object and extract its data into a simple array
|
||||
foreach ($orders as $order) {
|
||||
// Construct the URL using WordPress's admin_url() function
|
||||
$order_page_url = admin_url('admin.php?page=pmpro-orders&order=' . $order->id);
|
||||
$level = pmpro_getLevel($order->membership_id);
|
||||
$actionsHtml = '<td><a href="' . esc_url($order_page_url) . '" target="_blank">View Order</a></td>';
|
||||
$status = '<span class="fcrm_badge fcrm_badge_'.esc_attr($order->status).'">'. \FluentCrm\App\Services\Helper::getStatusText($order->status) .'</span>';
|
||||
$formattedOrders[] = [
|
||||
'order_code' => '#' . $order->code,
|
||||
'membership_level_name' => $level ? $level->name : null,
|
||||
'status' => $status,
|
||||
'total' => $order->total,
|
||||
'date' => gmdate('j F, Y', $order->timestamp),
|
||||
'gateway' => $order->gateway,
|
||||
'actions' => $actionsHtml
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $formattedOrders,
|
||||
'sidebar_html' => '',
|
||||
'total' => $totalOrders,
|
||||
'has_recount' => false,
|
||||
'columns_config' => [
|
||||
'order_code' => [
|
||||
'label' => __('Order Code', 'fluent-crm'),
|
||||
'width' => '120px',
|
||||
'sortable' => false,
|
||||
'key' => 'id'
|
||||
],
|
||||
'membership_level_name' => [
|
||||
'label' => __('Membership Level', 'fluent-crm'),
|
||||
'sortable' => false,
|
||||
],
|
||||
'date' => [
|
||||
'label' => __('Date', 'fluent-crm'),
|
||||
'sortable' => false,
|
||||
'key' => 'date_created_gmt'
|
||||
],
|
||||
'status' => [
|
||||
'label' => __('Status', 'fluent-crm'),
|
||||
'width' => '100px'
|
||||
],
|
||||
'total' => [
|
||||
'label' => __('Total', 'fluent-crm'),
|
||||
'width' => '130px',
|
||||
'sortable' => false,
|
||||
'key' => 'total_amount'
|
||||
],
|
||||
'gateway' => [
|
||||
'label' => __('Gateway', 'fluent-crm'),
|
||||
'width' => '120px',
|
||||
'sortable' => false,
|
||||
],
|
||||
'actions' => [
|
||||
'label' => __('Actions', 'fluent-crm'),
|
||||
'width' => '100px'
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Models\Campaign;
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
use FluentCrm\App\Models\CampaignUrlMetric;
|
||||
use FluentCrm\App\Models\UrlStores;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
/**
|
||||
* RedirectionHandler Class
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class RedirectionHandler
|
||||
{
|
||||
public function redirect($data)
|
||||
{
|
||||
nocache_headers();
|
||||
|
||||
$mailId = false;
|
||||
$urlSlug = sanitize_text_field($data['ns_url']);
|
||||
|
||||
if (isset($data['mid'])) {
|
||||
$mailId = intval($data['mid']);
|
||||
}
|
||||
|
||||
$urlData = fluentCrmGetFromCache('url_' . $urlSlug, function () use ($urlSlug) {
|
||||
return UrlStores::getRowByShort($urlSlug);
|
||||
});
|
||||
|
||||
if (!$urlData) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($data['fch'])) {
|
||||
$urlData->url_token = $data['fch'];
|
||||
}
|
||||
|
||||
$isAnonymousClick = isset($data['ano']);
|
||||
|
||||
$redirectUrl = trim($this->trackUrlClick($mailId, $urlData, $isAnonymousClick));
|
||||
$redirectUrl = htmlspecialchars_decode($redirectUrl);
|
||||
|
||||
if (!$redirectUrl) {
|
||||
wp_redirect(home_url(), 307);
|
||||
exit;
|
||||
}
|
||||
|
||||
// remove zero width space
|
||||
$redirectUrl = str_replace(["\xE2\x80\x8B", '%E2%80%8B'], '', $redirectUrl);
|
||||
do_action('fluentcrm_email_url_click', $redirectUrl, $mailId, $urlData);
|
||||
wp_redirect($redirectUrl, 307);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function trackUrlClick($mailId, $urlData, $isAnonymousClick = false)
|
||||
{
|
||||
if (!$mailId) {
|
||||
return $urlData->url;
|
||||
}
|
||||
|
||||
$campaignEmail = CampaignEmail::with(['subscriber'])->find($mailId);
|
||||
|
||||
if (!$campaignEmail || !$campaignEmail->subscriber) {
|
||||
return $urlData->url;
|
||||
}
|
||||
|
||||
$campaign = fluentCrmGetFromCache('campaign_' . $campaignEmail->campaign_id, function () use ($campaignEmail) {
|
||||
return Campaign::withoutGlobalScopes()->find($campaignEmail->campaign_id);
|
||||
});
|
||||
|
||||
if (!$campaign) {
|
||||
return $urlData->url;
|
||||
}
|
||||
|
||||
// Require valid fch token before recording any tracking data.
|
||||
// Missing or invalid token = redirect but don't record metrics.
|
||||
// This prevents analytics poisoning via forged or guessed mid values.
|
||||
if (empty($urlData->url_token) || substr($campaignEmail->email_hash, 0, 8) !== $urlData->url_token) {
|
||||
return $urlData->url;
|
||||
}
|
||||
|
||||
if (!$campaignEmail->is_open && !$isAnonymousClick) {
|
||||
do_action('fluent_crm/email_opened', $campaignEmail);
|
||||
}
|
||||
|
||||
if (!$isAnonymousClick) {
|
||||
CampaignUrlMetric::maybeInsert([
|
||||
'url_id' => $urlData->id,
|
||||
'campaign_id' => $campaignEmail->campaign_id,
|
||||
'subscriber_id' => $campaignEmail->subscriber_id,
|
||||
'type' => 'click',
|
||||
'ip_address' => FluentCrm('request')->getIp(fluentCrmWillAnonymizeIp())
|
||||
]);
|
||||
}
|
||||
|
||||
$url = $urlData->url;
|
||||
|
||||
$url = str_replace('&', '&', $url);
|
||||
$url = esc_url_raw($url);
|
||||
|
||||
$isSmartUrl = strpos($url, 'route=smart_url');
|
||||
|
||||
$tokenVerified = false;
|
||||
|
||||
/**
|
||||
* Filter whether to use cookies for FluentCRM redirection.
|
||||
*
|
||||
* This filter allows you to control whether cookies should be used for tracking
|
||||
* FluentCRM redirection. By default, it is set to true.
|
||||
*
|
||||
* @param bool Whether to use cookies for redirection. Default true.
|
||||
* @since 2.8.44
|
||||
*
|
||||
*/
|
||||
if (apply_filters('fluent_crm/will_use_cookie', true) && !empty($urlData->url_token)) {
|
||||
// validate the URL token here
|
||||
if (substr($campaignEmail->email_hash, 0, 8) === $urlData->url_token) {
|
||||
$tokenVerified = true;
|
||||
$secureHash = fluentCrmGetContactSecureHash($campaignEmail->subscriber_id);
|
||||
setcookie("fc_hash_secure", $secureHash, time() + 7776000, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true); /* expire in 90 days */
|
||||
$_COOKIE['fc_hash_secure'] = $secureHash;
|
||||
}
|
||||
|
||||
if ($campaignEmail->campaign_id) {
|
||||
setcookie("fc_cid", $campaignEmail->campaign_id, time() + 2419200, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true); /* expire in 28 days */
|
||||
}
|
||||
}
|
||||
|
||||
do_action('fluent_crm/email_url_clicked', $campaignEmail, $urlData);
|
||||
|
||||
$args = $campaign->getUtmParams();
|
||||
|
||||
if (!$isAnonymousClick) {
|
||||
$campaignEmail->click_counter += 1;
|
||||
$campaignEmail->is_open = 1;
|
||||
$campaignEmail->save();
|
||||
} else {
|
||||
do_action('fluent_crm/anonymous_email_url_clicked', $url, $campaign, $campaignEmail);
|
||||
}
|
||||
|
||||
do_action('fluent_crm/track_activity_by_subscriber', $campaignEmail->subscriber);
|
||||
|
||||
if ($isSmartUrl) {
|
||||
// this is a smart URL
|
||||
$url_components = wp_parse_url($url);
|
||||
parse_str($url_components['query'], $params);
|
||||
|
||||
if (!empty($params['slug'])) {
|
||||
$subscriber = $campaignEmail->subscriber;
|
||||
|
||||
$signedHash = Arr::get($_REQUEST, 'signed_hash');
|
||||
$isSecure = $tokenVerified && $signedHash && \FluentCrm\App\Services\Helper::verifySmartUrlHash($campaignEmail->email_hash, $signedHash);
|
||||
|
||||
if ($isSecure) {
|
||||
do_action('fluent_crm/smart_link_verified', $subscriber);
|
||||
}
|
||||
|
||||
do_action('fluentcrm_smartlink_clicked_direct', sanitize_text_field($params['slug']), $subscriber, $campaignEmail);
|
||||
}
|
||||
}
|
||||
|
||||
if (strpos($urlData->url, 'route=bnu') !== false) {
|
||||
$url_components = wp_parse_url($url);
|
||||
parse_str($url_components['query'], $params);
|
||||
if (!empty($params['aid'])) {
|
||||
$benchmarkActionId = intval($params['aid']);
|
||||
// Note: hook name has a known typo (missing 't' in 'fluent') — kept for backward compatibility with Pro
|
||||
do_action('fluencrm_benchmark_link_clicked', $benchmarkActionId, $campaignEmail->subscriber);
|
||||
}
|
||||
$args['bnu_timer_' . time()] = time();
|
||||
}
|
||||
|
||||
if ($args) {
|
||||
$url = add_query_arg($args, $url);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Models\Campaign;
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
use FluentCrm\App\Services\CampaignProcessor;
|
||||
use FluentCrm\App\Services\ExternalIntegrations\Maintenance;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\App\Services\Libs\FileSystem;
|
||||
use FluentCrm\App\Services\Libs\Mailer\Handler;
|
||||
use FluentCrm\App\Services\Libs\Mailer\MultiThreadHandler;
|
||||
|
||||
/**
|
||||
* Scheduler Class
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class Scheduler
|
||||
{
|
||||
|
||||
public static function register()
|
||||
{
|
||||
/*
|
||||
* Migrating from CRON to Action Scheduler for Every Minutes Tasks
|
||||
*/
|
||||
add_action('fluentcrm_scheduled_minute_tasks', function () {
|
||||
// Auto-migration: ensure the Action Scheduler recurring action exists.
|
||||
if (!as_has_scheduled_action('fluentcrm_scheduled_every_minute_tasks', [], 'fluent-crm')) {
|
||||
Helper::debugLog('Migrating Every Minute CRON to Action Scheduler for FluentCRM');
|
||||
as_schedule_recurring_action(time(), 60, 'fluentcrm_scheduled_every_minute_tasks', [], 'fluent-crm');
|
||||
return;
|
||||
}
|
||||
|
||||
// WP-Cron is a TRUE fallback: only take over when Action Scheduler
|
||||
// has actually stalled. _fcrm_last_scheduler is written by
|
||||
// Scheduler::process() on every successful AS-driven minute tick;
|
||||
// if it's fresh, AS owns this minute and we no-op here.
|
||||
$lastScheduler = fluentCrmGetOptionCache('_fcrm_last_scheduler');
|
||||
if ($lastScheduler && (time() - $lastScheduler) <= 70) {
|
||||
return;
|
||||
}
|
||||
|
||||
// AS appears stalled (or has never run on this site yet) — take
|
||||
// over via the same locked entry point AS uses. The atomic lock
|
||||
// inside process() prevents two concurrent WP-Cron runners both
|
||||
// deciding to take over from racing each other.
|
||||
self::process();
|
||||
});
|
||||
|
||||
// This is required to instantly send emails for regular email handler.
|
||||
// The atomic lock inside Handler::isSystemOk() (acquired before any
|
||||
// expensive work) is the authoritative guard against concurrent and
|
||||
// duplicate sends, so we invoke the sender directly — a losing racer
|
||||
// bails cheaply at the lock. No cron-timing pre-check is needed here.
|
||||
add_action('wp_ajax_nopriv_fluentcrm-post-campaigns-send-now', function () {
|
||||
(new \FluentCrm\App\Services\Libs\Mailer\Handler())->handle();
|
||||
|
||||
nocache_headers();
|
||||
wp_send_json_success([
|
||||
'message' => 'success',
|
||||
'timestamp' => time()
|
||||
]);
|
||||
});
|
||||
|
||||
// For Multi Threaded Emails Internal Ajax. Same as above — the atomic
|
||||
// lock inside MultiThreadHandler::isSystemOk() guards against concurrent
|
||||
// runners, so we call the handler directly and let the loser bail at the
|
||||
// lock. The experimental-flag check stays here to avoid constructing the
|
||||
// handler at all when multi-threading is disabled.
|
||||
add_action('wp_ajax_nopriv_fluentcrm-post-multi-thread-send-now', function () {
|
||||
if (Helper::isExperimentalEnabled('multi_threading_emails')) {
|
||||
(new MultiThreadHandler())->handle();
|
||||
}
|
||||
|
||||
nocache_headers();
|
||||
wp_send_json_success([
|
||||
'message' => 'success',
|
||||
'timestamp' => time()
|
||||
]);
|
||||
});
|
||||
|
||||
add_action('fluentcrm_scheduled_every_minute_tasks', array(__CLASS__, 'process'));
|
||||
add_action('fluentcrm_scheduled_hourly_tasks', array(__CLASS__, 'processHourly'));
|
||||
add_action('fluentcrm_scheduled_five_minute_tasks', array(__CLASS__, 'processFiveMinutes'));
|
||||
add_action('fluentcrm_process_contact_jobs', array(__CLASS__, 'processForSubscriber'), 999, 1);
|
||||
add_action('fluentcrm_scheduled_weekly_tasks', array(__CLASS__, 'processWeekly'));
|
||||
add_action('fluent_crm_send_multi_thread_emails', array(__CLASS__, 'processMultiThreadEmails'), 10);
|
||||
|
||||
add_action('fluent_crm_cancel_multi_thread_mailing', function () {
|
||||
as_unschedule_all_actions('fluent_crm_send_multi_thread_emails');
|
||||
return true;
|
||||
});
|
||||
|
||||
/*
|
||||
* Clean up schedule that means removing from database- tasks by action scheduler
|
||||
* Clean up before last 7 days logs generated by action scheduler
|
||||
* this action will be triggered daily and will remove all the logs generated before 7 days
|
||||
*/
|
||||
add_action('fluent_crm_ascheduler_runs_daily', function () {
|
||||
Cleanup::maybeRemoveOldScheuledActionLogs();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public static function process()
|
||||
{
|
||||
wp_raise_memory_limit('admin');
|
||||
|
||||
// In-process re-entrance guard (cheap; complements the cross-process lock below).
|
||||
if (did_action('fluentcrm_process_scheduled_tasks_init')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Atomic cross-process mutex. Prevents concurrent AS + WP-Cron + AJAX
|
||||
// runners from all reaching Handler->handle() at the same time. The
|
||||
// downstream BaseHandler also has its own lock — this outer guard
|
||||
// avoids wasted PHP bootstraps for the loser of the race.
|
||||
if (!self::acquireLock('minute_scheduler', 90)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// _fcrm_last_scheduler stays as the success-timestamp signal used
|
||||
// by the WP-Cron fallback in register() to detect a stalled Action
|
||||
// Scheduler. It is no longer the gate that prevents re-entry —
|
||||
// that role belongs to the atomic lock above.
|
||||
fluentCrmSetOptionCache('_fcrm_last_scheduler', time(), 50);
|
||||
do_action('fluentcrm_process_scheduled_tasks_init');
|
||||
|
||||
(new Handler)->handle();
|
||||
} finally {
|
||||
self::releaseLock('minute_scheduler');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-ping fallback for the every-minute task.
|
||||
*
|
||||
* Triggered from the admin app's periodic ping (ReportingController::ping,
|
||||
* fired ~every 50s while any CRM page is open). It is a TRUE last-resort
|
||||
* fallback: it only takes over when Action Scheduler (and the WP-Cron
|
||||
* fallback) have stalled, detected by the same _fcrm_last_scheduler
|
||||
* freshness signal the WP-Cron fallback in register() uses. When AS is
|
||||
* healthy this returns after a single option read, so it is safe to call on
|
||||
* every ping and for every admin who has the dashboard open — it does NOT
|
||||
* run cron more often than once per minute on a healthy site.
|
||||
*
|
||||
* All concurrency safety lives in process(): its atomic cross-process lock
|
||||
* means that even with many tabs/users pinging at once, at most one runner
|
||||
* sends emails, and the _fcrm_last_scheduler stamp written there throttles
|
||||
* takeovers to roughly once per minute. This only advances the minute task
|
||||
* (the email-sending pipeline); the heavier hourly/five-minute tasks keep
|
||||
* their own WP-Cron/AS schedules.
|
||||
*
|
||||
* @return bool True if it took over and ran the minute task, false otherwise.
|
||||
*/
|
||||
public static function maybeProcessFromBrowserPing()
|
||||
{
|
||||
// Action Scheduler owns this task; only step in when it has actually
|
||||
// stalled. Same 70s threshold as the WP-Cron fallback in register().
|
||||
$lastScheduler = fluentCrmGetOptionCache('_fcrm_last_scheduler');
|
||||
if ($lastScheduler && (time() - $lastScheduler) <= 70) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::process();
|
||||
}
|
||||
|
||||
public static function processForSubscriber($subscriber)
|
||||
{
|
||||
if (!is_object($subscriber) || empty($subscriber->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!defined('FLUENTCRM_DOING_BULK_IMPORT')) {
|
||||
// @todo: Implement this immediately
|
||||
(new Handler)->processSubscriberEmail($subscriber->id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function processHourly()
|
||||
{
|
||||
// Atomic mutex. Closes the duplicate-event leak in markArchiveCampaigns():
|
||||
// without this, two concurrent hourly runners both pass the SELECT,
|
||||
// both UPDATE rows to 'archived' (idempotent), and both fire
|
||||
// fluent_crm/campaign_archived for the same campaign — causing
|
||||
// listeners (webhooks, metrics, notifications) to fire twice.
|
||||
if (!self::acquireLock('hourly_scheduler', 300)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
self::markArchiveCampaigns();
|
||||
self::maybeCleanupCsvFiles();
|
||||
do_action('fluent_crm_process_automation');
|
||||
} finally {
|
||||
self::releaseLock('hourly_scheduler');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static function markArchiveCampaigns()
|
||||
{
|
||||
// get the scheduled or working campaigns where scheduled_at is five minutes ago
|
||||
$campaigns = Campaign::whereIn('status', ['working', 'scheduled'])
|
||||
->whereDoesntHave('emails', function ($query) {
|
||||
$query->whereIn('status', ['scheduling', 'pending', 'scheduled', 'processing', 'draft']);
|
||||
return $query;
|
||||
})
|
||||
->withoutGlobalScope('type')
|
||||
->whereIn('type', fluentCrmAutoProcessCampaignTypes())
|
||||
->where('scheduled_at', '<', gmdate('Y-m-d H:i:s', current_time('timestamp') - 300))
|
||||
->get();
|
||||
|
||||
if (!$campaigns->isEmpty()) {
|
||||
|
||||
Campaign::whereIn('id', array_unique($campaigns->pluck('id')->toArray()))
|
||||
->withoutGlobalScope('type')
|
||||
->update([
|
||||
'status' => 'archived'
|
||||
]);
|
||||
|
||||
foreach ($campaigns as $campaign) {
|
||||
do_action('fluent_crm/campaign_archived', $campaign);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public static function processWeekly()
|
||||
{
|
||||
(new Maintenance())->maybeProcessData();
|
||||
|
||||
// Clear email_body from historical 'sent' rows to reclaim disk space.
|
||||
// Loop a LIMIT-bounded UPDATE so each statement's row-lock footprint
|
||||
// stays small (an unbounded UPDATE on a multi-million-row table holds
|
||||
// locks for minutes and stalls report/dashboard SELECTs) while still
|
||||
// draining the full backlog in this tick. Going direct to $wpdb skips
|
||||
// ORM overhead on what is effectively the same repeated statement.
|
||||
try {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'fc_campaign_emails';
|
||||
$chunkSize = 50000;
|
||||
$maxIterations = 100; // safety cap — up to ~5M rows per weekly tick
|
||||
|
||||
for ($i = 0; $i < $maxIterations; $i++) {
|
||||
$affected = (int) $wpdb->query(
|
||||
"UPDATE {$table} SET email_body = '' WHERE status = 'sent' AND email_body != '' LIMIT {$chunkSize}"
|
||||
);
|
||||
|
||||
if ($affected < $chunkSize || fluentCrmIsMemoryExceeded()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Helper::debugLog('processWeekly', 'email_body cleanup deferred: ' . $e->getMessage(), 'extended');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and process pending campaigns.
|
||||
*
|
||||
* Called by cron/Action Scheduler. Handles housekeeping (stale email reset),
|
||||
* finds campaigns ready to process, and kicks off processing. For continuous
|
||||
* processing, use processCampaignById() via the AJAX handler.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function processFiveMinutes()
|
||||
{
|
||||
// Cheap time-based pre-check — skips the lock-acquire round trip when
|
||||
// the function is called more frequently than the work needs to run.
|
||||
$lastRun = fluentCrmGetOptionCache('_fcrm_last_five_minutes_run', 30);
|
||||
if ($lastRun && (time() - $lastRun) < 60) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Atomic mutex. The throttle above is non-atomic so two near-simultaneous
|
||||
// callers can both pass it; the lock guarantees that only one actually
|
||||
// proceeds into discovery + processing.
|
||||
if (!self::acquireLock('five_minute_scheduler', 180)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
fluentCrmSetOptionCache('_fcrm_last_five_minutes_run', time(), 60);
|
||||
|
||||
self::resetStaleProcessingEmails(100, 'processFiveMinutes');
|
||||
|
||||
$cutOutTime = gmdate('Y-m-d H:i:s', current_time('timestamp') + 360);
|
||||
|
||||
$campaigns = Campaign::whereIn('status', ['pending-scheduled', 'processing'])
|
||||
->withoutGlobalScope('type')
|
||||
->whereIn('type', fluentCrmAutoProcessCampaignTypes())
|
||||
->orderBy('scheduled_at', 'ASC')
|
||||
->where('scheduled_at', '<=', $cutOutTime)
|
||||
->limit(2)
|
||||
->get();
|
||||
|
||||
if ($campaigns->isEmpty()) {
|
||||
do_action('fluent_crm_process_automation');
|
||||
do_action('fluentcrm_scheduled_hourly_tasks');
|
||||
return false;
|
||||
}
|
||||
|
||||
$firstCampaign = $campaigns->first();
|
||||
|
||||
if ($firstCampaign->status == 'pending-scheduled') {
|
||||
$firstCampaign->status = 'processing';
|
||||
$firstCampaign->save();
|
||||
}
|
||||
|
||||
$result = self::processCampaignById($firstCampaign->id);
|
||||
|
||||
// If first campaign is done and there are more queued, chain the next one.
|
||||
// Skip if memory is low (aborted) to avoid cascading failures.
|
||||
if (!$result && count($campaigns) > 1 && !fluentCrmIsMemoryExceeded()) {
|
||||
// Verify first campaign actually finished (not just aborted)
|
||||
$firstCampaign = Campaign::withoutGlobalScope('type')->find($firstCampaign->id);
|
||||
if ($firstCampaign && $firstCampaign->status != 'processing') {
|
||||
$nextCampaign = $campaigns->last();
|
||||
if ($nextCampaign->status == 'pending-scheduled') {
|
||||
$nextCampaign->status = 'processing';
|
||||
$nextCampaign->save();
|
||||
}
|
||||
self::fireCampaignProcessingChain($nextCampaign->id);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
} finally {
|
||||
self::releaseLock('five_minute_scheduler');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rows stuck in 'processing' back to 'pending' so they get re-claimed.
|
||||
*
|
||||
* An unbounded mass UPDATE on (status='processing' AND updated_at < cutoff)
|
||||
* locks a wide range and deadlocks against the row-level SELECT ... FOR
|
||||
* UPDATE claims that the mailer Handler / MultiThreadHandler hold while
|
||||
* sending. We instead drain in bounded chunks by primary key.
|
||||
*
|
||||
* We deliberately do NOT order the SELECT: ORDER BY id would push MySQL
|
||||
* onto PRIMARY (full id-walk looking for sparse matches on a multi-million
|
||||
* row table) instead of the (status, scheduled_at) index, which contains
|
||||
* only the small currently-'processing' slice. Each chunk drains rows out
|
||||
* of the predicate, so the next iteration naturally finds different rows
|
||||
* without an explicit order.
|
||||
*
|
||||
* Any deadlock that still slips through is harmless — remaining rows will
|
||||
* be picked up on the next caller's tick.
|
||||
*
|
||||
* @param int $maxAgeSeconds Rows older than this (in 'processing') get reset.
|
||||
* @param string $callerContext Used in the deferred-log message.
|
||||
* @return int Number of rows reset back to pending.
|
||||
*/
|
||||
public static function resetStaleProcessingEmails($maxAgeSeconds = 100, $callerContext = '')
|
||||
{
|
||||
try {
|
||||
// If a sender lock is still fresh, a batch is likely active or just
|
||||
// yielded. Resetting 'processing' rows during that window risks
|
||||
// requeueing work owned by the live sender and increases row-lock
|
||||
// contention with SELECT ... FOR UPDATE / sent-status updates.
|
||||
if (self::hasFreshEmailSenderLock($maxAgeSeconds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$staleCutoff = gmdate('Y-m-d H:i:s', current_time('timestamp') - (int) $maxAgeSeconds);
|
||||
$chunkSize = 200;
|
||||
$maxChunks = 50; // up to 10k rows per call; subsequent calls drain the rest
|
||||
$recovered = 0;
|
||||
|
||||
for ($i = 0; $i < $maxChunks; $i++) {
|
||||
$staleIds = CampaignEmail::where('status', 'processing')
|
||||
->where('updated_at', '<', $staleCutoff)
|
||||
->limit($chunkSize)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
if (empty($staleIds)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$updated = CampaignEmail::whereIn('id', $staleIds)
|
||||
->where('status', 'processing')
|
||||
->update([
|
||||
'status' => 'pending'
|
||||
]);
|
||||
|
||||
if ($updated === false) {
|
||||
global $wpdb;
|
||||
Helper::debugLog($callerContext ?: 'resetStaleProcessingEmails', 'Stale email reset deferred: ' . $wpdb->last_error, 'extended');
|
||||
break;
|
||||
}
|
||||
|
||||
$recovered += (int) $updated;
|
||||
|
||||
if (count($staleIds) < $chunkSize || fluentCrmIsMemoryExceeded()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($recovered) {
|
||||
Helper::debugLog($callerContext ?: 'resetStaleProcessingEmails', 'Recovered ' . $recovered . ' stale processing emails older than ' . (int) $maxAgeSeconds . ' seconds', 'extended');
|
||||
}
|
||||
|
||||
return $recovered;
|
||||
} catch (\Exception $e) {
|
||||
Helper::debugLog($callerContext ?: 'resetStaleProcessingEmails', 'Stale email reset deferred: ' . $e->getMessage(), 'extended');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Avoid stale-row recovery while a sender still appears active.
|
||||
*
|
||||
* Sender locks are refreshed by BaseHandler::refreshLock() between claimed
|
||||
* batches. We check all sender lock keys because regular, multi-threaded,
|
||||
* and CLI senders can all own rows in fc_campaign_emails.
|
||||
*
|
||||
* @param int $maxAgeSeconds
|
||||
* @return bool
|
||||
*/
|
||||
private static function hasFreshEmailSenderLock($maxAgeSeconds)
|
||||
{
|
||||
// Use at least 60 seconds so a very small caller-provided stale window
|
||||
// does not make recovery race an otherwise healthy sender.
|
||||
$freshWindow = max(60, (int) $maxAgeSeconds);
|
||||
|
||||
// Compare everything against one timestamp for consistent decisions
|
||||
// across all sender lock keys checked below.
|
||||
$now = time();
|
||||
|
||||
foreach (['fluentcrm_is_sending_emails', 'fluentcrm_is_sending_multi_emails', 'fluentcrm_is_sending_cli_emails'] as $lockKey) {
|
||||
// Read the lock straight from its wp_options row. BaseHandler's
|
||||
// acquireLock()/refreshLock() store the timestamp there via
|
||||
// Helper::acquireDbLock()/refreshDbLock() on every environment, so we
|
||||
// must NOT use getInstantOption() here: on object-cache sites it reads
|
||||
// the fc_instant_options group, which the DB lock never writes to, and
|
||||
// would miss a live sender — letting recovery reset its rows.
|
||||
$lockedAt = Helper::getDbLockTimestamp($lockKey);
|
||||
|
||||
// A non-empty timestamp inside the freshness window means a sender
|
||||
// appears active, so stale recovery should defer to the next tick.
|
||||
if ($lockedAt && ($now - $lockedAt) <= $freshWindow) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// No fresh sender lock was found. Recovery may safely inspect stale rows.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a specific campaign by ID.
|
||||
*
|
||||
* Can be called directly from the AJAX handler for continuous chaining
|
||||
* without re-discovering campaigns or running housekeeping.
|
||||
*
|
||||
* @param int $campaignId
|
||||
* @return bool True if more processing is needed, false if done.
|
||||
*/
|
||||
public static function processCampaignById($campaignId)
|
||||
{
|
||||
// Per-campaign scheduler lock. processCampaignById has two entry points
|
||||
// — the AJAX self-trigger fluentcrm-post-campaigns-emails-processing
|
||||
// (which bypasses processFiveMinutes' scheduler-level lock entirely)
|
||||
// and processFiveMinutes itself (which holds five_minute_scheduler).
|
||||
// Without this guard, fireCampaignProcessingChain could pile up
|
||||
// overlapping AJAX requests for the same campaign that all reach
|
||||
// CampaignProcessor and bail at its per-campaign lock — wasted PHP
|
||||
// bootstraps. Lock name is per-campaign so different campaigns still
|
||||
// process in parallel. TTL matches the set_time_limit(120) below.
|
||||
$lockName = 'campaign_chain_' . (int)$campaignId;
|
||||
if (!self::acquireLock($lockName, 120)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (function_exists('set_time_limit')) {
|
||||
@set_time_limit(120);
|
||||
}
|
||||
|
||||
$campaign = Campaign::withoutGlobalScope('type')->find($campaignId);
|
||||
if (!$campaign) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$campaignProcessingChunk = (int)apply_filters('fluent_crm/five_minute_campaign_processing_chunk', 20, $campaign);
|
||||
if ($campaignProcessingChunk < 1) {
|
||||
$campaignProcessingChunk = 1;
|
||||
}
|
||||
|
||||
$runTime = fluentCrmMaxRunTime() - 5;
|
||||
$campaign = (new CampaignProcessor($campaignId))->processEmails($campaignProcessingChunk, $runTime);
|
||||
|
||||
if (fluentCrmIsMemoryExceeded()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($campaign && $campaign->status == 'processing') {
|
||||
self::fireCampaignProcessingChain($campaignId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
self::releaseLock($lockName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a background AJAX request to continue processing a specific campaign.
|
||||
*
|
||||
* @param int $campaignId
|
||||
*/
|
||||
private static function fireCampaignProcessingChain($campaignId)
|
||||
{
|
||||
$url = add_query_arg([
|
||||
'action' => 'fluentcrm-post-campaigns-emails-processing',
|
||||
'campaign_id' => $campaignId,
|
||||
'time' => time()
|
||||
], admin_url('admin-ajax.php'));
|
||||
|
||||
\FluentCrm\App\Services\Libs\Mailer\Handler::fireNonBlockingRequest($url, [
|
||||
'retry' => 1
|
||||
]);
|
||||
}
|
||||
|
||||
public static function maybeCleanupCsvFiles()
|
||||
{
|
||||
$dir = FileSystem::getDir();
|
||||
|
||||
// loop through files in directory
|
||||
foreach (glob($dir . '/fluentcrm-*.csv') as $filename) {
|
||||
// check if file was created before last 30 minutes
|
||||
if (time() - filectime($filename) >= 1800) {
|
||||
wp_delete_file($filename); // delete file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function processMultiThreadEmails()
|
||||
{
|
||||
(new MultiThreadHandler())->handle();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim a scheduler-level lock so two runners can't enter the
|
||||
* same critical section concurrently (e.g. Action Scheduler + WP-Cron
|
||||
* minute ticks landing in the same second).
|
||||
*
|
||||
* Backed by a conditional UPDATE on wp_options keyed off a timestamp
|
||||
* (Helper::acquireDbLock). The UPDATE succeeds only if the row is unclaimed
|
||||
* or its stored timestamp is older than $ttl, so a crashed runner's lock
|
||||
* self-recovers after the TTL. This is used on every environment — we no
|
||||
* longer take a wp_cache_add() fast path, because that primitive is not
|
||||
* atomic under all object-cache drop-ins (e.g. LiteSpeed), which let
|
||||
* concurrent runners all acquire the same lock. See Helper::acquireDbLock().
|
||||
*
|
||||
* @param string $name Lock identifier appended to the option key.
|
||||
* @param int $ttl Seconds before a held lock is considered abandoned.
|
||||
* @return bool True if the lock was acquired by this process.
|
||||
*/
|
||||
private static function acquireLock($name, $ttl)
|
||||
{
|
||||
return Helper::acquireDbLock('_fluentcrm_lock_' . $name, $ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a scheduler-level lock previously acquired by acquireLock().
|
||||
* Safe to call even if the lock was not held by this process — the worst
|
||||
* case is freeing the slot a tick early.
|
||||
*/
|
||||
private static function releaseLock($name)
|
||||
{
|
||||
Helper::releaseDbLock('_fluentcrm_lock_' . $name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
/**
|
||||
* Setup wizard class
|
||||
*
|
||||
* Intial Setup Wizard for FluentCRM
|
||||
*
|
||||
*/
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
use FluentCrm\App\Services\PermissionManager;
|
||||
use FluentCrm\App\Services\TransStrings;
|
||||
use FluentCrm\App\Vite;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
/**
|
||||
* SetupWizard Class
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class SetupWizard
|
||||
{
|
||||
/**
|
||||
* Hook in tabs.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
/**
|
||||
* Determine whether to enable the FluentCRM setup wizard.
|
||||
*
|
||||
* This filter allows you to enable or disable the setup wizard for FluentCRM.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param bool Whether to enable the setup wizard. Default true.
|
||||
*/
|
||||
if (apply_filters('fluentcrm_setup_wizard', true) && current_user_can('manage_options')) {
|
||||
if(fluentcrm_get_option('fluentcrm_setup_wizard_ran') == 'yes') {
|
||||
wp_redirect(admin_url('admin.php?page=fluentcrm-admin&setup_complete=' . time()));
|
||||
exit();
|
||||
}
|
||||
|
||||
fluentcrm_update_option('fluentcrm_setup_wizard_ran', 'yes');
|
||||
$this->setup_wizard();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the setup wizard
|
||||
*/
|
||||
public function setup_wizard()
|
||||
{
|
||||
add_filter('user_can_richedit', '__return_true');
|
||||
|
||||
if (!function_exists('media_handle_upload')) {
|
||||
require_once(ABSPATH . 'wp-admin/includes/image.php');
|
||||
require_once(ABSPATH . 'wp-admin/includes/file.php');
|
||||
require_once(ABSPATH . 'wp-admin/includes/media.php');
|
||||
}
|
||||
|
||||
|
||||
if (current_user_can('upload_files')) {
|
||||
wp_enqueue_script('media-upload');
|
||||
}
|
||||
add_thickbox();
|
||||
|
||||
wp_enqueue_editor();
|
||||
|
||||
|
||||
if (function_exists('wp_enqueue_media')) {
|
||||
wp_enqueue_media();
|
||||
}
|
||||
|
||||
|
||||
// Inject Vite HMR client — mirrors AdminMenu::loadCssJs().
|
||||
// Without this, Vue <style> blocks are not applied in dev mode.
|
||||
add_action('admin_head', function () {
|
||||
Vite::injectViteClient();
|
||||
}, 1);
|
||||
|
||||
// style.css is the merged bundle of all Vue component CSS + Element Plus CSS,
|
||||
// produced by vite.config.mjs's mergeCssChunksPlugin. The manifest is
|
||||
// stripped of references to the merged files at build time
|
||||
// (moveManifestPlugin), so Vite::enqueueScript's auto-enqueue won't
|
||||
// pick it up — load it explicitly here. In dev mode, Vite HMR
|
||||
// injects the styles via the client above.
|
||||
if (!Vite::underDevelopment()) {
|
||||
wp_enqueue_style(
|
||||
'fluentcrm_vendor',
|
||||
fluentCrmMix('admin/css/style.css'),
|
||||
[],
|
||||
FLUENTCRM_PLUGIN_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'fluentcrm-setup',
|
||||
fluentCrmMix('admin/css/setup-wizard.css'),
|
||||
['dashicons']
|
||||
);
|
||||
|
||||
// Use Vite::enqueueScript so handles are added to $moduleScripts,
|
||||
// ensuring type="module" is applied correctly in both dev and production.
|
||||
Vite::enqueueScript('fluentcrm-boot', 'admin/boot.js', ['jquery'], FLUENTCRM_PLUGIN_VERSION, true);
|
||||
Vite::enqueueScript('fluentcrm-setup', 'admin/setup-wizard.js', ['fluentcrm-boot'], FLUENTCRM_PLUGIN_VERSION, true);
|
||||
|
||||
wp_enqueue_script('lodash');
|
||||
|
||||
$existingSettings = get_option(FLUENTCRM . '-global-settings');
|
||||
$businessSettings = Arr::get($existingSettings, 'business_settings', []);
|
||||
|
||||
$currentUser = wp_get_current_user();
|
||||
|
||||
wp_localize_script('fluentcrm-boot', 'fcAdmin', [
|
||||
'ajaxurl' => admin_url('admin-ajax.php'),
|
||||
'slug' => FLUENTCRM,
|
||||
'rest' => $this->getRestInfo(FluentCrm()),
|
||||
'trans' => TransStrings::getStrings(),
|
||||
'dashboard_url' => admin_url('admin.php?page=fluentcrm-admin&setup_complete=' . time()),
|
||||
'business_settings' => (object) $businessSettings,
|
||||
'has_fluentform' => defined('FLUENTFORM'),
|
||||
'has_fluentcart' => defined('FLUENTCART_VERSION'),
|
||||
'auth' => [
|
||||
'permissions' => PermissionManager::currentUserPermissions(),
|
||||
'first_name' => $currentUser->first_name,
|
||||
'last_name' => $currentUser->last_name,
|
||||
'email' => $currentUser->user_email,
|
||||
'avatar' => fluentcrmGetAvatarHtml($currentUser->user_email, $currentUser->display_name, 128),
|
||||
'user_id' => $currentUser->ID
|
||||
],
|
||||
]);
|
||||
|
||||
$this->outputHtml();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Wizard HTML
|
||||
*/
|
||||
public function outputHtml()
|
||||
{
|
||||
ob_start();
|
||||
fluentCrm('view')->render('admin.setup_wizard');
|
||||
exit();
|
||||
}
|
||||
|
||||
protected function getRestInfo($app)
|
||||
{
|
||||
$ns = $app->config->get('app.rest_namespace');
|
||||
$v = $app->config->get('app.rest_version');
|
||||
|
||||
return [
|
||||
'base_url' => esc_url_raw(rest_url()),
|
||||
'url' => rest_url($ns . '/' . $v),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'namespace' => $ns,
|
||||
'version' => $v,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
class UpgradationHandler
|
||||
{
|
||||
public static function maybeUpdateDbTables()
|
||||
{
|
||||
$currentDbVerson = get_option('_fluentcrm_db_version');
|
||||
if (!$currentDbVerson || version_compare($currentDbVerson, FLUENTCRM_DB_VERSION, '<')) {
|
||||
require_once(FLUENTCRM_PLUGIN_PATH . 'database/FluentCRMDBMigrator.php');
|
||||
|
||||
// A migration just ran (and an index ALTER may have failed mid-flight).
|
||||
// Drop the cached index-health snapshot so the next health check hits
|
||||
// the live DB and the on-load self-heal reflects the true post-migration
|
||||
// state instead of a pre-migration "ok". Stored as an empty array (not
|
||||
// deleted) because getIndexHealth() treats an empty cache as stale.
|
||||
fluentcrm_update_option('_db_index_health', []);
|
||||
}
|
||||
}
|
||||
|
||||
public static function updateTables()
|
||||
{
|
||||
// Run DB Migrations
|
||||
require_once(FLUENTCRM_PLUGIN_PATH . 'database/FluentCRMDBMigrator.php');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Hooks\Handlers;
|
||||
|
||||
/**
|
||||
* UrlMetrics Class - For Internal Debugging usage only
|
||||
*
|
||||
* @package FluentCrm\App\Hooks
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class WpQueryLogger
|
||||
{
|
||||
static $logInFile = true;
|
||||
|
||||
public static function getQueryLog($withStack = true)
|
||||
{
|
||||
$trace = debug_backtrace(2, 0);
|
||||
$trace = reset($trace);
|
||||
$file = explode('/', $trace['file']);
|
||||
$caller = end($file);
|
||||
$caller = substr($caller, 0, strpos($caller, '.'));
|
||||
|
||||
$class = explode('\\', __CLASS__);
|
||||
$class = end($class);
|
||||
|
||||
$self = false;
|
||||
if ($caller == $class) {
|
||||
$self = true;
|
||||
}
|
||||
|
||||
if (!defined('SAVEQUERIES') || !SAVEQUERIES) {
|
||||
|
||||
if ($self) return;
|
||||
|
||||
return [
|
||||
'message' => __('Please enable query logging by calling enableQueryLog() before queries ran.', 'fluent-crm'),
|
||||
'Total Queries Ran' => null,
|
||||
'Query Logs' => null
|
||||
];
|
||||
}
|
||||
|
||||
if (!current_user_can('administrator')) {
|
||||
return [
|
||||
'message' => __('Oops! You are not able to see query logs.', 'fluent-crm'),
|
||||
'Total Queries Ran' => null,
|
||||
'Query Logs' => null
|
||||
];
|
||||
}
|
||||
|
||||
if (FluentCrm()->request->get('action') == 'heartbeat') {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$queries = (array)$GLOBALS['wpdb']->queries;
|
||||
|
||||
foreach ($queries as $key => $query) {
|
||||
$query = array_slice($query, 0, 3);
|
||||
|
||||
if ($withStack) {
|
||||
$stackArray = [];
|
||||
$stack = explode(', ', $query[2]);
|
||||
|
||||
foreach ($stack as $skey => $sValue) {
|
||||
$stackArray[++$skey] = $sValue;
|
||||
}
|
||||
|
||||
$query[2] = $stackArray;
|
||||
|
||||
$result[++$key] = array_combine([
|
||||
'query', 'execution_time', 'stack'
|
||||
], $query);
|
||||
} else {
|
||||
$result[++$key] = array_combine([
|
||||
'query', 'execution_time'
|
||||
], array_slice($query, 0, 2));
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'Total Queries Ran' => count($queries),
|
||||
'Query Logs' => array_filter($result)
|
||||
];
|
||||
}
|
||||
|
||||
public function logQueries()
|
||||
{
|
||||
if (!static::$logInFile) return;
|
||||
|
||||
$result = static::getQueryLog();
|
||||
|
||||
if (!$result) return;
|
||||
|
||||
error_log('[' . fluentCrmTimestamp() . ']: ' . json_encode(
|
||||
$result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
|
||||
) . PHP_EOL, 3, FluentCrm()->path . 'query.log');
|
||||
}
|
||||
|
||||
public static function enableQueryLog($inFile = false)
|
||||
{
|
||||
if (!defined('SAVEQUERIES')) {
|
||||
define('SAVEQUERIES', true);
|
||||
static::$logInFile = $inFile;
|
||||
}
|
||||
}
|
||||
|
||||
public static function init()
|
||||
{
|
||||
add_action('shutdown', [get_class(), 'logQueries'], 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var \FluentCrm\Framework\Foundation\Application $app
|
||||
*/
|
||||
|
||||
/*
|
||||
* Note: Namespace will be added automatically. For example, if you use MyClass
|
||||
* as the controller name then it will become FluentCrm\App\Hooks\Handlers\MyClass.
|
||||
*/
|
||||
|
||||
// Init scheduled tasks
|
||||
|
||||
\FluentCrm\App\Hooks\Handlers\Scheduler::register();
|
||||
(new \FluentCrm\App\Hooks\Handlers\FluentBlockEditorHandler())->register();
|
||||
(new \FluentCrm\App\Hooks\Handlers\FluentConditionalContentBlockHandler())->register();
|
||||
(new \FluentCrm\App\Modules\AbandonCart\AbandonCart())->register();
|
||||
|
||||
(new \FluentCrm\App\Hooks\Handlers\AutoSubscribeHandler())->register();
|
||||
|
||||
add_action('fluentcrm_contacts_filter_subscriber', function ($query, $filters) {
|
||||
return (new \FluentCrm\App\Models\Subscriber)->buildGeneralPropertiesFilterQuery($query, $filters);
|
||||
}, 10, 2);
|
||||
|
||||
add_action('fluentcrm_contacts_filter_segment', function ($query, $filters) {
|
||||
return (new \FluentCrm\App\Models\Subscriber)->buildSegmentFilterQuery($query, $filters);
|
||||
}, 10, 2);
|
||||
|
||||
add_action('fluentcrm_contacts_filter_custom_fields', function ($query, $filters) {
|
||||
return (new \FluentCrm\App\Models\Subscriber)->buildCustomFieldsFilterQuery($query, $filters);
|
||||
}, 10, 2);
|
||||
|
||||
add_action('fluentcrm_contacts_filter_activities', function ($query, $filters) {
|
||||
return (new \FluentCrm\App\Models\Subscriber)->buildActivitiesFilterQuery($query, $filters);
|
||||
}, 10, 2);
|
||||
|
||||
|
||||
// Add admin init
|
||||
|
||||
$app->addAction('wp_loaded', 'AdminMenu@init');
|
||||
|
||||
$app->addAction('init', 'ExternalPages@route', 99);
|
||||
|
||||
$app->addAction('wp_ajax_fluentcrm_unsubscribe_ajax', 'ExternalPages@handleUnsubscribe');
|
||||
$app->addAction('wp_ajax_nopriv_fluentcrm_unsubscribe_ajax', 'ExternalPages@handleUnsubscribe');
|
||||
|
||||
$app->addAction('wp_ajax_fluentcrm_request_unsubscribe_ajax', 'ExternalPages@handleUnsubscribeRequestAjax');
|
||||
$app->addAction('wp_ajax_nopriv_fluentcrm_request_unsubscribe_ajax', 'ExternalPages@handleUnsubscribeRequestAjax');
|
||||
|
||||
$app->addAction('wp_ajax_fluentcrm_manage_preferences_ajax', 'ExternalPages@handleManageSubPref');
|
||||
$app->addAction('wp_ajax_nopriv_fluentcrm_manage_preferences_ajax', 'ExternalPages@handleManageSubPref');
|
||||
|
||||
|
||||
$app->addAction('wp_ajax_fluentcrm_request_manage_subscription_ajax', 'ExternalPages@handleManageSubRequestAjax');
|
||||
$app->addAction('wp_ajax_nopriv_fluentcrm_request_manage_subscription_ajax', 'ExternalPages@handleManageSubRequestAjax');
|
||||
|
||||
|
||||
$app->addAction('wp_ajax_fluentcrm_callback_for_background', 'ExternalPages@handleBackgroundProcessCallback');
|
||||
$app->addAction('wp_ajax_nopriv_fluentcrm_callback_for_background', 'ExternalPages@handleBackgroundProcessCallback');
|
||||
|
||||
$app->addAction('wp_ajax_fluent_crm_account_form', 'PrefFormHandler@handleAjax');
|
||||
$app->addAction('wp_ajax_nopriv_fluent_crm_account_form', 'PrefFormHandler@handleAjax');
|
||||
|
||||
|
||||
// Fallback for funnel sequence save ajax
|
||||
$app->addAction('wp_ajax_fluentcrm_save_funnel_sequence_ajax', 'FunnelHandler@saveSequences');
|
||||
$app->addAction('wp_ajax_fluentcrm_export_funnel', 'FunnelHandler@exportFunnel');
|
||||
$app->addAction('wp_ajax_fluentcrm_save_funnel_email_action', 'FunnelHandler@saveEmailAction');
|
||||
$app->addAction('wp_ajax_fluentcrm_save_campaign_email_body', 'FunnelHandler@saveCampaignEmail');
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Integrations & Funnels Handler Init
|
||||
*/
|
||||
|
||||
(new \FluentCrm\App\Hooks\Handlers\FunnelHandler())->register();
|
||||
|
||||
// FluentCart's modal checkout fires fluent_cart/before_payment_methods and calls die()
|
||||
// during init at priority 10, before a priority-10 callback can register. Only
|
||||
// CheckoutSubscription needs to be early — everything else in FluentCart::init() is fine at 10.
|
||||
add_action('init', function () {
|
||||
if (defined('FLUENTCART_VERSION')) {
|
||||
(new \FluentCrm\App\Services\ExternalIntegrations\FluentCart\CheckoutSubscription())->init();
|
||||
}
|
||||
}, 1);
|
||||
|
||||
// All external integrations (FluentCart::init() runs here too, minus CheckoutSubscription)
|
||||
add_action('init', function () {
|
||||
(new \FluentCrm\App\Hooks\Handlers\Integrations())->register();
|
||||
}, 10);
|
||||
|
||||
|
||||
$app->addAction('fluentcrm_subscriber_status_to_subscribed', 'FunnelHandler@resumeSubscriberFunnels', 1, 2);
|
||||
|
||||
/*
|
||||
* Cleanup Hooks
|
||||
*/
|
||||
$app->addAction('fluentcrm_after_subscribers_deleted', 'Cleanup@deleteSubscribersAssets', 10, 1);
|
||||
$app->addAction('fluent_crm/campaign_deleted', 'Cleanup@deleteCampaignAssets', 10, 1);
|
||||
$app->addAction('fluent_crm/list_deleted', 'Cleanup@deleteListAssets', 10, 1);
|
||||
$app->addAction('fluent_crm/tag_deleted', 'Cleanup@deleteTagAssets', 10, 1);
|
||||
$app->addAction('fluent_crm/campaign_archived', 'Cleanup@archiveCampaignAssets', 10, 1);
|
||||
$app->addAction('fluent_crm/sync_subscriber_delete_setting', 'Cleanup@SyncSubscriberDeleteSettings', 10, 2);
|
||||
|
||||
$app->addAction('fluentcrm_subscriber_status_to_unsubscribed', 'Cleanup@handleUnsubscribe');
|
||||
$app->addAction('fluentcrm_subscriber_status_to_bounced', 'Cleanup@handleUnsubscribe');
|
||||
$app->addAction('fluentcrm_subscriber_status_to_complained', 'Cleanup@handleUnsubscribe');
|
||||
$app->addAction('fluentcrm_subscriber_status_to_spammed', 'Cleanup@handleUnsubscribe');
|
||||
|
||||
$app->addAction('fluent_crm/contact_email_changed', 'Cleanup@handleContactEmailChanged');
|
||||
$app->addAction('delete_user', 'Cleanup@handleUserDelete', 10, 3);
|
||||
$app->addAction('fluent_crm/company_deleted', 'Cleanup@handleCompanyDelete', 10, 1);
|
||||
$app->addAction('after_password_reset', 'Cleanup@handleUserPasswordChanged', 10, 1);
|
||||
|
||||
add_action('fluent_crm/debug_log', function ($logData) {
|
||||
if (!is_array($logData) || empty($logData['title'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
\FluentCrm\App\Services\Helper::debugLog($logData['title'], \FluentCrm\Framework\Support\Arr::get($logData, 'description', ''), \FluentCrm\Framework\Support\Arr::get($logData, 'type', 'info'));
|
||||
});
|
||||
|
||||
/*
|
||||
* Admin Bar
|
||||
*/
|
||||
$app->addAction('admin_bar_menu', 'AdminBar@init');
|
||||
|
||||
add_action('wp_ajax_nopriv_fluentcrm-post-campaigns-emails-processing', function () use ($app) {
|
||||
$campaignId = isset($_REQUEST['campaign_id']) ? intval($_REQUEST['campaign_id']) : 0;
|
||||
|
||||
if ($campaignId) {
|
||||
// Continue processing a specific campaign — skip housekeeping/discovery
|
||||
\FluentCrm\App\Hooks\Handlers\Scheduler::processCampaignById($campaignId);
|
||||
} else {
|
||||
// No campaign ID — run full discovery (backward compat)
|
||||
\FluentCrm\App\Hooks\Handlers\Scheduler::processFiveMinutes();
|
||||
}
|
||||
|
||||
wp_send_json_success([
|
||||
'message' => 'success',
|
||||
'time' => time()
|
||||
]);
|
||||
});
|
||||
|
||||
/*
|
||||
* For Short URL Redirect
|
||||
*/
|
||||
add_action('wp_loaded', function () use ($app) {
|
||||
if (isset($_GET['ns_url'])) {
|
||||
(new \FluentCrm\App\Hooks\Handlers\RedirectionHandler())->redirect($_GET);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Contact Activity Logger Class Init
|
||||
*/
|
||||
add_action('init', function () {
|
||||
(new \FluentCrm\App\Hooks\Handlers\ContactActivityLogger())->register();
|
||||
(new \FluentCrm\App\Hooks\Handlers\ActivityLogHandler())->register();
|
||||
});
|
||||
|
||||
/*
|
||||
* Setup-wizard
|
||||
*/
|
||||
if (!empty($_GET['page']) && 'fluentcrm-setup' == $_GET['page']) {
|
||||
add_action('admin_menu', function () {
|
||||
add_dashboard_page('FluentCRM Setup', 'FluentCRM Setup', 'manage_options', 'fluentcrm-setup', function () {
|
||||
return '';
|
||||
});
|
||||
});
|
||||
|
||||
add_action('current_screen', function () {
|
||||
new \FluentCrm\App\Hooks\Handlers\SetupWizard();
|
||||
}, 999);
|
||||
}
|
||||
|
||||
|
||||
add_shortcode('fluentcrm_pref', function ($atts, $content) {
|
||||
return (new \FluentCrm\App\Hooks\Handlers\PrefFormHandler())->handleShortCode($atts, $content);
|
||||
});
|
||||
|
||||
add_shortcode('fluentcrm_content', function ($atts, $content) {
|
||||
$result = (new \FluentCrm\App\Hooks\Handlers\PrefFormHandler())->handleDynamicContentShortCode($atts, $content);
|
||||
return wp_kses_post($result);
|
||||
});
|
||||
|
||||
// require the CLI
|
||||
if (defined('WP_CLI') && WP_CLI) {
|
||||
\WP_CLI::add_command('fluent_crm', '\FluentCrm\App\Hooks\CLI\Commands');
|
||||
}
|
||||
|
||||
add_action('admin_notices', function () {
|
||||
if (defined('FLUENTCAMPAIGN_FRAMEWORK_VERSION') && FLUENTCAMPAIGN_FRAMEWORK_VERSION < 3) {
|
||||
echo '<div class="fc_notice notice notice-error fc_notice_error"><h3>Update FluentCRM Pro Plugin</h3><p>You are using an out-of-date version of FluentCRM Pro. <a href="' . esc_url(admin_url('plugins.php?s=fluentcampaign=pro&plugin_status=all&fluentcrm_pro_check_update=' . time())) . '">' . esc_html__('Please update FluentCRM Pro to latest version', 'fluent-crm') . '</a>.</p></div>';
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* For REST API Nonce Renew
|
||||
*/
|
||||
add_action('wp_ajax_fluentcrm_renew_rest_nonce', function () {
|
||||
if (!\FluentCrm\App\Services\PermissionManager::currentUserPermissions()) {
|
||||
wp_send_json([
|
||||
'error' => 'You do not have permission to do this'
|
||||
], 403);
|
||||
}
|
||||
wp_send_json([
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'time' => time()
|
||||
], 200);
|
||||
});
|
||||
|
||||
/*
|
||||
* Add custom CSS for fcrm_notice
|
||||
*/
|
||||
add_action('admin_head', function () {
|
||||
echo '<style>
|
||||
.fcrm_notice {
|
||||
background: #ffffff;
|
||||
border: 1px solid #E1E4EA;
|
||||
border-left: 3px solid #FB3748;
|
||||
padding: 10px 12px !important;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
</style>';
|
||||
});
|
||||
|
||||
/*
|
||||
* MCP — Register abilities for the WordPress Abilities API.
|
||||
*
|
||||
* Lazy-register guard:
|
||||
* - On WP < 6.9 (no Abilities API in core) OR sites without the WP MCP Adapter
|
||||
* plugin active, `wp_register_ability` is undefined — we skip silently.
|
||||
* - The opt-out option `fluent_crm_mcp_enabled` (default 'yes') lets admins
|
||||
* disable the entire MCP surface from Settings → MCP without uninstalling
|
||||
* the adapter.
|
||||
*
|
||||
* See `app/Modules/MCP/MCPInit.php` for the registration logic.
|
||||
*/
|
||||
add_action('init', function () {
|
||||
if (!function_exists('wp_register_ability')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fluentcrm_get_option('mcp_enabled', 'yes') !== 'yes') {
|
||||
return;
|
||||
}
|
||||
|
||||
(new \FluentCrm\App\Modules\MCP\MCPInit())->init();
|
||||
}, 5);
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var $app \FluentCrm\Framework\Foundation\Application $app
|
||||
*/
|
||||
|
||||
/*
|
||||
* Note: Namespace will be added automatically. For example, if you use MyClass
|
||||
* as the controller name then it will become FluentCrm\App\Hooks\Handlers\MyClass.
|
||||
*/
|
||||
|
||||
$app->addFilter('fluent_crm/countries', 'CountryNames@get');
|
||||
|
||||
(new \FluentCrm\App\Hooks\Handlers\EmailDesignTemplates())->register();
|
||||
|
||||
$app->addFilter('fluent_crm/purchase_history_woocommerce', 'PurchaseHistory@wooOrders', 10, 2);
|
||||
$app->addFilter('fluent_crm/purchase_history_edd', 'PurchaseHistory@eddOrders', 10, 2);
|
||||
$app->addFilter('fluent_crm/purchase_history_payform', 'PurchaseHistory@payformSubmissions', 10, 2);
|
||||
$app->addFilter('fluent_crm/purchase_history_pmpro', 'PurchaseHistory@pmproOrders', 10, 2);
|
||||
|
||||
// Fluent Forms Integration
|
||||
(new \FluentCrm\App\Hooks\Handlers\FormSubmissions())->register();
|
||||
|
||||
add_filter('fluent_crm/parse_campaign_email_text', function ($text, $subscriber) {
|
||||
return \FluentCrm\App\Services\Libs\Parser\Parser::parse($text, $subscriber);
|
||||
}, 10, 2);
|
||||
|
||||
$app->addFilter('fluent_crm/parse_extended_crm_text', function ($text, $subscriber) {
|
||||
if (!$subscriber) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return \FluentCrm\App\Services\Libs\Parser\Parser::parseCrmValue($text, $subscriber);
|
||||
}, 10, 2);
|
||||
|
||||
$app->addFilter('comment_form_submit_field', 'AutoSubscribeHandler@addSubscribeCheckbox', 10, 1);
|
||||
$app->addFilter('wp_privacy_personal_data_exporters', 'Cleanup@attachCrmExporter');
|
||||
|
||||
|
||||
$app->addFilter('wp_privacy_personal_data_exporters', 'Cleanup@attachCrmExporter');
|
||||
$app->addFilter('fluent_crm/block_editor_unregister_all_patterns', 'FluentBlockPatternHandler@shouldUnregisterAllPatterns', 10, 3);
|
||||
$app->addFilter('fluent_crm/block_editor_custom_pattern_categories', 'FluentBlockPatternHandler@addCustomPatternCategories', 10, 1);
|
||||
$app->addFilter('fluent_crm/block_editor_custom_patterns', 'FluentBlockPatternHandler@addCustomPatterns', 10, 1);
|
||||
|
||||
/*
|
||||
* deprecated Hooks
|
||||
* @todo: Remove this by January 2023
|
||||
*/
|
||||
add_filter('fluentcrm_parse_campaign_email_text', function ($text, $subscriber) {
|
||||
if (!$subscriber) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
_deprecated_hook('fluentcrm_parse_campaign_email_text', '2.6.6', 'fluent_crm/parse_campaign_email_text', 'Use fluent_crm/parse_campaign_email_text filter hook instead');
|
||||
|
||||
return \FluentCrm\App\Services\Libs\Parser\Parser::parse($text, $subscriber);
|
||||
}, 10, 2);
|
||||
|
||||
$app->addFilter('fluentcrm_email-design-template-plain', function ($emailBody, $templateData, $campaign) {
|
||||
_deprecated_hook('fluentcrm_email-design-template-plain', '2.6.6', 'fluent_crm/email-design-template-plain', 'Use fluent_crm/email-design-template-plain filter hook instead');
|
||||
return (new \FluentCrm\App\Hooks\Handlers\EmailDesignTemplates())->addPlainTemplate($emailBody, $templateData, $campaign);
|
||||
}, 10, 3);
|
||||
|
||||
$app->addFilter('fluentcrm_email-design-template-simple', function ($emailBody, $templateData, $campaign) {
|
||||
_deprecated_hook('fluentcrm_email-design-template-simple', '2.6.6', 'fluent_crm/email-design-template-simple', 'Use fluent_crm/email-design-template-simple filter hook instead');
|
||||
return (new \FluentCrm\App\Hooks\Handlers\EmailDesignTemplates())->addSimpleTemplate($emailBody, $templateData, $campaign);
|
||||
}, 10, 3);
|
||||
|
||||
$app->addFilter('fluentcrm_email-design-template-classic', function ($emailBody, $templateData, $campaign) {
|
||||
_deprecated_hook('fluentcrm_email-design-template-classic', '2.6.6', 'fluent_crm/email-design-template-classic', 'Use fluent_crm/email-design-template-classic filter hook instead');
|
||||
return (new \FluentCrm\App\Hooks\Handlers\EmailDesignTemplates())->addClassicTemplate($emailBody, $templateData, $campaign);
|
||||
}, 10, 3);
|
||||
|
||||
$app->addFilter('fluentcrm_email-design-template-raw_classic', function ($emailBody, $templateData, $campaign) {
|
||||
_deprecated_hook('fluentcrm_email-design-template-raw_classic', '2.6.6', 'fluent_crm/email-design-template-raw_classic', 'Use fluent_crm/email-design-template-raw_classic filter hook instead');
|
||||
return (new \FluentCrm\App\Hooks\Handlers\EmailDesignTemplates())->addRawClassicTemplate($emailBody, $templateData, $campaign);
|
||||
}, 10, 3);
|
||||
|
||||
$app->addFilter('fluentcrm_email-design-template-web_preview', function ($emailBody, $templateData, $campaign) {
|
||||
_deprecated_hook('fluentcrm_email-design-template-web_preview', '2.6.6', 'fluent_crm/email-design-template-web_preview', 'Use fluent_crm/email-design-template-web_preview filter hook instead');
|
||||
return (new \FluentCrm\App\Hooks\Handlers\EmailDesignTemplates())->addWebPreviewTemplate($emailBody, $templateData, $campaign);
|
||||
}, 10, 3);
|
||||
|
||||
/*
|
||||
* </deprecated_hooks_end>
|
||||
*/
|
||||
Reference in New Issue
Block a user