| @@ -3,19 +3,12 @@ | ||
| 3 | 3 | namespace FluentCart\App\Services\Renderer; |
| 4 | 4 | |
| 5 | 5 | use FluentCart\Api\PaymentMethods; |
| 6 | 6 | use FluentCart\Api\Resource\CustomerResource; |
| 7 | -use FluentCart\Api\Resource\FrontendResource\CustomerAddressResource; | |
| 8 | 7 | use FluentCart\Api\StoreSettings; |
| 9 | -use FluentCart\App\App; | |
| 10 | -use FluentCart\App\Helpers\AddressHelper; | |
| 11 | -use FluentCart\App\Helpers\CartHelper; | |
| 12 | 8 | use FluentCart\App\Helpers\Helper; |
| 13 | 9 | use FluentCart\App\Models\Cart; |
| 14 | -use FluentCart\App\Models\ProductVariation; | |
| 15 | -use FluentCart\App\Services\Localization\LocalizationManager; | |
| 16 | 10 | use FluentCart\App\Services\URL; |
| 17 | -use FluentCart\App\Vite; | |
| 18 | 11 | use FluentCart\Framework\Support\Arr; |
| 19 | 12 | use FluentCart\App\Models\ProductMeta; |
| 20 | 13 | |
| 21 | 14 | class ModalCheckoutRenderer |
| @@ -39,8 +32,16 @@ | ||
| 39 | 32 | private $cartSummaryRenderer; |
| 40 | 33 | |
| 41 | 34 | private $customer; |
| 42 | 35 | |
| 36 | + /** | |
| 37 | + * Memoised result of the before_payment_methods placement filter. Null until | |
| 38 | + * resolved; see beforePaymentMethodsPlacement() for why it is asked only once. | |
| 39 | + * | |
| 40 | + * @var string|null | |
| 41 | + */ | |
| 42 | + private $paymentMethodsHookPlacement = null; | |
| 43 | + | |
| 43 | 44 | public function __construct(Cart $cart, $config = []) |
| 44 | 45 | { |
| 45 | 46 | $this->cart = $cart; |
| 46 | 47 | $this->config = $config; |
| @@ -183,8 +184,40 @@ | ||
| 183 | 184 | // No explicit save — the recalc action that follows persists this |
| 184 | 185 | // together with the newly computed tax data, avoiding a double write. |
| 185 | 186 | } |
| 186 | 187 | |
| 188 | + /** | |
| 189 | + * Where `fluent_cart/before_payment_methods` fires in the modal. | |
| 190 | + * | |
| 191 | + * 'payment' (default) wraps the payment-method list, matching CheckoutRenderer. | |
| 192 | + * 'details' restores the pre-fix position in the address/shipping pane, for a | |
| 193 | + * store whose add-on positioned itself around that spot. | |
| 194 | + * | |
| 195 | + * Resolved ONCE per renderer and memoised. The two call sites are mutually | |
| 196 | + * exclusive, so the hook must fire exactly once — but that only holds if both | |
| 197 | + * sites agree, and a filter is free to be stateful or context-sensitive. Asking | |
| 198 | + * it twice would let it answer differently and either fire the hook twice or | |
| 199 | + * swallow it entirely. Ask once, remember the answer. | |
| 200 | + * | |
| 201 | + * @return string 'payment'|'details' | |
| 202 | + */ | |
| 203 | + private function beforePaymentMethodsPlacement(): string | |
| 204 | + { | |
| 205 | + if ($this->paymentMethodsHookPlacement !== null) { | |
| 206 | + return $this->paymentMethodsHookPlacement; | |
| 207 | + } | |
| 208 | + | |
| 209 | + $placement = apply_filters('fluent_cart/modal_checkout/before_payment_methods_placement', 'payment', [ | |
| 210 | + 'cart' => $this->cart, | |
| 211 | + ]); | |
| 212 | + | |
| 213 | + // Anything unrecognised falls back to the correct placement rather than | |
| 214 | + // silently dropping the hook. | |
| 215 | + $this->paymentMethodsHookPlacement = $placement === 'details' ? 'details' : 'payment'; | |
| 216 | + | |
| 217 | + return $this->paymentMethodsHookPlacement; | |
| 218 | + } | |
| 219 | + | |
| 187 | 220 | public function renderCheckoutDetails() |
| 188 | 221 | { |
| 189 | 222 | ?> |
| 190 | 223 | <div class="fct-modal-checkout-details"> |
| @@ -199,10 +232,28 @@ | ||
| 199 | 232 | <div class="fct_checkout_shipping_methods <?php echo $this->requireShipping ? '' : 'is-hidden' ?>"> |
| 200 | 233 | <?php $this->checkoutRenderer->renderShippingOptions(); ?> |
| 201 | 234 | </div> |
| 202 | 235 | |
| 203 | - <?php do_action('fluent_cart/before_payment_methods', ['cart' => $this->cart]); ?> | |
| 236 | + <?php | |
| 237 | + /** | |
| 238 | + * The position `fluent_cart/before_payment_methods` used to | |
| 239 | + * occupy in the modal. Given its own name so an add-on that | |
| 240 | + * genuinely wants this pane — under shipping, above the terms | |
| 241 | + * — has a stable place to render, instead of relying on a hook | |
| 242 | + * whose name promises it sits before the payment methods. | |
| 243 | + * | |
| 244 | + * @param array $data ['cart' => Cart] | |
| 245 | + */ | |
| 246 | + do_action('fluent_cart/modal_checkout/after_shipping_methods', ['cart' => $this->cart]); | |
| 204 | 247 | |
| 248 | + // Opt-in restoration of the old placement, for a store whose | |
| 249 | + // add-on positioned itself around the previous spot. Mutually | |
| 250 | + // exclusive with the call site in renderCheckoutBilling(). | |
| 251 | + if ($this->beforePaymentMethodsPlacement() === 'details') { | |
| 252 | + do_action('fluent_cart/before_payment_methods', ['cart' => $this->cart]); | |
| 253 | + } | |
| 254 | + ?> | |
| 255 | + | |
| 205 | 256 | <?php $this->checkoutRenderer->agreeTerms(); ?> |
| 206 | 257 | |
| 207 | 258 | <?php $this->renderSummaryGroup(); ?> |
| 208 | 259 | |
| @@ -219,9 +270,9 @@ | ||
| 219 | 270 | $couponDiscount = (int) Arr::get($this->cart->cart_data, '0.coupon_discount', 0); |
| 220 | 271 | $subtotalRaw = (int) Arr::get($this->cart->cart_data, '0.subtotal', 0); |
| 221 | 272 | $lineTotalRaw = (int) Arr::get($this->cart->cart_data, '0.line_total', 0); |
| 222 | 273 | $hasCouponDiscount = $couponDiscount > 0 && $lineTotalRaw < $subtotalRaw; |
| 223 | - $media = Arr::get($this->cart->cart_data, '0.featured_media', ''); | |
| 274 | + $media = Helper::productImageOrPlaceholder(Arr::get($this->cart->cart_data, '0.featured_media', '')); | |
| 224 | 275 | |
| 225 | 276 | // Mirror CartItemRenderer's event payload so the shared line-item hooks |
| 226 | 277 | // (e.g. the per-item tax breakdown) also fire in modal checkout. |
| 227 | 278 | $lineItemEventInfo = [ |
| @@ -479,8 +530,58 @@ | ||
| 479 | 530 | |
| 480 | 531 | } |
| 481 | 532 | |
| 482 | 533 | |
| 534 | + /** | |
| 535 | + * First/Last name inputs for the modal. Enabling either in Checkout Fields | |
| 536 | + * replaces the single Full name input. The field definitions come from | |
| 537 | + * CheckoutFieldsSchema, shared with the regular checkout; the modal only | |
| 538 | + * moves the label out of the placeholder. | |
| 539 | + * | |
| 540 | + * First name is always rendered and required, because checkout validation | |
| 541 | + * always asks for it in this mode. That keeps an older saved configuration | |
| 542 | + * with only Last name enabled working. | |
| 543 | + * | |
| 544 | + * @return array[] field configs for FormFieldRenderer::renderField() | |
| 545 | + */ | |
| 546 | + private function getFirstLastNameFields(): array | |
| 547 | + { | |
| 548 | + $schema = CheckoutFieldsSchema::getNameEmailFieldsSchema($this->cart); | |
| 549 | + $fields = []; | |
| 550 | + | |
| 551 | + foreach (['billing_first_name', 'billing_last_name'] as $name) { | |
| 552 | + $field = Arr::get($schema, 'fields.' . $name); | |
| 553 | + if ($name === 'billing_first_name') { | |
| 554 | + $field = wp_parse_args($field ?: [], [ | |
| 555 | + 'name' => $name, | |
| 556 | + 'id' => $name, | |
| 557 | + 'type' => 'text', | |
| 558 | + 'data-type' => 'text', | |
| 559 | + 'aria-label' => __('First Name', 'fluent-cart'), | |
| 560 | + 'autocomplete' => 'given-name', | |
| 561 | + 'value' => '', | |
| 562 | + ]); | |
| 563 | + $field['required'] = 'yes'; | |
| 564 | + } | |
| 565 | + | |
| 566 | + if (!$field) { | |
| 567 | + continue; | |
| 568 | + } | |
| 569 | + | |
| 570 | + $field['label'] = Arr::get($field, 'aria-label') . (Arr::get($field, 'required') ? ' *' : ''); | |
| 571 | + $field['placeholder'] = $name === 'billing_first_name' ? __('Jon', 'fluent-cart') : __('Doe', 'fluent-cart'); | |
| 572 | + | |
| 573 | + $customerValue = $this->customer->{str_replace('billing_', '', $name)} ?? ''; | |
| 574 | + if ($customerValue) { | |
| 575 | + $field['value'] = $customerValue; | |
| 576 | + } | |
| 577 | + | |
| 578 | + $fields[] = $field; | |
| 579 | + } | |
| 580 | + | |
| 581 | + return $fields; | |
| 582 | + } | |
| 583 | + | |
| 483 | 584 | public function renderCheckoutBilling() { |
| 484 | 585 | $user = wp_get_current_user(); |
| 485 | 586 | $formRender = new FormFieldRenderer(); |
| 486 | 587 | $fullName = trim( |
| @@ -497,31 +598,44 @@ | ||
| 497 | 598 | $emailField = Arr::get($fieldsSchema, 'basic_info.email', []); |
| 498 | 599 | $isRequiredFullName = Arr::get($fullNameField, 'required', 'no') === 'yes' ? 'yes' : ''; |
| 499 | 600 | $isRequiredEmail = Arr::get($emailField, 'required', 'no') === 'yes' ? 'yes' : ''; |
| 500 | 601 | |
| 602 | + $isFullName = CheckoutFieldsSchema::isFullNameRequired(); | |
| 603 | + $firstLastNameFields = $isFullName ? [] : $this->getFirstLastNameFields(); | |
| 604 | + // First and last name share one row, so email gets the next row to itself. | |
| 605 | + $emailColClass = count($firstLastNameFields) === 2 ? 'col-12' : 'col-6'; | |
| 606 | + | |
| 501 | 607 | ?> |
| 502 | 608 | <div class="fct-modal-checkout-billing-wrap"> |
| 503 | 609 | <!-- Account Details --> |
| 504 | 610 | <div class="fct-modal-account-details" data-fct-checkout-form-section> |
| 505 | 611 | <div class="fct-modal-form-info"> |
| 506 | - <div class="fct-modal-form-field col-6"> | |
| 612 | + <?php if ($isFullName) : ?> | |
| 613 | + <div class="fct-modal-form-field col-6"> | |
| 614 | + <?php | |
| 615 | + $formRender->renderField([ | |
| 616 | + 'label' => esc_attr__('Full name', 'fluent-cart') . ($isRequiredFullName ? ' *' : ''), | |
| 617 | + 'id' => 'billing_full_name', | |
| 618 | + 'type' => 'text', | |
| 619 | + 'placeholder' => __('Jon Doe', 'fluent-cart'), | |
| 620 | + 'name' => 'billing_full_name', | |
| 621 | + 'autocomplete' => 'given-name', | |
| 622 | + 'aria-label' => esc_attr__('Full name', 'fluent-cart'), | |
| 623 | + 'required' => $isRequiredFullName, | |
| 624 | + 'value' => $fullName | |
| 625 | + ]); | |
| 626 | + ?> | |
| 627 | + </div> | |
| 628 | + <?php else : ?> | |
| 629 | + <?php foreach ($firstLastNameFields as $nameField) : ?> | |
| 630 | + <div class="fct-modal-form-field col-6"> | |
| 631 | + <?php $formRender->renderField($nameField); ?> | |
| 632 | + </div> | |
| 633 | + <?php endforeach; ?> | |
| 634 | + <?php endif; ?> | |
| 635 | + <div class="fct-modal-form-field <?php echo esc_attr($emailColClass); ?>"> | |
| 507 | 636 | <?php |
| 508 | 637 | $formRender->renderField([ |
| 509 | - 'label' => esc_attr__('Full name', 'fluent-cart') . ($isRequiredFullName ? ' *' : ''), | |
| 510 | - 'id' => 'billing_full_name', | |
| 511 | - 'type' => 'text', | |
| 512 | - 'placeholder' => __('Jon Doe', 'fluent-cart'), | |
| 513 | - 'name' => 'billing_full_name', | |
| 514 | - 'autocomplete' => 'given-name', | |
| 515 | - 'aria-label' => esc_attr__('Full name', 'fluent-cart'), | |
| 516 | - 'required' => $isRequiredFullName, | |
| 517 | - 'value' => $fullName | |
| 518 | - ]); | |
| 519 | - ?> | |
| 520 | - </div> | |
| 521 | - <div class="fct-modal-form-field col-6"> | |
| 522 | - <?php | |
| 523 | - $formRender->renderField([ | |
| 524 | 638 | 'label' => esc_attr__('Email address', 'fluent-cart') . ($isRequiredEmail ? ' *' : ''), |
| 525 | 639 | 'id' => 'billing_email', |
| 526 | 640 | 'type' => 'text', |
| 527 | 641 | 'placeholder' => '[email protected]', |
| @@ -533,23 +647,8 @@ | ||
| 533 | 647 | 'disabled' => (bool)($this->customer->email ?? $user->user_email) |
| 534 | 648 | ]); |
| 535 | 649 | ?> |
| 536 | 650 | </div> |
| 537 | - | |
| 538 | -<!-- <div class="fct-modal-form-field col-3">--> | |
| 539 | -<!-- --><?php | |
| 540 | -// $formRender->renderField([ | |
| 541 | -// 'label' => __('Last name', 'fluent-cart'), | |
| 542 | -// 'id' => 'billing_last_name', | |
| 543 | -// 'type' => 'text', | |
| 544 | -// 'placeholder' => __('Doe', 'fluent-cart'), | |
| 545 | -// 'name' => 'billing_last_name', | |
| 546 | -// 'autocomplete' => 'given-name', | |
| 547 | -// 'required' => true, | |
| 548 | -// 'value' => $this->customer->last_name ?? '' | |
| 549 | -// ]); | |
| 550 | -// ?> | |
| 551 | -<!-- </div>--> | |
| 552 | 651 | </div> |
| 553 | 652 | |
| 554 | 653 | <?php $this->checkoutRenderer->renderCreateAccountField(); ?> |
| 555 | 654 | |
| @@ -564,12 +663,35 @@ | ||
| 564 | 663 | <?php echo esc_html__('Billing information', 'fluent-cart');?> |
| 565 | 664 | </h4> |
| 566 | 665 | </header> |
| 567 | 666 | |
| 667 | + <?php | |
| 668 | + /** | |
| 669 | + * Fired here, wrapping the payment methods, exactly as | |
| 670 | + * CheckoutRenderer does. It used to fire from | |
| 671 | + * renderCheckoutDetails() — the address/shipping pane — which | |
| 672 | + * is a different part of the modal entirely, so anything | |
| 673 | + * hooked to it (the saved-payment-method picker, Turnstile) | |
| 674 | + * rendered detached from the methods it belongs to. A hook | |
| 675 | + * named "before payment methods" has to fire before the | |
| 676 | + * payment methods. | |
| 677 | + */ | |
| 678 | + if ($this->beforePaymentMethodsPlacement() === 'payment') { | |
| 679 | + do_action('fluent_cart/before_payment_methods', ['cart' => $this->cart]); | |
| 680 | + } | |
| 681 | + ?> | |
| 682 | + | |
| 568 | 683 | <div class="fct_checkout_payment_methods" data-fluent-cart-checkout-payment-methods> |
| 569 | 684 | <?php $this->renderPaymentMethods(); ?> |
| 570 | 685 | </div> |
| 571 | 686 | |
| 687 | + <?php | |
| 688 | + // The modal never fired this at all, so add-ons rendering | |
| 689 | + // below the methods (the save-my-card consent box) were | |
| 690 | + // simply absent from quick checkout. | |
| 691 | + do_action('fluent_cart/after_payment_methods', ['cart' => $this->cart]); | |
| 692 | + ?> | |
| 693 | + | |
| 572 | 694 | <div class="fct-modal-checkout-btn-wrap"> |
| 573 | 695 | <?php (new CheckoutRenderer($this->cart))->renderCheckoutButton(); ?> |
| 574 | 696 | </div> |
| 575 | 697 | </div> |
| @@ -588,8 +710,9 @@ | ||
| 588 | 710 | } |
| 589 | 711 | |
| 590 | 712 | $selectedPaymentMethod = Arr::get($this->cart->checkout_data, 'form_data._fct_pay_method', ''); |
| 591 | 713 | $activePaymentMethods = PaymentMethods::getActiveMethodInstance($this->cart); |
| 714 | + $hadActiveMethods = !empty($activePaymentMethods); | |
| 592 | 715 | |
| 593 | 716 | $activePaymentMethods = apply_filters('fluent_cart/checkout_active_payment_methods', $activePaymentMethods, [ |
| 594 | 717 | 'cart' => $this->cart |
| 595 | 718 | ]); |
| @@ -602,11 +725,28 @@ | ||
| 602 | 725 | }); |
| 603 | 726 | } |
| 604 | 727 | |
| 605 | 728 | if (!$selectedPaymentMethod && !empty($activePaymentMethods)) { |
| 606 | - $selectedPaymentMethod = $activePaymentMethods[0] ? $activePaymentMethods[0]->getMeta('route') : ''; | |
| 729 | + // reset() not [0] — array_filter above preserves keys, so key 0 may be gone. | |
| 730 | + $firstMethod = reset($activePaymentMethods); | |
| 731 | + $selectedPaymentMethod = $firstMethod ? $firstMethod->getMeta('route') : ''; | |
| 607 | 732 | } |
| 608 | 733 | |
| 734 | + /** | |
| 735 | + * The payment method rendered as checked. An add-on that renders its own | |
| 736 | + * selector outside this list (e.g. saved payment methods) returns a route | |
| 737 | + * that is not in the list, so no gateway here is checked — the page then | |
| 738 | + * arrives with the add-on's choice already selected instead of a gateway | |
| 739 | + * being checked first and switched over by JS after load. | |
| 740 | + * | |
| 741 | + * @param string $selectedPaymentMethod | |
| 742 | + * @param array $context ['cart' => Cart, 'payment_methods' => array] | |
| 743 | + */ | |
| 744 | + $selectedPaymentMethod = (string) apply_filters('fluent_cart/checkout/selected_payment_method', $selectedPaymentMethod, [ | |
| 745 | + 'cart' => $this->cart, | |
| 746 | + 'payment_methods' => $activePaymentMethods | |
| 747 | + ]); | |
| 748 | + | |
| 609 | 749 | $checkoutMethodStyle = $this->storeSettings->get('checkout_method_style', 'logo'); |
| 610 | 750 | |
| 611 | 751 | ?> |
| 612 | 752 | <div id="fluent_payment_methods" class="fct_modal_payment_methods fluent_payment_methods"> |
| @@ -624,9 +764,13 @@ | ||
| 624 | 764 | ]); |
| 625 | 765 | } ?> |
| 626 | 766 | <?php else: ?> |
| 627 | 767 | <?php |
| 628 | - $emptyText = esc_html__('No Payment method is activated for this site yet.', 'fluent-cart'); | |
| 768 | + if ($hadActiveMethods) { | |
| 769 | + $emptyText = esc_html__('None of the available payment methods can process this order. Please contact the store.', 'fluent-cart'); | |
| 770 | + } else { | |
| 771 | + $emptyText = esc_html__('No Payment method is activated for this site yet.', 'fluent-cart'); | |
| 772 | + } | |
| 629 | 773 | if (current_user_can('manage_options')) { |
| 630 | 774 | $emptyText .= '<a href="' . esc_url(URL::getDashboardUrl('settings/payments')) . '" target="_blank">' . esc_html__('Activate from settings.', 'fluent-cart') . '</a>'; |
| 631 | 775 | } |
| 632 | 776 | echo '<div class="fct-empty-state">' . wp_kses_post($emptyText) . '</div>'; |
| @@ -713,9 +857,9 @@ | ||
| 713 | 857 | 'route' => $route, |
| 714 | 858 | 'method_title' => $methodTitle, |
| 715 | 859 | 'method_style' => $methodStyle, |
| 716 | 860 | ]; |
| 717 | - $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.'); | |
| 861 | + $paymentMethodClass = ''; | |
| 718 | 862 | $paymentMethodClass = apply_filters('fluent_cart/payment_method_list_class', $paymentMethodClass, $pmContext); |
| 719 | 863 | |
| 720 | 864 | ?> |
| 721 | 865 | <div class="fluent-cart-checkout_embed_payment_wrapper"> |