includes(); $this->init_hooks(); } private function includes(): void { // Model + fee engine: needed on both frontend and admin. require_once EXTNIFY_OFFER_PLUGIN_PATH . 'admin/model.php'; Extnify_Offer_Model::maybe_upgrade(); // sync DB schema (handles updates, not just activation) require_once EXTNIFY_OFFER_PLUGIN_PATH . 'front/class-offer.php'; if ( is_admin() ) { require_once EXTNIFY_OFFER_PLUGIN_PATH . 'system/extnify-core/ui/autoloader.php'; require_once EXTNIFY_OFFER_PLUGIN_PATH . 'system/framework/traits/woo-helpers.php'; require_once EXTNIFY_OFFER_PLUGIN_PATH . 'admin/controller.php'; new Extnify_Offer_Back( new Extnify_Offer_Model() ); } } private function init_hooks(): void { // Translations load automatically on WP 4.6+ (slug = text domain = "extnify-offer"), // so no load_plugin_textdomain() call is needed. // // Offers are added to the cart/checkout totals via woocommerce_cart_calculate_fees. // The engine reads the shared methods table and calls WC()->cart->add_fee(). add_action( 'woocommerce_cart_calculate_fees', array( $this, 'calculate_fees' ) ); // ── Selectable offers: a row inside the cart/checkout totals table, styled like // the Shipment row. Position is store-configurable (After Subtotal / Before Tax / // Before Total). These rows live inside the fragments WooCommerce refreshes, so // the list re-evaluates on a shipping change with no JS. The applied discount stays // a real (taxable, recorded) fee; its separate line is hidden in front.js. $pos = (string) ( ( (array) get_option( 'extnify_offer', array() ) )['offer_position'] ?? 'before_tax' ); $cart_hooks = array( 'after_subtotal' => 'woocommerce_cart_totals_before_shipping', 'before_tax' => 'woocommerce_cart_totals_after_shipping', 'before_total' => 'woocommerce_cart_totals_before_order_total', ); $chk_hooks = array( 'after_subtotal' => 'woocommerce_review_order_before_shipping', 'before_tax' => 'woocommerce_review_order_after_shipping', 'before_total' => 'woocommerce_review_order_before_order_total', ); add_action( $cart_hooks[ $pos ] ?? $cart_hooks['before_tax'], array( $this, 'render_offers_row' ) ); add_action( $chk_hooks[ $pos ] ?? $chk_hooks['before_tax'], array( $this, 'render_offers_row' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_front' ) ); add_action( 'wp_ajax_x_offer_apply', array( $this, 'ajax_apply' ) ); add_action( 'wp_ajax_nopriv_x_offer_apply', array( $this, 'ajax_apply' ) ); add_action( 'wp_ajax_x_offer_get_offers', array( $this, 'ajax_get_offers' ) ); add_action( 'wp_ajax_nopriv_x_offer_get_offers', array( $this, 'ajax_get_offers' ) ); // Block (Store-API) checkout: expose offers + accept the selection. add_action( 'woocommerce_blocks_loaded', array( $this, 'register_store_api' ) ); } /** Register the offers data + selection update on the Cart Store API (block checkout). */ public function register_store_api(): void { if ( ! function_exists( 'woocommerce_store_api_register_endpoint_data' ) || ! function_exists( 'woocommerce_store_api_register_update_callback' ) ) { return; } woocommerce_store_api_register_endpoint_data( array( 'endpoint' => 'cart', 'namespace' => 'extnify-offer', 'data_callback' => array( $this, 'store_api_data' ), 'schema_callback' => array( $this, 'store_api_schema' ), 'schema_type' => ARRAY_A, ) ); woocommerce_store_api_register_update_callback( array( 'namespace' => 'extnify-offer', 'callback' => array( $this, 'store_api_update' ), ) ); } /** Store API payload: eligible offers + mode + heading. */ public function store_api_data(): array { $offers = ( function_exists( 'WC' ) && WC()->cart ) ? ( new Extnify_Offer_Offer() )->eligible_offers( WC()->cart ) : array(); $cfg = (array) get_option( 'extnify_offer', array() ); return array( 'offers' => array_values( $offers ), 'single' => ( ( $cfg['offer_type'] ?? 'multiple' ) === 'single' ), 'heading' => ! empty( $cfg['offer_heading'] ) ? (string) $cfg['offer_heading'] : __( 'Recommended Offers', 'extnify-offer' ), ); } /** Store API schema. */ public function store_api_schema(): array { return array( 'offers' => array( 'description' => __( 'Eligible offers for the current cart.', 'extnify-offer' ), 'type' => 'array', 'readonly' => true ), 'single' => array( 'description' => __( 'Single-selection mode.', 'extnify-offer' ), 'type' => 'boolean', 'readonly' => true ), 'heading' => array( 'description' => __( 'Offers box heading.', 'extnify-offer' ), 'type' => 'string', 'readonly' => true ), ); } /** Store API update: persist the shopper's selection (triggers a recalc). */ public function store_api_update( $data ): void { $ids = isset( $data['offers'] ) ? array_map( 'intval', (array) $data['offers'] ) : array(); ( new Extnify_Offer_Offer() )->set_chosen( $ids ); } /** Enqueue the storefront offers-widget assets on cart/checkout only. */ public function enqueue_front(): void { if ( ! function_exists( 'is_cart' ) || ! ( is_cart() || is_checkout() ) ) { return; } $base = EXTNIFY_OFFER_PLUGIN_URL; $path = EXTNIFY_OFFER_PLUGIN_PATH; $fb = defined( 'EXTNIFY_OFFER_PLUGIN_VERSION' ) ? EXTNIFY_OFFER_PLUGIN_VERSION : '1.0.0'; // Version assets by filemtime so a plain `cp` of a changed CSS/JS busts the cache // (matches the admin-asset convention); fall back to the plugin version. $ver = function ( $rel ) use ( $path, $fb ) { return file_exists( $path . $rel ) ? filemtime( $path . $rel ) : $fb; }; wp_enqueue_style( 'xoffer-front', $base . 'assets/css/front.css', array(), $ver( 'assets/css/front.css' ) ); wp_enqueue_script( 'xoffer-front', $base . 'assets/js/front.js', array( 'jquery' ), $ver( 'assets/js/front.js' ), true ); wp_localize_script( 'xoffer-front', 'XOfferFront', array( 'ajax' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'x_offer_front' ), ) ); // Block (Store-API) checkout widget — renders only when the Blocks runtime // is present; harmlessly no-ops on classic checkout. if ( function_exists( 'is_checkout' ) && is_checkout() && wp_script_is( 'wc-blocks-checkout', 'registered' ) ) { wp_enqueue_script( 'xoffer-blocks', $base . 'assets/js/blocks.js', array( 'wc-blocks-checkout', 'wp-element', 'wp-plugins', 'wp-data', 'wp-i18n' ), $ver( 'assets/js/blocks.js' ), true ); } } /** Render the "Recommended Offers" box with the eligible offers (radio/checkbox). */ /** * Render the offers chooser as a row inside the cart/checkout totals table, styled like * the Shipment row (label cell + radio/checkbox options). The chosen offer is applied as * a real fee by the engine (correct tax + recorded on the order); front.js hides that * fee's own line so this row is the single place offers appear. */ public function render_offers_row(): void { if ( ! function_exists( 'WC' ) || ! WC()->cart || WC()->cart->is_empty() ) { return; } $offers = ( new Extnify_Offer_Offer() )->eligible_offers( WC()->cart ); if ( empty( $offers ) ) { return; } $cfg = (array) get_option( 'extnify_offer', array() ); $single = ( ( $cfg['offer_type'] ?? 'multiple' ) === 'single' ); $heading = ! empty( $cfg['offer_heading'] ) ? (string) $cfg['offer_heading'] : __( 'Recommended Offers', 'extnify-offer' ); $input = $single ? 'radio' : 'checkbox'; $html = '