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/Services/Renderer/ModalCheckoutRenderer.php +220 -12 1.3.21 → 1.6.5 View file →
@@ -39,8 +39,16 @@
39 39 private $cartSummaryRenderer;
40 40
41 41 private $customer;
42 42
43 + /**
44 + * Memoised result of the before_payment_methods placement filter. Null until
45 + * resolved; see beforePaymentMethodsPlacement() for why it is asked only once.
46 + *
47 + * @var string|null
48 + */
49 + private $paymentMethodsHookPlacement = null;
50 +
43 51 public function __construct(Cart $cart, $config = [])
44 52 {
45 53 $this->cart = $cart;
46 54 $this->config = $config;
@@ -66,9 +74,10 @@
66 74 public function getFragment($fragmentName)
67 75 {
68 76 $maps = [
69 77 'payment_methods' => 'renderPaymentMethods',
70 - 'summary_group' => 'renderSummaryGroup'
78 + 'summary_group' => 'renderSummaryGroup',
79 + 'checkout_summary' => 'renderCheckoutSummary'
71 80 ];
72 81
73 82 if(isset($maps[$fragmentName])) {
74 83 ob_start();
@@ -110,8 +119,28 @@
110 119 }
111 120
112 121 public function renderForm()
113 122 {
123 + // Without a billing country in checkout_data, the tax recalc below
124 + // produces no per-item tax. Pre-fill from the current customer's
125 + // primary billing address (if any) so the per-item tax badge can
126 + // render on the very first modal open instead of only after the
127 + // customer interacts with the address form.
128 + $this->maybePopulateFormDataFromCustomer();
129 +
130 + // Trigger the recalc only when the cart hasn't been taxed yet. Once
131 + // line_meta.tax_config is on the item, the badge already renders from
132 + // existing data — another recalc here would just duplicate work
133 + // (Tax + Shipping each call $cart->save() on this action).
134 + // Payload shape matches Cart::addItem() so every handler receives the
135 + // data it expects.
136 + if (empty(Arr::get($this->cart->cart_data, '0.line_meta.tax_config'))) {
137 + do_action('fluent_cart/cart/cart_data_items_updated', [
138 + 'cart' => $this->cart,
139 + 'scope' => 'modal_open',
140 + 'scope_data' => null,
141 + ]);
142 + }
114 143 ?>
115 144 <div class="fct-modal-checkout-form-wrapper" data-fluent-cart-checkout-page>
116 145 <form
117 146 class="fct-modal-checkout-form"
@@ -130,13 +159,81 @@
130 159 <?php
131 160 }
132 161
133 162
134 - public function renderCheckoutDetails()
163 + private function maybePopulateFormDataFromCustomer()
135 164 {
165 + if (!$this->customer) {
166 + return;
167 + }
168 +
169 + $checkoutData = is_array($this->cart->checkout_data) ? $this->cart->checkout_data : [];
170 + $formData = Arr::get($checkoutData, 'form_data', []);
171 +
172 + // Never clobber an answer the customer has actively chosen; only
173 + // pre-fill when the cart has no billing country yet.
174 + if (!empty(Arr::get($formData, 'billing_country'))) {
175 + return;
176 + }
177 +
178 + $primaryBilling = $this->customer->primary_billing_address;
179 + if (!$primaryBilling) {
180 + return;
181 + }
182 +
183 + foreach ($primaryBilling->getFormattedDataForCheckout('billing_') as $key => $value) {
184 + if (!isset($formData[$key]) || $formData[$key] === '' || $formData[$key] === null) {
185 + $formData[$key] = $value;
186 + }
187 + }
188 +
189 + $checkoutData['form_data'] = $formData;
190 + $this->cart->checkout_data = $checkoutData;
191 + // No explicit save — the recalc action that follows persists this
192 + // together with the newly computed tax data, avoiding a double write.
193 + }
194 +
195 + /**
196 + * Where `fluent_cart/before_payment_methods` fires in the modal.
197 + *
198 + * 'payment' (default) wraps the payment-method list, matching CheckoutRenderer.
199 + * 'details' restores the pre-fix position in the address/shipping pane, for a
200 + * store whose add-on positioned itself around that spot.
201 + *
202 + * Resolved ONCE per renderer and memoised. The two call sites are mutually
203 + * exclusive, so the hook must fire exactly once — but that only holds if both
204 + * sites agree, and a filter is free to be stateful or context-sensitive. Asking
205 + * it twice would let it answer differently and either fire the hook twice or
206 + * swallow it entirely. Ask once, remember the answer.
207 + *
208 + * @return string 'payment'|'details'
209 + */
210 + private function beforePaymentMethodsPlacement(): string
211 + {
212 + if ($this->paymentMethodsHookPlacement !== null) {
213 + return $this->paymentMethodsHookPlacement;
214 + }
215 +
216 + $placement = apply_filters('fluent_cart/modal_checkout/before_payment_methods_placement', 'payment', [
217 + 'cart' => $this->cart,
218 + ]);
219 +
220 + // Anything unrecognised falls back to the correct placement rather than
221 + // silently dropping the hook.
222 + $this->paymentMethodsHookPlacement = $placement === 'details' ? 'details' : 'payment';
223 +
224 + return $this->paymentMethodsHookPlacement;
225 + }
226 +
227 + public function renderCheckoutDetails()
228 + {
136 229 ?>
137 230 <div class="fct-modal-checkout-details">
138 - <?php $this->renderCheckoutSummary();?>
231 + <!-- Stable swap target: tax recalculation re-renders the item card
232 + here via the checkout_summary fragment. -->
233 + <div data-fct-modal-checkout-summary>
234 + <?php $this->renderCheckoutSummary(); ?>
235 + </div>
139 236
140 237 <?php $this->checkoutRenderer->renderAddressFields(); ?>
141 238
142 239 <div class="fct_checkout_shipping_methods <?php echo $this->requireShipping ? '' : 'is-hidden' ?>">
@@ -142,10 +239,28 @@
142 239 <div class="fct_checkout_shipping_methods <?php echo $this->requireShipping ? '' : 'is-hidden' ?>">
143 240 <?php $this->checkoutRenderer->renderShippingOptions(); ?>
144 241 </div>
145 242
146 - <?php do_action('fluent_cart/before_payment_methods', ['cart' => $this->cart]); ?>
243 + <?php
244 + /**
245 + * The position `fluent_cart/before_payment_methods` used to
246 + * occupy in the modal. Given its own name so an add-on that
247 + * genuinely wants this pane — under shipping, above the terms
248 + * — has a stable place to render, instead of relying on a hook
249 + * whose name promises it sits before the payment methods.
250 + *
251 + * @param array $data ['cart' => Cart]
252 + */
253 + do_action('fluent_cart/modal_checkout/after_shipping_methods', ['cart' => $this->cart]);
147 254
255 + // Opt-in restoration of the old placement, for a store whose
256 + // add-on positioned itself around the previous spot. Mutually
257 + // exclusive with the call site in renderCheckoutBilling().
258 + if ($this->beforePaymentMethodsPlacement() === 'details') {
259 + do_action('fluent_cart/before_payment_methods', ['cart' => $this->cart]);
260 + }
261 + ?>
262 +
148 263 <?php $this->checkoutRenderer->agreeTerms(); ?>
149 264
150 265 <?php $this->renderSummaryGroup(); ?>
151 266
@@ -157,11 +272,24 @@
157 272 public function renderCheckoutSummary()
158 273 {
159 274 $title = Arr::get($this->cart->cart_data, '0.title', '');
160 275 $postTitle = Arr::get($this->cart->cart_data, '0.post_title', '');
161 - $subTotal = Helper::toDecimal(Arr::get($this->cart->cart_data, '0.subtotal', 0));
276 + $subTotal = Helper::toDecimal(Arr::get($this->cart->cart_data, '0.subtotal', 0));
277 + $couponDiscount = (int) Arr::get($this->cart->cart_data, '0.coupon_discount', 0);
278 + $subtotalRaw = (int) Arr::get($this->cart->cart_data, '0.subtotal', 0);
279 + $lineTotalRaw = (int) Arr::get($this->cart->cart_data, '0.line_total', 0);
280 + $hasCouponDiscount = $couponDiscount > 0 && $lineTotalRaw < $subtotalRaw;
162 281 $media = Arr::get($this->cart->cart_data, '0.featured_media', '');
163 282
283 + // Mirror CartItemRenderer's event payload so the shared line-item hooks
284 + // (e.g. the per-item tax breakdown) also fire in modal checkout.
285 + $lineItemEventInfo = [
286 + 'item' => Arr::get($this->cart->cart_data, '0', []),
287 + 'cart' => $this->cart,
288 + 'product' => null,
289 + 'variant' => null,
290 + ];
291 +
164 292 ?>
165 293 <div class="fct-modal-checkout-summary">
166 294 <div class="fct-modal-cs-img">
167 295 <img src="<?php echo esc_url($media);?>" alt="<?php echo esc_attr($postTitle);?>">
@@ -177,13 +305,27 @@
177 305 - <?php echo esc_html($title);?>
178 306 </h3>
179 307 </div>
180 308
181 - <span class="fct-modal-cs-line-price">
182 - <?php echo $subTotal;?>
183 - </span>
309 + <?php if ($hasCouponDiscount) : ?>
310 + <div class="fct-modal-cs-price-wrapper">
311 + <span class="fct-modal-cs-line-price fct-modal-cs-line-price--original" aria-label="<?php esc_attr_e('Original price', 'fluent-cart'); ?>">
312 + <?php echo esc_html(Helper::toDecimal($subtotalRaw)); ?>
313 + </span>
314 + <span class="fct-modal-cs-line-price fct-modal-cs-line-price--discounted" aria-label="<?php esc_attr_e('Discounted price', 'fluent-cart'); ?>">
315 + <?php echo esc_html(Helper::toDecimal($lineTotalRaw)); ?>
316 + </span>
317 + </div>
318 + <?php else : ?>
319 + <span class="fct-modal-cs-line-price">
320 + <?php echo esc_html($subTotal); ?>
321 + </span>
322 + <?php endif; ?>
323 + <?php do_action('fluent_cart/cart/line_item/after_total', $lineItemEventInfo); ?>
184 324 </div>
185 325
326 + <?php do_action('fluent_cart/cart/line_item/footer_start', $lineItemEventInfo); ?>
327 +
186 328 <?php $this->renderPaymentTypeInfo(); ?>
187 329
188 330 <?php if($this->matchedVariation) :?>
189 331 <div class="fct-modal-cs-license">
@@ -211,9 +353,8 @@
211 353 $otherInfo = Arr::get($this->cart->cart_data, '0.other_info', []);
212 354 $paymentType = Arr::get($otherInfo, 'payment_type', '');
213 355 $itemPrice = Arr::get($this->cart->cart_data, '0.unit_price', 0);
214 356
215 -
216 357 if ($paymentType === 'subscription') {
217 358 $subscriptionInfo = Helper::generateSubscriptionInfo($otherInfo, $itemPrice);
218 359 $setupFeeInfo = Helper::generateSetupFeeInfo($otherInfo);
219 360 $trialInfo = Helper::generateTrialInfo($otherInfo);
@@ -239,9 +380,31 @@
239 380 </span>
240 381 <?php endif; ?>
241 382 </div>
242 383 <?php
384 + return;
243 385 }
386 +
387 + $quantity = (int) Arr::get($this->cart->cart_data, '0.quantity', 1);
388 + if ($quantity < 2) {
389 + return;
390 + }
391 +
392 + $lineItemEventInfo = [
393 + 'item' => Arr::get($this->cart->cart_data, '0', []),
394 + 'cart' => $this->cart,
395 + 'product' => null,
396 + 'variant' => null,
397 + ];
398 + ?>
399 + <div class="fct-modal-cs-payment-info">
400 + <?php
401 + /* translators: %1$s: formatted unit price */
402 + printf(esc_html__('%1$s each', 'fluent-cart'), esc_html(Helper::toDecimal($itemPrice)));
403 + ?>
404 + <?php do_action('fluent_cart/cart/line_item/unit_price_hint', $lineItemEventInfo); ?>
405 + </div>
406 + <?php
244 407 }
245 408
246 409 public function renderPromoCode() {
247 410 ?>
@@ -459,12 +622,35 @@
459 622 <?php echo esc_html__('Billing information', 'fluent-cart');?>
460 623 </h4>
461 624 </header>
462 625
626 + <?php
627 + /**
628 + * Fired here, wrapping the payment methods, exactly as
629 + * CheckoutRenderer does. It used to fire from
630 + * renderCheckoutDetails() — the address/shipping pane — which
631 + * is a different part of the modal entirely, so anything
632 + * hooked to it (the saved-payment-method picker, Turnstile)
633 + * rendered detached from the methods it belongs to. A hook
634 + * named "before payment methods" has to fire before the
635 + * payment methods.
636 + */
637 + if ($this->beforePaymentMethodsPlacement() === 'payment') {
638 + do_action('fluent_cart/before_payment_methods', ['cart' => $this->cart]);
639 + }
640 + ?>
641 +
463 642 <div class="fct_checkout_payment_methods" data-fluent-cart-checkout-payment-methods>
464 643 <?php $this->renderPaymentMethods(); ?>
465 644 </div>
466 645
646 + <?php
647 + // The modal never fired this at all, so add-ons rendering
648 + // below the methods (the save-my-card consent box) were
649 + // simply absent from quick checkout.
650 + do_action('fluent_cart/after_payment_methods', ['cart' => $this->cart]);
651 + ?>
652 +
467 653 <div class="fct-modal-checkout-btn-wrap">
468 654 <?php (new CheckoutRenderer($this->cart))->renderCheckoutButton(); ?>
469 655 </div>
470 656 </div>
@@ -483,8 +669,9 @@
483 669 }
484 670
485 671 $selectedPaymentMethod = Arr::get($this->cart->checkout_data, 'form_data._fct_pay_method', '');
486 672 $activePaymentMethods = PaymentMethods::getActiveMethodInstance($this->cart);
673 + $hadActiveMethods = !empty($activePaymentMethods);
487 674
488 675 $activePaymentMethods = apply_filters('fluent_cart/checkout_active_payment_methods', $activePaymentMethods, [
489 676 'cart' => $this->cart
490 677 ]);
@@ -497,11 +684,28 @@
497 684 });
498 685 }
499 686
500 687 if (!$selectedPaymentMethod && !empty($activePaymentMethods)) {
501 - $selectedPaymentMethod = $activePaymentMethods[0] ? $activePaymentMethods[0]->getMeta('route') : '';
688 + // reset() not [0] — array_filter above preserves keys, so key 0 may be gone.
689 + $firstMethod = reset($activePaymentMethods);
690 + $selectedPaymentMethod = $firstMethod ? $firstMethod->getMeta('route') : '';
502 691 }
503 692
693 + /**
694 + * The payment method rendered as checked. An add-on that renders its own
695 + * selector outside this list (e.g. saved payment methods) returns a route
696 + * that is not in the list, so no gateway here is checked — the page then
697 + * arrives with the add-on's choice already selected instead of a gateway
698 + * being checked first and switched over by JS after load.
699 + *
700 + * @param string $selectedPaymentMethod
701 + * @param array $context ['cart' => Cart, 'payment_methods' => array]
702 + */
703 + $selectedPaymentMethod = (string) apply_filters('fluent_cart/checkout/selected_payment_method', $selectedPaymentMethod, [
704 + 'cart' => $this->cart,
705 + 'payment_methods' => $activePaymentMethods
706 + ]);
707 +
504 708 $checkoutMethodStyle = $this->storeSettings->get('checkout_method_style', 'logo');
505 709
506 710 ?>
507 711 <div id="fluent_payment_methods" class="fct_modal_payment_methods fluent_payment_methods">
@@ -519,9 +723,13 @@
519 723 ]);
520 724 } ?>
521 725 <?php else: ?>
522 726 <?php
523 - $emptyText = esc_html__('No Payment method is activated for this site yet.', 'fluent-cart');
727 + if ($hadActiveMethods) {
728 + $emptyText = esc_html__('None of the available payment methods can process this order. Please contact the store.', 'fluent-cart');
729 + } else {
730 + $emptyText = esc_html__('No Payment method is activated for this site yet.', 'fluent-cart');
731 + }
524 732 if (current_user_can('manage_options')) {
525 733 $emptyText .= '<a href="' . esc_url(URL::getDashboardUrl('settings/payments')) . '" target="_blank">' . esc_html__('Activate from settings.', 'fluent-cart') . '</a>';
526 734 }
527 735 echo '<div class="fct-empty-state">' . wp_kses_post($emptyText) . '</div>';
@@ -608,9 +816,9 @@
608 816 'route' => $route,
609 817 'method_title' => $methodTitle,
610 818 'method_style' => $methodStyle,
611 819 ];
612 - $paymentMethodClass = apply_filters_deprecated('fluent_cart_payment_method_list_class', ['', $pmContext], '1.3.16', 'fluent_cart/payment_method_list_class', 'Use fluent_cart/payment_method_list_class instead of fluent_cart_payment_method_list_class.');
820 + $paymentMethodClass = '';
613 821 $paymentMethodClass = apply_filters('fluent_cart/payment_method_list_class', $paymentMethodClass, $pmContext);
614 822
615 823 ?>
616 824 <div class="fluent-cart-checkout_embed_payment_wrapper">