Files
CommerceKit-FloatingCart/cgkit-floating-cart.php

255 lines
8.7 KiB
PHP

<?php
/**
* 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.1.1
* Author: CommerceGurus
* Author URI: https://www.commercegurus.com
* License: GPLv3
* License URI: http://www.gnu.org/licenses/gpl.html
* Text Domain: cgkit-floating-cart
* Requires at least: 5.6
* Requires PHP: 7.4
* WC requires at least: 5.0
* WC tested up to: 9.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'CGKIT_FC_VERSION', '1.1.1' );
define( 'CGKIT_FC_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'CGKIT_FC_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
/**
* Declare WooCommerce HPOS compatibility.
*/
add_action( 'before_woocommerce_init', function () {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
}
} );
/**
* 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 );
/**
* Fallback free-shipping amount for guests / users without a shipping address.
*
* CommerceKit bails with `if (!$free_amount) return ''` BEFORE it reaches the
* `fsn_before_ship` check. The bail happens because for guests WooCommerce zone
* detection falls back to "Rest of World" (empty destination in session), which
* often has no free_shipping method → amount stays 0.
*
* Fix: if the amount is still 0 after CommerceKit's own lookup, redo the zone
* detection using the shop's base country/state so we always resolve against
* the same zone as a logged-in customer in the store's home country.
*/
add_filter( 'commercekit_fsn_get_cart_options', function ( array $options ): array {
// Amount already resolved — nothing to do.
if ( $options['amount'] > 0 ) {
return $options;
}
// Re-run zone detection with the store base location.
$fake_package = [
'destination' => [
'country' => WC()->countries->get_base_country(),
'state' => WC()->countries->get_base_state(),
'postcode' => '',
'city' => '',
],
];
$zone = wc_get_shipping_zone( $fake_package );
$th_sep = wc_get_price_thousand_separator();
$dc_sep = wc_get_price_decimal_separator();
foreach ( $zone->get_shipping_methods( true ) as $method ) {
if ( 'free_shipping' !== $method->id ) {
continue;
}
$instance = $method->instance_settings ?? [];
$amount = isset( $instance['min_amount'] ) ? $instance['min_amount'] : 0;
$requires = isset( $instance['requires'] ) ? $instance['requires'] : '';
$amount = floatval( str_replace( $dc_sep, '.', str_replace( $th_sep, '', $amount ) ) );
// Only use this fallback when the method just needs a minimum order
// amount — coupon-gated thresholds can't be shown without a coupon.
if ( $amount > 0 && 'min_amount' === $requires ) {
$options['amount'] = $amount;
$options['requires'] = $requires;
$options['shipping'] = true;
}
break;
}
return $options;
}, 10 );
/**
* Main plugin class.
*/
class CommerceKit_Floating_Cart {
private static $instance = null;
public static function get_instance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_assets' ] );
add_action( 'wp_footer', [ $this, 'render_floating_cart' ] );
add_filter( 'woocommerce_add_to_cart_fragments', [ $this, 'cart_count_fragment' ] );
// Disable WooCommerce's "redirect to cart page after add" so the page
// 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 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' ] );
}
/**
* Return 'no' so WooCommerce never redirects to /cart after adding a product.
*/
public function disable_cart_redirect( string $value ): string {
return 'no';
}
/**
* Return 'yes' so WooCommerce uses AJAX add-to-cart on archive/shop pages.
*/
public function enable_ajax_add_to_cart( string $value ): string {
return ( 'yes' === $value ) ? $value : 'yes';
}
/**
* Enqueue frontend CSS and JS.
*/
public function enqueue_assets(): void {
wp_enqueue_style(
'cgkit-floating-cart',
CGKIT_FC_PLUGIN_URL . 'assets/css/floating-cart.css',
[],
CGKIT_FC_VERSION
);
wp_enqueue_script(
'cgkit-floating-cart',
CGKIT_FC_PLUGIN_URL . 'assets/js/floating-cart.js',
[ 'jquery' ],
CGKIT_FC_VERSION,
true
);
/**
* Filter: cgkit_fc_minicart_trigger
* 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 false to disable auto-open after add. */
$auto_open = (bool) apply_filters( 'cgkit_fc_auto_open', true );
/** 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', [
'minicartTrigger' => $trigger,
'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%%' ),
] );
}
/**
* Output the floating cart button HTML in the footer.
*/
public function render_floating_cart(): void {
$count = WC()->cart ? WC()->cart->get_cart_contents_count() : 0;
?>
<div id="cgkit-floating-cart" class="cgkit-floating-cart" role="complementary" aria-label="<?php esc_attr_e( 'Floating cart', 'cgkit-floating-cart' ); ?>">
<button class="cgkit-floating-cart__btn" type="button" aria-label="<?php esc_attr_e( 'Open mini cart', 'cgkit-floating-cart' ); ?>">
<?php echo $this->cart_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput ?>
<?php echo $this->count_badge_html( $count ); // phpcs:ignore WordPress.Security.EscapeOutput ?>
</button>
</div>
<?php
}
/**
* 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 );
return $fragments;
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private function count_badge_html( int $count ): string {
$hidden = 0 === $count ? ' cgkit-floating-cart__count--hidden' : '';
return sprintf(
'<span id="cgkit-fc-count" class="cgkit-floating-cart__count%s" aria-hidden="true">%s</span>',
esc_attr( $hidden ),
esc_html( $count > 0 ? $count : '' )
);
}
private function cart_icon_svg(): string {
return '<svg class="cgkit-floating-cart__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
width="24" height="24" aria-hidden="true" focusable="false"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="21" r="1"></circle>
<circle cx="20" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
</svg>';
}
}
/**
* Bootstrap after WooCommerce is loaded so WC() is available.
*/
add_action( 'woocommerce_loaded', function () {
CommerceKit_Floating_Cart::get_instance();
} );