| @@ -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"> |