← All changes
|
app/Modules/Integrations/FluentPlugins/FluentCRMDeepIntegration.php
+190
-26
1.3.21
→
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. $), 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 | |