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.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 trunk All 48 releases
← All changes | app/Modules/Integrations/FluentPlugins/FluentCRMDeepIntegration.php +133 -20 1.6.4 → 1.6.5 View file →
@@ -3,11 +3,14 @@
3 3 namespace FluentCart\App\Modules\Integrations\FluentPlugins;
4 4
5 5 use FluentAffiliate\App\Models\Affiliate;
6 6 use FluentCart\Api\ModuleSettings;
7 +use FluentCart\App\App;
7 8 use FluentCart\App\Helpers\Status;
8 9 use FluentCart\App\Models\Customer;
10 +use FluentCart\App\Models\OrderItem;
9 11 use FluentCart\App\Models\Product;
12 +use FluentCart\App\Services\DateTime\DateFormatter;
10 13 use FluentCart\App\Services\URL;
11 14 use FluentCrm\App\Models\Subscriber;
12 15 use FluentCrm\App\Services\Helper;
13 16 use FluentCrm\App\Services\Libs\ConditionAssessor;
@@ -576,19 +579,87 @@
576 579
577 580
578 581 $widgets['fluent_cart'] = [
579 582 'title' => __('Commerce Info', 'fluent-cart'),
580 - 'content' => $this->getStatsHtml($customer)
583 + 'content' => $this->getStatsHtml($customer, [$this, 'formatDateForFluentCrm'])
581 584 ];
582 585
583 586 return $widgets;
584 587 }
585 588
586 - public function getStatsHtml($customer)
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
587 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 +
588 649 $viewUrl = URL::getDashboardUrl('customers/' . $customer->id . '/view');
589 650 $naLabel = __('N/A', 'fluent-cart');
590 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 +
591 662 // Compact, consistent labels (no trailing colons) so they read well as
592 663 // stat-card captions on the FluentCRM contact profile.
593 664 $stats = [
594 665 [
@@ -598,18 +669,22 @@
598 669 // value is a controlled currency string, never user input.
599 670 'value' => '<a href="' . esc_url($viewUrl) . '" target="_blank" rel="noopener" class="fc_view_more">' . \FluentCart\App\Helpers\Helper::toDecimal($customer->ltv) . '</a>'
600 671 ],
601 672 [
602 - 'label' => __('Purchases', 'fluent-cart'),
673 + 'label' => __('Paid Purchases', 'fluent-cart'),
603 674 'value' => esc_html($customer->purchase_count)
604 675 ],
605 676 [
606 - 'label' => __('First Order', 'fluent-cart'),
607 - 'value' => $customer->first_purchase_date ? esc_html(gmdate('M j, Y', strtotime($customer->first_purchase_date))) : esc_html($naLabel)
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)
608 683 ],
609 684 [
610 - 'label' => __('Last Order', 'fluent-cart'),
611 - 'value' => $customer->last_purchase_date ? esc_html(gmdate('M j, Y', strtotime($customer->last_purchase_date))) : esc_html($naLabel)
685 + 'label' => __('Last Paid Order', 'fluent-cart'),
686 + 'value' => ($hasPaidPurchases && $customer->last_purchase_date) ? esc_html($dateFormatter($customer->last_purchase_date)) : esc_html($naLabel)
612 687 ],
613 688 ];
614 689
615 690 // FluentCRM styles this markup via .fcrm_fluentcart_customer_commerce_info
@@ -623,23 +698,61 @@
623 698 . '</li>';
624 699 }
625 700 $html .= '</ul>';
626 701
627 - $orderedItems = $customer->success_order_items()->orderBy('id', 'DESC')->get();
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;
628 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 +
629 738 // Group identical products and count repeat purchases
630 739 $formattedItems = [];
631 - foreach ($orderedItems as $orderedItem) {
632 - $count = isset($formattedItems[$orderedItem->object_id]) ? $formattedItems[$orderedItem->object_id]['count'] + 1 : 1;
633 - $formattedItems[$orderedItem->object_id] = [
634 - 'title' => $orderedItem->title,
635 - 'post_title' => $orderedItem->post_title,
636 - 'count' => $count,
637 - // Items are iterated newest-first, so the last write for a
638 - // product keeps its oldest row: the customer's first order for
639 - // it. Both the shown date and the link point at that order.
640 - 'created_at' => $orderedItem->created_at,
641 - 'order_id' => $orderedItem->order_id
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
642 755 ];
643 756 }
644 757
645 758 if ($formattedItems) {
@@ -660,9 +773,9 @@
660 773 $variantHtml = '<span class="fcrm_fc_product_variant">' . esc_html($formattedItem['title']) . '</span>';
661 774 }
662 775
663 776 // Link the date straight to the (first) order for this product
664 - $dateText = esc_html(gmdate('M j, Y', strtotime($formattedItem['created_at'])));
777 + $dateText = esc_html($dateFormatter($formattedItem['created_at']));
665 778 if (!empty($formattedItem['order_id'])) {
666 779 $orderUrl = URL::getDashboardUrl('orders/' . $formattedItem['order_id'] . '/view');
667 780 $dateHtml = '<a class="fcrm_fc_product_date" href="' . esc_url($orderUrl) . '" target="_blank" rel="noopener">' . $dateText . '</a>';
668 781 } else {