From f8081c6b65f57cf06fa0229c32bc2c4d77dea07f Mon Sep 17 00:00:00 2001 From: Malin Date: Thu, 5 Mar 2026 08:38:04 +0100 Subject: [PATCH] fix: FSN for guests via option filter, AJAX add-to-cart on single product pages --- assets/js/floating-cart.js | 220 ++++++++++++++++++++++++------------- cgkit-floating-cart.php | 111 ++++++------------- 2 files changed, 180 insertions(+), 151 deletions(-) diff --git a/assets/js/floating-cart.js b/assets/js/floating-cart.js index 95d17c6..bf0077b 100644 --- a/assets/js/floating-cart.js +++ b/assets/js/floating-cart.js @@ -2,25 +2,21 @@ * CommerceKit Floating Cart – frontend script * * Responsibilities: - * 1. Open the CommerceKit / Shoptimizer minicart when the floating button is clicked. - * 2. Automatically open the minicart after a successful add-to-cart (AJAX or standard). - * 3. Play a small badge-bump animation when the item count changes. - * - * All selectors and feature flags come from the `cgkitFC` object localised by PHP. + * 1. Open the Shoptimizer minicart when the floating button is clicked. + * 2. Intercept single-product add-to-cart form submits, send them via AJAX, + * then open the minicart (WooCommerce's built-in AJAX only covers archives). + * 3. Auto-open the minicart after AJAX add-to-cart on archive/shop pages. + * 4. Animate the item-count badge when the cart changes. */ ( function ( $ ) { 'use strict'; - /* ----------------------------------------------------------------------- - * Module - * --------------------------------------------------------------------- */ var FloatingCart = { - $btn: null, - $count: null, + $btn: null, + $count: null, prevCount: 0, - /** Bootstrap */ init: function () { if ( typeof cgkitFC === 'undefined' ) { return; @@ -35,87 +31,167 @@ this.prevCount = parseInt( this.$count.text(), 10 ) || 0; - this.bindEvents(); + this.bindFloatingButton(); + this.bindArchiveAddToCart(); + this.bindSingleProductAddToCart(); + this.bindFragmentRefresh(); }, - /** Attach event listeners */ - bindEvents: function () { + // ----------------------------------------------------------------- + // 1. Floating button click → open minicart + // ----------------------------------------------------------------- + bindFloatingButton: function () { var self = this; - - // --- Floating button click → open minicart ----------------------- this.$btn.on( 'click', function ( e ) { e.preventDefault(); - // Stop the click bubbling to document-level handlers. - // Shoptimizer closes the cart on any click outside .shoptimizer-cart, - // so without this the cart would open then instantly close again. + // Stop bubbling to Shoptimizer's document-level "click outside = close" handler. e.stopPropagation(); self.openMinicart(); } ); - - // --- Auto-open after AJAX add-to-cart ---------------------------- - if ( cgkitFC.autoOpen === 'yes' ) { - $( document.body ).on( 'added_to_cart', function () { - setTimeout( function () { - self.openMinicart(); - self.pulseBtn(); - }, parseInt( cgkitFC.autoOpenDelay, 10 ) || 400 ); - } ); - } - - // --- Badge bump when WooCommerce refreshes fragments ------------- - $( document.body ).on( - 'wc_fragments_refreshed wc_fragments_loaded', - function () { - self.maybeBumpBadge(); - } - ); }, + // ----------------------------------------------------------------- + // 2. Archive / shop pages – WooCommerce fires `added_to_cart` natively + // after its own AJAX add-to-cart (enabled via PHP filter). + // ----------------------------------------------------------------- + bindArchiveAddToCart: function () { + if ( cgkitFC.autoOpen !== 'yes' ) { + return; + } + var self = this; + $( document.body ).on( 'added_to_cart', function ( e, fragments, cartHash, $btn ) { + // Skip if this came from our own single-product handler + // (it will open the cart itself after the AJAX call). + if ( $btn && $btn.data( 'cgkit-fc-handled' ) ) { + return; + } + setTimeout( function () { + self.openMinicart(); + self.pulseBtn(); + }, parseInt( cgkitFC.autoOpenDelay, 10 ) || 400 ); + } ); + }, + + // ----------------------------------------------------------------- + // 3. Single product page – intercept form submit and use AJAX. + // + // WooCommerce's `woocommerce_enable_ajax_add_to_cart` option only + // covers archive loop buttons, NOT the single-product form. We handle + // it here so the page never reloads and the minicart opens instead. + // ----------------------------------------------------------------- + bindSingleProductAddToCart: function () { + if ( cgkitFC.autoOpen !== 'yes' ) { + return; + } + + var self = this; + var ajaxUrl = ( cgkitFC.wcAjaxUrl || '' ).replace( '%%endpoint%%', 'add_to_cart' ); + + if ( ! ajaxUrl ) { + return; + } + + $( document ).on( 'submit', 'form.cart', function ( e ) { + var $form = $( this ); + var $btn = $form.find( '.single_add_to_cart_button' ); + + // Skip disabled, loading, or variation-not-selected states. + if ( + ! $btn.length || + $btn.hasClass( 'disabled' ) || + $btn.hasClass( 'loading' ) || + $btn.hasClass( 'wc-variation-selection-needed' ) + ) { + return; + } + + // Skip grouped product forms (they POST to the cart page directly). + if ( $form.hasClass( 'grouped_form' ) ) { + return; + } + + e.preventDefault(); + $btn.addClass( 'loading' ).data( 'cgkit-fc-handled', true ); + + $.post( ajaxUrl, $form.serialize() ) + .done( function ( response ) { + if ( ! response ) { + self.formFallback( $form, $btn ); + return; + } + + // WooCommerce signals a validation error by setting product_url. + if ( response.error && response.product_url ) { + window.location.href = response.product_url; + return; + } + + // Let WooCommerce update all fragments (header count, mini-cart HTML, etc.) + $( document.body ).trigger( 'added_to_cart', [ + response.fragments, + response.cart_hash, + $btn + ] ); + + $btn.removeClass( 'loading' ); + + // Open the minicart after fragments have settled. + setTimeout( function () { + self.openMinicart(); + self.pulseBtn(); + }, parseInt( cgkitFC.autoOpenDelay, 10 ) || 400 ); + } ) + .fail( function () { + self.formFallback( $form, $btn ); + } ); + } ); + }, + + // Fall back to a normal (non-AJAX) submit if the AJAX call fails. + formFallback: function ( $form, $btn ) { + $btn.removeClass( 'loading' ).removeData( 'cgkit-fc-handled' ); + $( document ).off( 'submit', 'form.cart' ); + $form.submit(); + }, + + // ----------------------------------------------------------------- + // 4. Badge animation when WooCommerce refreshes fragments + // ----------------------------------------------------------------- + bindFragmentRefresh: function () { + var self = this; + $( document.body ).on( 'wc_fragments_refreshed wc_fragments_loaded', function () { + self.maybeBumpBadge(); + } ); + }, + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + /** - * Trigger the theme's minicart open mechanism. - * - * Strategy (stops at the first approach that succeeds): - * 1. Find the header cart button via the configured selector list and - * fire ONE native click on it. Using native click (not jQuery trigger - * + native) avoids the double-fire / toggle-closed bug. - * 2. If no button is found, try to show the Shoptimizer cart panel - * directly by toggling the body class the theme uses. - * 3. Last resort: navigate to the cart URL. - * - * NOTE: never call both $el.trigger('click') AND el.click() — that - * fires two events and the toggle cancels itself immediately. + * Open the Shoptimizer minicart by clicking its header button. + * Uses ONE native click only — never both jQuery trigger and native + * click, which would double-fire and toggle the cart back closed. + * Stops propagation so Shoptimizer's document "click outside" handler + * does not immediately close the cart again. */ openMinicart: function () { - // --- 1. Try clicking the configured header cart button ------------ var selectors = ( cgkitFC.minicartTrigger || '' ).split( ',' ); for ( var i = 0; i < selectors.length; i++ ) { var $el = $( $.trim( selectors[ i ] ) ).first(); if ( $el.length ) { - // One native click – reaches both jQuery and native listeners. $el[ 0 ].click(); return; } } - // --- 2. Shoptimizer direct panel toggle -------------------------- - // Shoptimizer's cart anchor is a.cart-contents inside .shoptimizer-cart. - // If the selector loop above somehow missed it, grab it directly. - var $directBtn = $( '.shoptimizer-cart a.cart-contents' ); - if ( $directBtn.length ) { - $directBtn[ 0 ].click(); - return; - } - - // --- 3. Fall back to cart page ----------------------------------- + // Absolute fallback: navigate to cart page. if ( cgkitFC.cartUrl ) { window.location.href = cgkitFC.cartUrl; } }, - /** - * Add a pulse ring on the button after add-to-cart. - */ pulseBtn: function () { var self = this; this.$btn.addClass( 'cgkit-fc--pulse' ); @@ -124,31 +200,23 @@ }, 600 ); }, - /** - * If the item count increased, animate the badge. - */ maybeBumpBadge: function () { - var $freshCount = $( '#cgkit-fc-count' ); - // Re-query in case the fragment replaced the element. - if ( ! $freshCount.length ) { + var $fresh = $( '#cgkit-fc-count' ); + if ( ! $fresh.length ) { return; } - this.$count = $freshCount; + this.$count = $fresh; var newCount = parseInt( this.$count.text(), 10 ) || 0; if ( newCount !== this.prevCount ) { this.prevCount = newCount; this.$count.removeClass( 'cgkit-floating-cart__count--bump' ); - // Force reflow so removing + re-adding the class restarts the animation. - void this.$count[ 0 ].offsetWidth; // eslint-disable-line no-unused-expressions + void this.$count[ 0 ].offsetWidth; // force reflow to restart animation this.$count.addClass( 'cgkit-floating-cart__count--bump' ); } } }; - /* ----------------------------------------------------------------------- - * Boot - * --------------------------------------------------------------------- */ $( document ).ready( function () { FloatingCart.init(); } ); diff --git a/cgkit-floating-cart.php b/cgkit-floating-cart.php index 253ca9b..51e83e8 100644 --- a/cgkit-floating-cart.php +++ b/cgkit-floating-cart.php @@ -3,7 +3,7 @@ * Plugin Name: CommerceKit Floating Cart * Plugin URI: https://www.commercegurus.com * Description: Adds a floating cart icon (bottom-right) and auto-opens the CommerceKit minicart after add to cart. Requires CommerceGurus CommerceKit and WooCommerce. - * Version: 1.0.4 + * Version: 1.1.0 * Author: CommerceGurus * Author URI: https://www.commercegurus.com * License: GPLv3 @@ -19,7 +19,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -define( 'CGKIT_FC_VERSION', '1.0.4' ); +define( 'CGKIT_FC_VERSION', '1.1.0' ); define( 'CGKIT_FC_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'CGKIT_FC_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); @@ -32,6 +32,30 @@ add_action( 'before_woocommerce_init', function () { } } ); +/** + * Force CommerceKit's "show FSN before shipping address is entered" flag on. + * + * Registered early (plugins_loaded priority 5) so it is in place before + * CommerceKit reads the option at plugins_loaded priority 10+. + * + * Why here rather than in the class: this filter must be attached BEFORE + * WooCommerce and CommerceKit finish loading so the very first option read + * during any request already sees fsn_before_ship = 1. + * + * What fsn_before_ship does inside CommerceKit: + * if ( ! $fsn_before_ship ) { + * if ( ! $show_shipping ) { return ''; } // bails for guests + * } + * Setting it to 1 skips that entire block, so the notification renders + * regardless of whether WooCommerce has calculated shipping yet. + */ +add_filter( 'option_commercekit', function ( $options ) { + if ( is_array( $options ) ) { + $options['fsn_before_ship'] = 1; + } + return $options; +}, 1 ); + /** * Main plugin class. */ @@ -55,40 +79,9 @@ class CommerceKit_Floating_Cart { // stays put and we can open the minicart instead. add_filter( 'pre_option_woocommerce_cart_redirect_after_add', [ $this, 'disable_cart_redirect' ] ); - // Ensure WooCommerce's AJAX add-to-cart is active so the body receives - // the `added_to_cart` JS event that our script listens for. + // Ensure WooCommerce's AJAX add-to-cart is active so archive/shop page + // buttons fire the `added_to_cart` JS event without a page reload. add_filter( 'pre_option_woocommerce_enable_ajax_add_to_cart', [ $this, 'enable_ajax_add_to_cart' ] ); - - // Show the CommerceKit free shipping notification for guests. - // WC's show_shipping() returns false for logged-out users when the store - // hides shipping costs until an address is entered, which causes CommerceKit - // to skip rendering the bar entirely. We force shipping => true for any - // non-empty cart so the notification always renders. - add_filter( 'commercekit_fsn_get_cart_options', [ $this, 'fsn_show_for_guests' ] ); - } - - /** - * Force the CommerceKit free shipping notification to render for logged-out - * users. WC_Cart::show_shipping() returns false for guests when the store - * option "Hide shipping costs until an address is entered" is active, which - * makes CommerceKit bail before building the bar HTML. Overriding the - * `shipping` key here only affects the FSN display logic — WooCommerce's - * actual shipping calculation is untouched. - * - * @param array $options Result from commercekit_fsn_get_cart_options(). - * @return array - */ - public function fsn_show_for_guests( array $options ): array { - if ( ! isset( $options['shipping'] ) || $options['shipping'] ) { - return $options; // already showing, nothing to do. - } - - $cart = WC()->cart; - if ( $cart && $cart->get_cart_contents_count() > 0 && $options['amount'] > 0 ) { - $options['shipping'] = true; - } - - return $options; } /** @@ -100,10 +93,8 @@ class CommerceKit_Floating_Cart { /** * Return 'yes' so WooCommerce uses AJAX add-to-cart on archive/shop pages. - * This ensures `added_to_cart` JS event fires without a page reload. */ public function enable_ajax_add_to_cart( string $value ): string { - // Only override when it's not already enabled. return ( 'yes' === $value ) ? $value : 'yes'; } @@ -128,39 +119,17 @@ class CommerceKit_Floating_Cart { /** * Filter: cgkit_fc_minicart_trigger - * - * CSS selector(s) for the theme's minicart open button. The floating - * cart icon and the add-to-cart auto-open both programmatically click - * the first matching element. - * - * For Shoptimizer / CommerceKit the default covers the most common - * header cart selectors. Override this filter in your child theme if - * needed. - * - * @param string $selector A comma-separated CSS selector string. + * CSS selector(s) for the theme's minicart open button. */ $trigger = apply_filters( 'cgkit_fc_minicart_trigger', '.shoptimizer-cart .cart-contents, .site-header-cart .cart-contents, .wcmenucart, .header-cart-link' ); - /** - * Filter: cgkit_fc_auto_open - * - * Set to false to disable auto-opening the minicart after add to cart. - * - * @param bool $auto_open - */ + /** Filter: cgkit_fc_auto_open — set false to disable auto-open after add. */ $auto_open = (bool) apply_filters( 'cgkit_fc_auto_open', true ); - /** - * Filter: cgkit_fc_auto_open_delay - * - * Milliseconds to wait before opening the minicart after add to cart, - * to allow WooCommerce fragments to refresh first. - * - * @param int $delay - */ + /** Filter: cgkit_fc_auto_open_delay — ms to wait before opening (default 400). */ $delay = (int) apply_filters( 'cgkit_fc_auto_open_delay', 400 ); wp_localize_script( 'cgkit-floating-cart', 'cgkitFC', [ @@ -168,6 +137,8 @@ class CommerceKit_Floating_Cart { 'autoOpen' => $auto_open ? 'yes' : 'no', 'autoOpenDelay' => $delay, 'cartUrl' => wc_get_cart_url(), + // WooCommerce AJAX endpoint used for single-product AJAX add-to-cart. + 'wcAjaxUrl' => WC_AJAX::get_endpoint( '%%endpoint%%' ), ] ); } @@ -187,15 +158,11 @@ class CommerceKit_Floating_Cart { } /** - * Provide a WooCommerce fragment so the badge count updates via AJAX - * (e.g. after mini-cart quantity changes or AJAX add-to-cart). - * - * @param array $fragments - * @return array + * WooCommerce fragment — keeps the badge count live after AJAX operations. */ public function cart_count_fragment( array $fragments ): array { - $count = WC()->cart ? WC()->cart->get_cart_contents_count() : 0; - $fragments['#cgkit-fc-count'] = $this->count_badge_html( $count ); + $count = WC()->cart ? WC()->cart->get_cart_contents_count() : 0; + $fragments['#cgkit-fc-count'] = $this->count_badge_html( $count ); return $fragments; } @@ -203,9 +170,6 @@ class CommerceKit_Floating_Cart { // Private helpers // ------------------------------------------------------------------------- - /** - * Return the count badge HTML (targeted by fragments). - */ private function count_badge_html( int $count ): string { $hidden = 0 === $count ? ' cgkit-floating-cart__count--hidden' : ''; return sprintf( @@ -215,9 +179,6 @@ class CommerceKit_Floating_Cart { ); } - /** - * Shopping-cart SVG icon (Feather icons style, matches CommerceKit aesthetic). - */ private function cart_icon_svg(): string { return '