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 +190 -26 1.3.20 → 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,65 +579,226 @@
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 +
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.
588 664 $stats = [
589 665 [
590 - 'title' => __('Lifetime Value', 'fluent-cart'),
591 - 'value' => '<a href="' . URL::getDashboardUrl('customers/' . $customer->id . '/view') . '" target="_blank" class="fc_view_more">' . \FluentCart\App\Helpers\Helper::toDecimal($customer->ltv) . '</a>'
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>'
592 671 ],
593 672 [
594 - 'title' => __('Purchases', 'fluent-cart'),
595 - 'value' => $customer->purchase_count
673 + 'label' => __('Paid Purchases', 'fluent-cart'),
674 + 'value' => esc_html($customer->purchase_count)
596 675 ],
597 676 [
598 - 'title' => __('First Purchased: ', 'fluent-cart'),
599 - 'value' => $customer->first_purchase_date ? gmdate('F j, Y', strtotime($customer->first_purchase_date)) : 'N/A'
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)
600 683 ],
601 684 [
602 - 'title' => __('Last Purchased: ', 'fluent-cart'),
603 - 'value' => $customer->last_purchase_date ? gmdate('F j, Y', strtotime($customer->last_purchase_date)) : 'N/A'
685 + 'label' => __('Last Paid Order', 'fluent-cart'),
686 + 'value' => ($hasPaidPurchases && $customer->last_purchase_date) ? esc_html($dateFormatter($customer->last_purchase_date)) : esc_html($naLabel)
604 687 ],
605 688 ];
606 689
607 - $html = '<ul class="fc_full_listed fcrm_fluentcart_customer_commerce_info">';
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">';
608 694 foreach ($stats as $stat) {
609 - $html .= '<li><span class="fc_list_sub">' . $stat['title'] . '</span> <span class="fc_list_value">' . $stat['value'] . '</span></li>';
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>';
610 699 }
700 + $html .= '</ul>';
611 701
612 - $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;
613 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
614 739 $formattedItems = [];
740 + foreach ($productAggregates as $productAggregate) {
741 + $earliestItem = $earliestItemsById[$productAggregate->earliest_item_id] ?? null;
742 + if (!$earliestItem) {
743 + continue;
744 + }
615 745
616 - foreach ($orderedItems as $orderedItem) {
617 - $count = isset($formattedItems[$orderedItem->object_id]) ? $formattedItems[$orderedItem->object_id]['count'] + 1 : 1;
618 - $formattedItems[$orderedItem->object_id] = [
619 - 'title' => $orderedItem->title,
620 - 'post_title' => $orderedItem->post_title,
621 - 'count' => $count,
622 - 'created_at' => $orderedItem->created_at
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
623 755 ];
624 756 }
625 757
626 - foreach ($formattedItems as $formattedItem) {
627 - $countHtml = '';
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">';
628 762
629 - if ($formattedItem['count'] > 1) {
630 - $countHtml = ' <span style="background: #673AB7;color: #fffef1;font-size: 10px;padding: 3px 5px;border-radius: 50%;display: inline-block;line-height: 100%;">' . $formattedItem['count'] . '</span> ';
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>';
631 795 }
632 796
633 - $html .= '<li>' . $countHtml . $formattedItem['post_title'] . ' - <span style="font-style: italic;">' . $formattedItem['title'] . '</span> (' . gmdate('F j, Y', strtotime($formattedItem['created_at'])) . ')</li>';
797 + $html .= '</ul></div>';
634 798 }
635 799
636 - $html .= '</ul>';
800 + $html .= '</div>';
637 801
638 802 return $html;
639 803 }
640 804