PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Modules / Integrations / FluentPlugins / FluentCRMDeepIntegration.php

FluentCRMDeepIntegration.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.5, at app/Modules/Integrations/FluentPlugins/FluentCRMDeepIntegration.php

806 lines 35.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\Integrations\FluentPlugins;
4
5 use FluentAffiliate\App\Models\Affiliate;
6 use FluentCart\Api\ModuleSettings;
7 use FluentCart\App\App;
8 use FluentCart\App\Helpers\Status;
9 use FluentCart\App\Models\Customer;
10 use FluentCart\App\Models\OrderItem;
11 use FluentCart\App\Models\Product;
12 use FluentCart\App\Services\DateTime\DateFormatter;
13 use FluentCart\App\Services\URL;
14 use FluentCrm\App\Models\Subscriber;
15 use FluentCrm\App\Services\Helper;
16 use FluentCrm\App\Services\Libs\ConditionAssessor;
17 use FluentCrm\Framework\Support\Arr;
18
19 class FluentCRMDeepIntegration
20 {
21 private $importKey = 'fluent_cart';
22
23 public function init()
24 {
25 // Advanced Filters
26 add_filter('fluentcrm_contacts_filter_fluent_cart', array($this, 'applyAdvancedFilters'), 10, 2);
27 add_filter('fluentcrm_advanced_filter_options', array($this, 'addAdvancedFilterOptions'), 10, 1);
28
29 add_filter('fluentcrm_ajax_options_product_selector_fluent_cart', [$this, 'handleProductSelectorAjax'], 10, 3);
30 add_filter('fluent_crm/cascade_selection_options_fct_variations', [$this, 'handleProductVariationsAjax'], 1, 2);
31
32 add_filter('fluent_crm/subscriber_info_widgets', [$this, 'pushInfoWidgetToContact'], 1, 2);
33
34
35 // temp
36 // @todo: Remove after FluentCRM version 3 release
37 add_filter('fluent_crm/purchase_history_fluent_cart', function ($data) {
38
39 if (!$data || empty($data['data'])) {
40 return $data;
41 }
42
43 $dataItems = $data['data'];
44
45 foreach ($dataItems as $index => $item) {
46 $action = $item['action'];
47 $action = str_replace('fluent-crm#/orders/', 'fluent-cart#/orders/', $action);
48 $dataItems[$index]['action'] = $action;
49 }
50
51 $data['data'] = $dataItems;
52
53 return $data;
54 }, 99, 1);
55
56 }
57
58 /**
59 * @param \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder $query
60 * @param array $filters
61 * @return \FluentCrm\Framework\Database\Orm\Builder|\FluentCrm\Framework\Database\Query\Builder
62 */
63 public function applyAdvancedFilters($query, $filters)
64 {
65 foreach ($filters as $filter) {
66 $query = $this->applyFilter($query, $filter);
67 }
68
69 return $query;
70 }
71
72 public function addAdvancedFilterOptions($groups)
73 {
74 $conditionItems = [
75 [
76 'value' => 'commerce_exist',
77 'label' => __('Is a customer?', 'fluent-cart'),
78 'type' => 'selections',
79 'is_multiple' => false,
80 'disable_values' => true,
81 'value_description' => __('This filter will check if a contact has at least one shop order or not', 'fluent-cart'),
82 'custom_operators' => [
83 'exist' => __('Yes', 'fluent-cart'),
84 'not_exist' => __('No', 'fluent-cart'),
85 ]
86 ],
87 [
88 'value' => 'ltv',
89 'label' => __('Lifetime Value', 'fluent-cart'),
90 'type' => 'numeric'
91 ],
92 [
93 'value' => 'aov',
94 'label' => __('Average Order Value', 'fluent-cart'),
95 'type' => 'numeric',
96 ],
97 [
98 'value' => 'first_purchase_date',
99 'label' => __('First Order Date', 'fluent-cart'),
100 'type' => 'dates'
101 ],
102 [
103 'value' => 'last_purchase_date',
104 'label' => __('Last Order Date', 'fluent-cart'),
105 'type' => 'dates'
106 ],
107 [
108 'value' => 'purchased_items',
109 'label' => __('Products', 'fluent-cart'),
110 'type' => 'selections',
111 'component' => 'product_selector',
112 'is_multiple' => true,
113 'custom_operators' => [
114 'exist' => __('purchased', 'fluent-cart'),
115 'not_exist' => __('not purchased', 'fluent-cart'),
116 ],
117 'help' => __('Will filter the contacts who have at least one order', 'fluent-cart')
118 ],
119 [
120 'value' => 'variation_purchased',
121 'label' => __('Product Variations', 'fluent-cart'),
122 'type' => 'cascade_selections',
123 'provider' => 'fct_variations',
124 'is_multiple' => true,
125 'value_description' => __('This filter will check if a contact has purchased at least one specific product variation or not', 'fluent-cart'),
126 'custom_operators' => [
127 'exist' => __('purchased', 'fluent-cart'),
128 'not_exist' => __('not purchased', 'fluent-cart'),
129 ]
130 ],
131 [
132 'value' => 'purchased_categories',
133 'label' => __('Product Categories', 'fluent-cart'),
134 'type' => 'selections',
135 'component' => 'tax_selector',
136 'taxonomy' => 'product-categories',
137 'is_multiple' => true,
138 'disabled' => true,
139 'help' => __('Will filter the contacts who have at least one order', 'fluent-cart'),
140 'custom_operators' => [
141 'exist' => __('purchased', 'fluent-cart'),
142 'not_exist' => __('not purchased', 'fluent-cart'),
143 ]
144 ],
145 [
146 'value' => 'commerce_coupons',
147 'label' => __('Used Coupons', 'fluent-cart'),
148 'type' => 'selections',
149 'component' => 'ajax_selector',
150 'option_key' => 'fct_coupons',
151 'is_multiple' => true,
152 'disabled' => true,
153 'custom_operators' => [
154 'exist' => __('in', 'fluent-cart'),
155 'not_exist' => __('not in', 'fluent-cart'),
156 ],
157 'help' => __('Will filter the contacts who have at least one order', 'fluent-cart')
158 ]
159 ];
160
161 if (ModuleSettings::isActive('license')) {
162 $conditionItems[] = [
163 'value' => 'active_licenses',
164 'label' => __('Active Licenses', 'fluent-cart'),
165 'type' => 'selections',
166 'component' => 'product_selector',
167 'is_multiple' => true,
168 'custom_operators' => [
169 'exist' => __('have', 'fluent-cart'),
170 'not_exist' => __('do not have', 'fluent-cart'),
171 ],
172 'help' => __('Will filter the contacts who have at least one active licenses or not', 'fluent-cart')
173 ];
174 $conditionItems[] = [
175 'value' => 'active_variation_licenses',
176 'label' => __('Active Variation Licenses', 'fluent-cart'),
177 'type' => 'cascade_selections',
178 'provider' => 'fct_variations',
179 'is_multiple' => true,
180 'value_description' => __('This filter will check if a contact has at least one specific variation license or not', 'fluent-cart'),
181 'custom_operators' => [
182 'exist' => __('have', 'fluent-cart'),
183 'not_exist' => __('do not have', 'fluent-cart'),
184 ]
185 ];
186 $conditionItems[] = [
187 'value' => 'expired_licenses',
188 'label' => __('Expired Licenses', 'fluent-cart'),
189 'type' => 'selections',
190 'component' => 'product_selector',
191 'is_multiple' => true,
192 'custom_operators' => [
193 'exist' => __('have', 'fluent-cart'),
194 'not_exist' => __('do not have', 'fluent-cart'),
195 ],
196 'help' => __('Will filter the contacts who have at least one expired licenses or not', 'fluent-cart')
197 ];
198 $conditionItems[] = [
199 'value' => 'expired_variation_licenses',
200 'label' => __('Expired Variation Licenses', 'fluent-cart'),
201 'type' => 'cascade_selections',
202 'provider' => 'fct_variations',
203 'is_multiple' => true,
204 'value_description' => __('This filter will check if a contact has at least one specific variation expired license or not', 'fluent-cart'),
205 'custom_operators' => [
206 'exist' => __('have', 'fluent-cart'),
207 'not_exist' => __('do not have', 'fluent-cart'),
208 ]
209 ];
210 $conditionItems[] = [
211 'value' => 'license_exist',
212 'label' => __('Has any active license?', 'fluent-cart'),
213 'type' => 'selections',
214 'is_multiple' => false,
215 'disable_values' => true,
216 'value_description' => __('Check if contacts has any active license from any products', 'fluent-cart'),
217 'custom_operators' => [
218 'exist' => __('Yes', 'fluent-cart'),
219 'not_exist' => __('No', 'fluent-cart'),
220 ]
221 ];
222 }
223
224 $groups['fluent_cart'] = [
225 'label' => __('FluentCart', 'fluent-cart'),
226 'value' => 'fluent_cart',
227 'children' => $conditionItems
228 ];
229
230 return $groups;
231 }
232
233 private function applyFilter($query, $filter)
234 {
235 $key = Arr::get($filter, 'property', '');
236 $value = Arr::get($filter, 'value', '');
237 $operator = Arr::get($filter, 'operator', '');
238
239 if (!$key || !$operator) {
240 return $query;
241 }
242
243 if ($key == 'commerce_exist') {
244 if ($operator === 'exist') {
245 return $query->whereExists(function ($q) {
246 $q->select(fluentCrmDb()->raw(1))
247 ->from('fct_customers')
248 ->whereColumn('fct_customers.email', 'fc_subscribers.email');
249 });
250 }
251
252 return $query->whereNotExists(function ($q) {
253 $q->select(fluentCrmDb()->raw(1))
254 ->from('fct_customers')
255 ->whereColumn('fct_customers.email', 'fc_subscribers.email');
256 });
257 }
258
259 if ($key === 'license_exist') {
260 if ($operator === 'exist') {
261 return $query->whereExists(function ($q) {
262 $q->select(fluentCrmDb()->raw(1))
263 ->from('fct_customers')
264 ->whereColumn('fct_customers.email', 'fc_subscribers.email')
265 ->join('fct_licenses', function ($join) {
266 $join->on('fct_licenses.customer_id', '=', 'fct_customers.id')
267 ->whereIn('fct_licenses.status', ['active', 'inactive']);
268 });
269 });
270 }
271
272 return $query->whereNotExists(function ($q) {
273 $q->select(fluentCrmDb()->raw(1))
274 ->from('fct_customers')
275 ->whereColumn('fct_customers.email', 'fc_subscribers.email')
276 ->join('fct_licenses', function ($join) {
277 $join->on('fct_licenses.customer_id', '=', 'fct_customers.id')
278 ->whereIn('fct_licenses.status', ['active', 'inactive']);
279 });
280 });
281 }
282
283 if ($value === '') {
284 return $query;
285 }
286
287 $customerProperties = ['ltv', 'aov'];
288
289 if (in_array($key, $customerProperties)) {
290 $value = \FluentCart\App\Helpers\Helper::toCent($value);
291 return $query->whereExists(function ($q) use ($key, $value, $operator) {
292 $q->select(fluentCrmDb()->raw(1))
293 ->from('fct_customers')
294 ->whereColumn('fct_customers.email', 'fc_subscribers.email')
295 ->where('fct_customers.' . $key, $operator, $value);
296 });
297 }
298
299 if ($key == 'first_purchase_date' || $key == 'last_purchase_date') {
300 $filter = Subscriber::filterParser($filter);
301 return $query->whereExists(function ($q) use ($filter, $key) {
302 $q->select(fluentCrmDb()->raw(1))
303 ->from('fct_customers')
304 ->whereColumn('fct_customers.email', 'fc_subscribers.email');
305 if ($filter['operator'] == 'BETWEEN') {
306 return $q->whereBetween('fct_customers.' . $key, $filter['value']);
307 } else {
308 return $q->where('fct_customers.' . $key, $filter['operator'], $filter['value']);
309 }
310 });
311 }
312
313 if ($key == 'last_payout_date') {
314 $filter = Subscriber::filterParser($filter);
315 return $query->whereExists(function ($q) use ($filter) {
316 $q->select(fluentCrmDb()->raw(1))
317 ->from('fa_affiliates')
318 ->whereColumn('fa_affiliates.user_id', 'fc_subscribers.user_id')
319 ->join('fa_payout_transactions', 'fa_payout_transactions.affiliate_id', '=', 'fa_affiliates.id');
320 if ($filter['operator'] == 'BETWEEN') {
321 return $q->whereBetween('fa_payout_transactions.created_at', $filter['value']);
322 }
323
324 return $q->where('fa_payout_transactions.created_at', $filter['operator'], $filter['value']);
325 });
326 }
327
328 if ($key === 'purchased_items' || $key === 'variation_purchased') {
329 if ($key === 'variation_purchased') {
330 $value = array_map(function ($item) {
331 $parts = explode('||', $item);
332 return isset($parts[1]) ? (int)$parts[1] : 0;
333 }, is_array($value) ? $value : [$value]);
334
335 $value = array_values(array_filter($value, 'is_numeric'));
336 }
337
338 $itemColumn = $key === 'purchased_items' ? 'post_id' : 'object_id';
339 $value = is_array($value) ? $value : [$value];
340
341 if ($operator === 'exist') {
342 return $query->whereExists(function ($q) use ($itemColumn, $value) {
343 $q->select(fluentCrmDb()->raw(1))
344 ->from('fct_customers')
345 ->whereColumn('fct_customers.email', 'fc_subscribers.email')
346 ->join('fct_orders', function ($join) {
347 $join->on('fct_orders.customer_id', '=', 'fct_customers.id')
348 ->whereIn('fct_orders.payment_status', Status::getOrderPaymentSuccessStatuses());
349 })
350 ->join('fct_order_items', 'fct_order_items.order_id', '=', 'fct_orders.id')
351 ->whereIn('fct_order_items.' . $itemColumn, $value);
352 });
353 }
354
355 return $query->whereNotExists(function ($q) use ($itemColumn, $value) {
356 $q->select(fluentCrmDb()->raw(1))
357 ->from('fct_customers')
358 ->whereColumn('fct_customers.email', 'fc_subscribers.email')
359 ->join('fct_orders', function ($join) {
360 $join->on('fct_orders.customer_id', '=', 'fct_customers.id')
361 ->whereIn('fct_orders.payment_status', Status::getOrderPaymentSuccessStatuses());
362 })
363 ->join('fct_order_items', 'fct_order_items.order_id', '=', 'fct_orders.id')
364 ->whereIn('fct_order_items.' . $itemColumn, $value);
365 });
366 }
367
368 if ($key === 'active_licenses' || $key === 'active_variation_licenses') {
369 if ($key === 'active_variation_licenses') {
370 $value = array_map(function ($item) {
371 $parts = explode('||', $item);
372 return isset($parts[1]) ? (int)$parts[1] : 0;
373 }, is_array($value) ? $value : [$value]);
374 $value = array_values(array_filter($value, 'is_numeric'));
375 }
376
377 $itemColumn = $key === 'active_licenses' ? 'product_id' : 'variation_id';
378 $value = is_array($value) ? $value : [$value];
379
380 if ($operator === 'exist') {
381 return $query->whereExists(function ($q) use ($itemColumn, $value) {
382 $q->select(fluentCrmDb()->raw(1))
383 ->from('fct_customers')
384 ->whereColumn('fct_customers.email', 'fc_subscribers.email')
385 ->join('fct_licenses', function ($join) {
386 $join->on('fct_licenses.customer_id', '=', 'fct_customers.id')
387 ->whereIn('fct_licenses.status', ['active', 'inactive']);
388 })
389 ->whereIn('fct_licenses.' . $itemColumn, $value);
390 });
391 }
392
393 return $query->whereNotExists(function ($q) use ($itemColumn, $value) {
394 $q->select(fluentCrmDb()->raw(1))
395 ->from('fct_customers')
396 ->whereColumn('fct_customers.email', 'fc_subscribers.email')
397 ->join('fct_licenses', function ($join) {
398 $join->on('fct_licenses.customer_id', '=', 'fct_customers.id')
399 ->whereIn('fct_licenses.status', ['active', 'inactive']);
400 })
401 ->whereIn('fct_licenses.' . $itemColumn, $value);
402 });
403 }
404
405 if ($key === 'expired_licenses' || $key === 'expired_variation_licenses') {
406 if ($key === 'expired_variation_licenses') {
407 $value = array_map(function ($item) {
408 $parts = explode('||', $item);
409 return isset($parts[1]) ? (int)$parts[1] : 0;
410 }, is_array($value) ? $value : [$value]);
411 $value = array_values(array_filter($value, 'is_numeric'));
412 }
413
414 $itemColumn = $key === 'expired_licenses' ? 'product_id' : 'variation_id';
415 $value = is_array($value) ? $value : [$value];
416
417 if ($operator === 'exist') {
418 return $query->whereExists(function ($q) use ($itemColumn, $value) {
419 $q->select(fluentCrmDb()->raw(1))
420 ->from('fct_customers')
421 ->whereColumn('fct_customers.email', 'fc_subscribers.email')
422 ->join('fct_licenses', function ($join) {
423 $join->on('fct_licenses.customer_id', '=', 'fct_customers.id');
424 })
425 ->where('fct_licenses.status', 'expired')
426 ->whereNotIn('fct_licenses.status', ['active', 'inactive'])
427 ->whereIn('fct_licenses.' . $itemColumn, $value);
428 });
429 }
430
431 return $query->whereNotExists(function ($q) use ($itemColumn, $value) {
432 $q->select(fluentCrmDb()->raw(1))
433 ->from('fct_customers')
434 ->whereColumn('fct_customers.email', 'fc_subscribers.email')
435 ->join('fct_licenses', function ($join) {
436 $join->on('fct_licenses.customer_id', '=', 'fct_customers.id');
437 })
438 ->where('fct_licenses.status', 'expired')
439 ->whereNotIn('fct_licenses.status', ['active', 'inactive'])
440 ->whereIn('fct_licenses.' . $itemColumn, $value);
441 });
442 }
443
444 return $query;
445 }
446
447 public function handleProductSelectorAjax($options, $searchTerm, $includedIds = [])
448 {
449 if (!$includedIds || !is_array($includedIds)) {
450 $includedIds = [];
451 }
452
453 $products = Product::query()->whereLike('post_title', $searchTerm)->limit(50)->get();
454
455 $formattedProducts = [];
456
457 $pushedIds = [];
458
459 foreach ($products as $product) {
460 $pushedIds[] = (string)$product->ID;
461 $formattedProducts[] = [
462 'id' => (string)$product->ID,
463 'title' => $product->post_title
464 ];
465 }
466
467 $leftoverIds = array_diff($includedIds, $pushedIds);
468
469 if ($leftoverIds) {
470 $leftoverProducts = Product::query()->whereIn('ID', $leftoverIds)->get();
471 foreach ($leftoverProducts as $product) {
472 $formattedProducts[] = [
473 'id' => (string)$product->ID,
474 'title' => $product->post_title
475 ];
476 }
477 }
478
479 return $formattedProducts;
480 }
481
482 public function handleProductVariationsAjax($response, $reqestData = [])
483 {
484 $prevValues = Arr::get($reqestData, 'values', []);
485 $search = Arr::get($reqestData, 'search', '');
486 $prevItemIds = [];
487
488 if ($prevValues) {
489 $prevValues = (array)$prevValues;
490 foreach ($prevValues as $prevValue) {
491 $prevItemIds[] = explode('||', $prevValue)[0];
492 }
493 $prevItemIds = array_values(array_unique($prevItemIds));
494 }
495
496 // get wc variable products
497 $variableProducts = Product::query()->whereLike('post_title', $search)
498 ->with(['variants'])
499 ->limit(50)
500 ->get();
501
502 $formattedProducts = [];
503
504 $includedProductIds = [];
505
506 foreach ($variableProducts as $product) {
507 $item = [
508 'value' => (string)$product->ID,
509 'label' => $product->post_title
510 ];
511
512 $formattedVariations = [];
513 foreach ($product->variants as $variant) {
514 $formattedVariations[] = [
515 'value' => $item['value'] . '||' . $variant->id,
516 'label' => $variant->variation_title
517 ];
518 }
519 $item['children'] = $formattedVariations;
520 $formattedProducts[] = $item;
521
522 $includedProductIds[] = $item['value'];
523 }
524
525 if ($prevItemIds) {
526 $includedProductIds = array_diff($prevItemIds, $includedProductIds);
527 if ($includedProductIds) {
528 $variableProducts = Product::query()->whereIn('ID', $includedProductIds)
529 ->with(['variants'])
530 ->get();
531
532 foreach ($variableProducts as $product) {
533 $item = [
534 'value' => (string)$product->id,
535 'label' => $product->post_title
536 ];
537
538 $variations = $product->get_children();
539 $formattedVariations = [];
540 foreach ($product->variants as $variant) {
541 $formattedVariations[] = [
542 'value' => $item['value'] . '||' . $variant->id,
543 'label' => $variant->variation_title
544 ];
545 }
546 $item['children'] = $formattedVariations;
547 $formattedProducts[] = $item;
548 }
549 }
550 }
551
552 $formattedProducts = array_filter($formattedProducts, function ($item) {
553 return !empty($item['children']) && count($item['children']) > 1;
554 });
555
556
557 return [
558 'options' => $formattedProducts,
559 'has_more' => true
560 ];
561 }
562
563 public function pushInfoWidgetToContact($widgets, $subscriber)
564 {
565 $userId = $subscriber->user_id;
566 $customer = Customer::query();
567 if ($userId) {
568 $customer = $customer->where('user_id', $userId)
569 ->orWhere('email', $subscriber->email);
570 } else {
571 $customer = $customer->where('email', $subscriber->email);
572 }
573
574 $customer = $customer->first();
575
576 if (!$customer) {
577 return $widgets;
578 }
579
580
581 $widgets['fluent_cart'] = [
582 'title' => __('Commerce Info', 'fluent-cart'),
583 'content' => $this->getStatsHtml($customer, [$this, 'formatDateForFluentCrm'])
584 ];
585
586 return $widgets;
587 }
588
589 /**
590 * The shared FluentCRM-context date formatter for every FluentCart date
591 * rendered inside a FluentCRM contact profile's Purchases page: the
592 * Commerce Info widget (via pushInfoWidgetToContact() below) AND the
593 * Purchase History tab's order table, Order Summary, and Purchased
594 * Products list, which the fluent-crm plugin renders in
595 * FluentCrm\App\Services\ExternalIntegrations\FluentCart\FluentCart and
596 * calls through this public method (cross-plugin, same as that class
597 * already importing FluentCart\App\Models\Customer).
598 *
599 * FluentCRM contacts can render every native date either as a relative
600 * difference ("2 hours ago") or, with its "classic" date/time preference
601 * enabled, as an absolute WordPress-formatted date -- both are
602 * FluentCRM's own display modes, produced by Helper::formatDateTime()
603 * itself. Product decision: on this FluentCRM-owned page, FluentCRM's
604 * preference always wins over FluentCart's own date format setting
605 * (DateFormatter / StoreSettings::date_time_format_source), in EITHER
606 * mode -- so both branches defer to Helper::formatDateTime() and neither
607 * falls through to DateFormatter for a reachable Helper.
608 *
609 * DateFormatter::format($datetime, false, wp_timezone()) remains only as
610 * the defensive fallback for when FluentCrm\App\Services\Helper can't be
611 * resolved at all (this plugin's own test suite doesn't load the
612 * FluentCRM plugin; see FluentCRMDeepIntegrationTest). Pinned to
613 * wp_timezone() so a store on FluentCart's 'fluent_cart' timezone source
614 * (no order context here) doesn't fall back to UTC.
615 *
616 * Scoped to this page: DateFormatter's own default behavior is unchanged
617 * everywhere else in FluentCart, including the FluentSupport customer
618 * widget, which renders this same getStatsHtml() markup but must NOT
619 * pick up FluentCRM's display preference -- see getStatsHtml()'s
620 * $dateFormatter parameter.
621 */
622 public function formatDateForFluentCrm($datetime): string
623 {
624 if (empty($datetime)) {
625 return '';
626 }
627
628 if (class_exists(Helper::class)) {
629 return Helper::formatDateTime($datetime);
630 }
631
632 return DateFormatter::format($datetime, false, wp_timezone());
633 }
634
635 /**
636 * @param \FluentCart\App\Models\Customer $customer
637 * @param callable|null $dateFormatter Formats a single GMT datetime string
638 * for display. Defaults to DateFormatter::format() unchanged, which
639 * is what FluentSupportWidget::getPurchaseWidgets() relies on when
640 * it calls this method directly -- FluentCRM's own date/time
641 * preference must stay opt-in via formatDateForFluentCrm(),
642 * passed explicitly by pushInfoWidgetToContact() below, or it would
643 * leak into the unrelated FluentSupport widget.
644 */
645 public function getStatsHtml($customer, ?callable $dateFormatter = null)
646 {
647 $dateFormatter = $dateFormatter ?: [DateFormatter::class, 'format'];
648
649 $viewUrl = URL::getDashboardUrl('customers/' . $customer->id . '/view');
650 $naLabel = __('N/A', 'fluent-cart');
651
652 // This widget's count/dates come from the customer's aggregate columns,
653 // which recountStat() only ever populates from orders with a
654 // payment-success status (see Customer::recountStat()). The FluentCRM
655 // "Purchase History" tab's own order table/summary, by contrast, lists
656 // every FluentCart order regardless of payment status. Those two counts
657 // can legitimately disagree (e.g. a customer with only pending orders),
658 // so the labels here are explicit about being paid-only rather than
659 // reusing the ambiguous "Purchases"/"First Order"/"Last Order" captions.
660 $hasPaidPurchases = (int) $customer->purchase_count > 0;
661
662 // Compact, consistent labels (no trailing colons) so they read well as
663 // stat-card captions on the FluentCRM contact profile.
664 $stats = [
665 [
666 'label' => __('Lifetime Value', 'fluent-cart'),
667 // toDecimal() returns the amount prefixed with an HTML-entity
668 // currency sign (e.g. &#36;), so it is rendered raw here; the
669 // value is a controlled currency string, never user input.
670 'value' => '<a href="' . esc_url($viewUrl) . '" target="_blank" rel="noopener" class="fc_view_more">' . \FluentCart\App\Helpers\Helper::toDecimal($customer->ltv) . '</a>'
671 ],
672 [
673 'label' => __('Paid Purchases', 'fluent-cart'),
674 'value' => esc_html($customer->purchase_count)
675 ],
676 [
677 'label' => __('First Paid Order', 'fluent-cart'),
678 // Guarded on purchase_count, not just the date column: a
679 // customer whose only paid order was later refunded/canceled
680 // can keep a stale first/last_purchase_date until the next
681 // recountStat() run, and this must read N/A regardless.
682 'value' => ($hasPaidPurchases && $customer->first_purchase_date) ? esc_html($dateFormatter($customer->first_purchase_date)) : esc_html($naLabel)
683 ],
684 [
685 'label' => __('Last Paid Order', 'fluent-cart'),
686 'value' => ($hasPaidPurchases && $customer->last_purchase_date) ? esc_html($dateFormatter($customer->last_purchase_date)) : esc_html($naLabel)
687 ],
688 ];
689
690 // FluentCRM styles this markup via .fcrm_fluentcart_customer_commerce_info
691 $html = '<div class="fcrm_fluentcart_customer_commerce_info">';
692
693 $html .= '<ul class="fcrm_fc_stat_grid">';
694 foreach ($stats as $stat) {
695 $html .= '<li class="fcrm_fc_stat">'
696 . '<span class="fcrm_fc_stat_label">' . $stat['label'] . '</span>'
697 . '<span class="fcrm_fc_stat_value">' . $stat['value'] . '</span>'
698 . '</li>';
699 }
700 $html .= '</ul>';
701
702 // Aggregated in SQL and capped to the most recently purchased products,
703 // rather than pulling every paid order item the customer has ever had
704 // into PHP: a long-time customer's full order-item history can run into
705 // the thousands, and this widget only ever shows a short "Recent
706 // purchases" list. purchase_count/earliest/latest item ids are grouped
707 // per product server-side; only the (at most $recentProductsLimit)
708 // earliest-occurrence rows are then fetched to supply title/date/link.
709 $recentProductsLimit = 20;
710
711 // selectRaw bypasses the query grammar's table-prefixing, so the real
712 // (prefixed) items table name is required here -- a bare
713 // "fct_order_items" throws "Unknown column" once WordPress's table
714 // prefix isn't literally "fct_" (every wp-browser test run, and any
715 // site sharing tables with another install).
716 $itemsTable = App::db()->getTableName('fct_order_items');
717
718 $productAggregates = $customer->success_order_items()
719 ->selectRaw($itemsTable . '.object_id, COUNT(*) as purchase_count, MIN(' . $itemsTable . '.id) as earliest_item_id, MAX(' . $itemsTable . '.id) as latest_item_id')
720 // groupBy/orderBy go through the query grammar, which prefixes
721 // bare column names itself -- unlike selectRaw above, so this one
722 // must stay unqualified (and unambiguous: only the items table has
723 // an object_id column).
724 ->groupBy('object_id')
725 ->orderBy('latest_item_id', 'DESC')
726 ->limit($recentProductsLimit)
727 ->get();
728
729 $earliestItemIds = array_values(array_filter($productAggregates->pluck('earliest_item_id')->all()));
730
731 $earliestItemsById = [];
732 if ($earliestItemIds) {
733 foreach (OrderItem::query()->whereIn('id', $earliestItemIds)->get() as $earliestItem) {
734 $earliestItemsById[$earliestItem->id] = $earliestItem;
735 }
736 }
737
738 // Group identical products and count repeat purchases
739 $formattedItems = [];
740 foreach ($productAggregates as $productAggregate) {
741 $earliestItem = $earliestItemsById[$productAggregate->earliest_item_id] ?? null;
742 if (!$earliestItem) {
743 continue;
744 }
745
746 $formattedItems[$productAggregate->object_id] = [
747 'title' => $earliestItem->title,
748 'post_title' => $earliestItem->post_title,
749 'count' => (int) $productAggregate->purchase_count,
750 // earliest_item_id is the oldest item id per product: the
751 // customer's first order for it. Both the shown date and the
752 // link point at that order.
753 'created_at' => $earliestItem->created_at,
754 'order_id' => $earliestItem->order_id
755 ];
756 }
757
758 if ($formattedItems) {
759 $html .= '<div class="fcrm_fc_products">';
760 $html .= '<div class="fcrm_fc_products_title">' . esc_html__('Recent purchases', 'fluent-cart') . '</div>';
761 $html .= '<ul class="fcrm_fc_product_list">';
762
763 foreach ($formattedItems as $formattedItem) {
764 $qtyHtml = '';
765 if ($formattedItem['count'] > 1) {
766 $qtyHtml = '<span class="fcrm_fc_product_qty">' . esc_html($formattedItem['count']) . '</span>';
767 }
768
769 // Only show the variant when it differs from the product name,
770 // so single-variant products don't read as "Name - Name"
771 $variantHtml = '';
772 if ($formattedItem['title'] && $formattedItem['title'] !== $formattedItem['post_title']) {
773 $variantHtml = '<span class="fcrm_fc_product_variant">' . esc_html($formattedItem['title']) . '</span>';
774 }
775
776 // Link the date straight to the (first) order for this product
777 $dateText = esc_html($dateFormatter($formattedItem['created_at']));
778 if (!empty($formattedItem['order_id'])) {
779 $orderUrl = URL::getDashboardUrl('orders/' . $formattedItem['order_id'] . '/view');
780 $dateHtml = '<a class="fcrm_fc_product_date" href="' . esc_url($orderUrl) . '" target="_blank" rel="noopener">' . $dateText . '</a>';
781 } else {
782 $dateHtml = '<span class="fcrm_fc_product_date">' . $dateText . '</span>';
783 }
784
785 $html .= '<li class="fcrm_fc_product">'
786 . '<span class="fcrm_fc_product_main">'
787 . '<span class="fcrm_fc_product_name">' . esc_html($formattedItem['post_title']) . '</span>'
788 . $variantHtml
789 . '</span>'
790 . '<span class="fcrm_fc_product_meta">'
791 . $qtyHtml
792 . $dateHtml
793 . '</span>'
794 . '</li>';
795 }
796
797 $html .= '</ul></div>';
798 }
799
800 $html .= '</div>';
801
802 return $html;
803 }
804
805 }
806