← 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 { |