model = $model; $this->form = new Form(); $this->misc = new Misc( self::group() ); $this->langs_data = $this->langs(); $this->form->setBasename( self::group() )->setLangs( $this->langs_data ); add_action( 'admin_menu', array( $this, 'register_submenu' ) ); add_action( 'admin_init', array( $this, 'handle_submenu_save' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); // AJAX action names are prefixed with this plugin's unique group // (e.g. wp_ajax_x_feepro_load_method) so two active Extnify plugins never // register the same hook — otherwise both handlers fire on one request. $ajax = array( 'load_method', 'save_method', 'delete_method', 'copy_method', 'reorder_methods', 'get_zones', 'clear_cache', 'debug_log', 'debug_clear' ); foreach ( $ajax as $action ) { add_action( 'wp_ajax_' . self::group() . '_' . $action, array( $this, 'ajax_' . $action ) ); } } private function langs() { return require __DIR__ . '/language/en.php'; } // ── Submenu ────────────────────────────────────────────────────────────── public function register_submenu() { add_submenu_page( 'woocommerce', __( 'Extnify Fee Rules', 'extnify-fee' ), __( 'Extnify Fee Rules', 'extnify-fee' ), 'manage_woocommerce', self::slug(), array( $this, 'views_settings' ) ); } // ── Global settings save ────────────────────────────────────────────────── public function handle_submenu_save() { if ( ! isset( $_GET['page'] ) || self::slug() !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin page routing return; } $nonce_key = self::group() . '_nonce'; if ( ! isset( $_POST[ $nonce_key ] ) ) { return; } if ( ! wp_verify_nonce( sanitize_key( $_POST[ $nonce_key ] ), self::group() . '_save' ) ) { return; } if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } $posted = isset( $_POST[ self::group() ] ) ? (array) wp_unslash( $_POST[ self::group() ] ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; sanitized in sanitize_global() update_option( self::group(), $this->sanitize_global( $posted ) ); wp_safe_redirect( add_query_arg( array( 'page' => self::slug(), 'saved' => '1' ), admin_url( 'admin.php' ) ) ); exit; } private function sanitize_global( array $data ) { $saved = array(); if ( isset( $data['heading'] ) ) { $saved['heading'] = sanitize_text_field( $data['heading'] ); } if ( isset( $data['sort_order'] ) ) { $saved['sort_order'] = absint( $data['sort_order'] ); } if ( isset( $data['debug'] ) ) { $saved['debug'] = sanitize_key( $data['debug'] ); } $saved['status'] = ! empty( $data['status'] ) ? '1' : '0'; return $saved; } // ── Page renderer ───────────────────────────────────────────────────────── public function views_settings() { $this->form ->setBasename( self::group() ) ->setPreset( null ) ->setIDPostfix( '' ) ->setOptions( $this->get_global_form_options() ) ->setLangs( $this->langs_data ); $this->view( 'settings', array( 'page_title' => __( 'Extnify Fee Rules', 'extnify-fee' ), 'saved' => isset( $_GET['saved'] ) && '1' === $_GET['saved'], // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin page routing 'global_html' => $this->get_config_form(), 'methods_list' => $this->model->getData(), ) ); } private function get_global_form_options() { return array( 'status' => array( array( 'value' => '1', 'name' => __( 'Enabled', 'extnify-fee' ) ), array( 'value' => '0', 'name' => __( 'Disabled', 'extnify-fee' ) ), ), 'debug' => array( array( 'value' => '1', 'name' => __( 'Enabled', 'extnify-fee' ) ), array( 'value' => '0', 'name' => __( 'Disabled', 'extnify-fee' ) ), ), ); } private function get_config_form() { $setting = (array) get_option( self::group(), array() ); $nonce_key = self::group() . '_nonce'; $subtabs = array( 'general' => __( 'General', 'extnify-fee' ), 'grouping' => __( 'Group Option', 'extnify-fee' ), 'import_export' => __( 'Import/Export', 'extnify-fee' ), 'help' => __( 'Help & Support', 'extnify-fee' ), ); $html = '
'; $html .= wp_nonce_field( self::group() . '_save', $nonce_key, true, false ); $html .= '
'; $html .= '
' . $this->tab_gs_general( $setting ) . '
'; $html .= '
' . $this->pro_placeholder() . '
'; $html .= '
' . $this->pro_placeholder() . '
'; $html .= '
' . $this->tab_gs_help() . '
'; $html .= '
'; return $html; } private function tab_gs_general( array $setting ) { $this->form->setPreset( $setting ); $html = ''; $html .= $this->form->get( 'input', array( 'name' => 'sort_order', 'type' => 'number' ) ); $html .= $this->form->get( 'select', array( 'name' => 'status', 'title' => __( 'Module Status', 'extnify-fee' ) ) ); $html .= $this->form->get( 'select', 'debug' ); $html .= ''; return $html; } /** * "Available in Pro Version" placeholder. On the wp.org build (XFEE_UPGRADE_URL * set) it links to the Pro product page; on the WooCommerce build (constant * empty) it stays plain text — no external upsell link (Marketplace rules). */ private function pro_placeholder( $text = null, $icon = 'dashicons-lock' ) { $url = defined( 'XFEE_UPGRADE_URL' ) ? (string) XFEE_UPGRADE_URL : ''; $label = ( null === $text ) ? esc_html__( 'Available in Pro Version', 'extnify-fee' ) : esc_html( $text ); $body = ( '' !== $url ) ? '' . $label . '' : $label; return '
' . '' . '

' . $body . '

' . '
'; } /** Help & Support — support links only (the live debug log is a Pro feature). */ private function tab_gs_help() { $ver = defined( 'XFEE_PLUGIN_VERSION' ) ? XFEE_PLUGIN_VERSION : '1.0.0'; $wp_ver = get_bloginfo( 'version' ); $php_ver = preg_replace( '/^(\d+\.\d+).*/', '$1', PHP_VERSION ); $wc_ver = defined( 'WC_VERSION' ) ? WC_VERSION : ( function_exists( 'WC' ) ? WC()->version : '—' ); $sysinfo = sprintf( 'v%s · WP %s · PHP %s · WC %s', $ver, $wp_ver, $php_ver, $wc_ver ); // slug, icon, url, title, subtitle, arrow ('ext' = ↗, 'go' = ›) $cards = array( array( 'docs', 'dashicons-book-alt', 'https://extnify.com/docs/woocommerce/extnify-fee-rules-pro-woocommerce', __( 'Documentation', 'extnify-fee' ), __( 'Guides and how-tos', 'extnify-fee' ), 'ext' ), array( 'support', 'dashicons-sos', 'https://extnify.com/tickets', __( 'Request support', 'extnify-fee' ), __( 'Get help from our team', 'extnify-fee' ), 'go' ), array( 'ext', 'dashicons-admin-plugins', 'https://extnify.com/platforms/woocommerce', __( 'More extensions', 'extnify-fee' ), __( 'Other plugins by us', 'extnify-fee' ), 'ext' ), ); $html = '
'; $html .= '
' . '' . '
' . esc_html__( 'Help & Support', 'extnify-fee' ) . '' . '' . esc_html__( 'Get help and resources', 'extnify-fee' ) . '
' . '
'; $html .= '
' . '' . '
' . esc_html__( 'System healthy', 'extnify-fee' ) . '' . '' . esc_html( $sysinfo ) . '
'; foreach ( $cards as $c ) { list( $slug, $icon, $url, $title, $sub, $arrow ) = $c; $arw = 'ext' === $arrow ? 'dashicons-external' : 'dashicons-arrow-right-alt2'; $html .= '' . '' . '' . esc_html( $title ) . '' . '' . esc_html( $sub ) . '' . ''; } // Upgrade-to-Pro card — shown only when the build sets an upgrade URL // (WordPress.org build). The WooCommerce build ships an empty URL, so the // card is dropped entirely (the marketplace owns the upsell there). if ( defined( 'XFEE_UPGRADE_URL' ) && XFEE_UPGRADE_URL ) { $html .= '' . '' . '' . esc_html__( 'Upgrade to Pro', 'extnify-fee' ) . '' . '' . esc_html__( 'Unlock advanced rules, more rate types, grouping & more', 'extnify-fee' ) . '' . ''; } $html .= '
'; // .ext-hs-left // ── Live debugging log ───────────────────────────────────────────────── $html .= '
'; $html .= '
' . '' . '' . esc_html__( 'Live debugging log', 'extnify-fee' ) . '' . '' . esc_html__( 'LIVE', 'extnify-fee' ) . '' . '
'; $html .= '
'; foreach ( array( 'all' => 'All', 'info' => 'Info', 'warn' => 'Warn', 'error' => 'Error', 'debug' => 'Debug' ) as $lv => $lbl ) { $html .= ''; } $html .= '
' . '' . '
'; $html .= '
' . esc_html__( 'No log entries yet.', 'extnify-fee' ) . '
'; $html .= '
' . '' . esc_html__( 'Connected', 'extnify-fee' ) . ' · 0 ' . esc_html__( 'entries', 'extnify-fee' ) . '' . '
'; $html .= '
'; // .ext-hs-log $html .= '
'; // .ext-hs return $html; } // ── Method form ──────────────────────────────────────────────────────────── private function get_form_builder_options() { $opts = array(); $tc = array( array( 'value' => '', 'name' => __( 'None', 'extnify-fee' ) ) ); foreach ( WC_Tax::get_tax_classes() as $class ) { $tc[] = array( 'value' => sanitize_title( $class ), 'name' => Misc::decode( $class ) ); } $opts['tax_class_id'] = $tc; $gz = array(); foreach ( WC_Shipping_Zones::get_zones() as $zone ) { $gz[] = array( 'value' => (string) $zone['id'], 'name' => Misc::decode( $zone['zone_name'] ) ); } $opts['geo_zone'] = $gz; // Payment methods (chosen gateway id → title), for the Payment Method rule. $pm = array(); if ( function_exists( 'WC' ) && WC()->payment_gateways() ) { foreach ( WC()->payment_gateways()->payment_gateways() as $gw ) { $pm[] = array( 'value' => (string) $gw->id, 'name' => Misc::decode( $gw->get_method_title() ) ); } } $opts['payment'] = $pm; $opts['shipping'] = $this->get_shipping_options(); // Free supports ONLY a flat amount (a plain number, or N% of the subtotal). $opts['rate_type'] = array( array( 'value' => 'flat', 'name' => __( 'Flat Amount', 'extnify-fee' ) ), ); $opts['status'] = array( array( 'value' => '1', 'name' => __( 'Enabled', 'extnify-fee' ) ), array( 'value' => '0', 'name' => __( 'Disabled', 'extnify-fee' ) ), ); return $opts; } /** * Options for the Shipping Method rule. * * Core WooCommerce methods (Flat rate, Free shipping, Local pickup, …) are * listed by type — a chosen rate like "flat_rate:3" matches the "flat_rate" * base. Extnify Shipping Rules is a dynamic method: it produces one rate per * configured method row (rate id "_"), so it's EXPANDED * into those rows here (read from the shared methods table), letting a fee * target a specific shipping method — e.g. "only when 'Weight Based' is chosen". */ private function get_shipping_options() { $out = array(); if ( ! function_exists( 'WC' ) || ! WC()->shipping() ) { return $out; } $extnify = null; // the Extnify shipping method (free 'x_shipping' or pro 'x_shipping_pro'). foreach ( WC()->shipping()->get_shipping_methods() as $method ) { if ( in_array( $method->id, array( 'x_shipping', 'x_shipping_pro' ), true ) ) { $extnify = $method; // expanded into rows below — don't list the collapsed type. continue; } $out[] = array( 'value' => (string) $method->id, 'name' => Misc::decode( $method->get_method_title() ) ); } if ( $extnify ) { global $wpdb; $table = $wpdb->prefix . 'xshippingpro_methods'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- checking existence of a fixed prefixed table. if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table ) { $prefix = $extnify->get_method_title(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- fixed prefixed table; no user input. $rows = $wpdb->get_results( "SELECT id, method_data FROM {$table} ORDER BY sort_order ASC, id ASC", ARRAY_A ); foreach ( (array) $rows as $r ) { $md = json_decode( $r['method_data'], true ); $md = is_array( $md ) ? $md : array(); $name = ''; if ( ! empty( $md['name'] ) ) { $name = is_array( $md['name'] ) ? (string) reset( $md['name'] ) : (string) $md['name']; } elseif ( ! empty( $md['display'] ) ) { $name = (string) $md['display']; } else { $name = 'Method #' . (int) $r['id']; } $out[] = array( 'value' => $extnify->id . '_' . (int) $r['id'], 'name' => Misc::decode( $prefix . ' — ' . $name ), ); } } } return $out; } private function get_default_values() { return array( 'geo_zone' => array(), 'geo_zone_all' => '1', 'payment' => array(), 'payment_all' => '1', 'shipping' => array(), 'shipping_all' => '1', 'tax_class_id' => '', 'sort_order' => '', 'status' => '1', 'rate_type' => 'flat', 'cost' => '', 'display' => __( 'New Fee', 'extnify-fee' ), 'name' => __( 'Fee', 'extnify-fee' ), ); } private function get_form_data( array $method_data, $method_id, $id_postfix = '' ) { $method_data = array_merge( $this->get_default_values(), $method_data ); $postfix = ( '' !== (string) $id_postfix ) ? (string) $id_postfix : (string) $method_id; $this->form ->setBasename( 'xfeepro' ) ->setPreset( $method_data ) ->setIDPostfix( $postfix ) ->setOptions( $this->get_form_builder_options() ) ->setLangs( $this->langs_data ); $mid = esc_attr( $postfix ); $saved_id = ( (int) $method_id > 0 ) ? esc_attr( (string) $method_id ) : ''; $html = '
'; $html .= ''; $html .= '
'; $html .= $this->misc->getButton( array( 'type' => 'primary', 'help' => __( 'Save Method', 'extnify-fee' ), 'class' => 'ext-method-save ext-icon-btn', 'icon' => 'dashicons-saved' ) ); $html .= $this->misc->getButton( array( 'type' => 'secondary', 'help' => __( 'Copy Method', 'extnify-fee' ), 'class' => 'ext-method-copy ext-icon-btn', 'icon' => 'dashicons-admin-page' ) ); $html .= $this->misc->getButton( array( 'type' => 'delete', 'help' => __( 'Remove Method', 'extnify-fee' ), 'class' => 'ext-method-delete ext-icon-btn', 'icon' => 'dashicons-trash' ) ); $html .= '
'; // Top fields (Admin Name + Method Title). $html .= ''; $html .= $this->form->get( 'input', 'display' ); $html .= $this->form->get( 'input', 'name' ); $html .= ''; $mtabs = array( 'general' => __( 'General', 'extnify-fee' ), 'criteria' => __( 'Generic Rules', 'extnify-fee' ), 'catprod' => __( 'Product Rules', 'extnify-fee' ), 'price' => __( 'Price Setting', 'extnify-fee' ), 'event' => __( 'Event/Condition', 'extnify-fee' ), ); $html .= ''; // General tab: Tax Class, Sort Order, Status. $html .= '
'; $html .= ''; $html .= $this->form->get( 'bare', array( 'name' => 'id', 'title' => __( 'Method ID', 'extnify-fee' ), 'element' => '' . esc_html( (string) $method_id ) . '', ) ); $html .= $this->form->get( 'select', 'tax_class_id' ); $html .= $this->form->get( 'input', array( 'name' => 'sort_order', 'type' => 'number' ) ); $html .= $this->form->get( 'select', array( 'name' => 'status', 'title' => __( 'Status', 'extnify-fee' ), 'help' => $this->langs_data['help_method_status'] ) ); $html .= ''; $html .= '
'; // Generic Rules tab: Shipping Zone + Payment Method + Shipping Method. $html .= '
'; $html .= ''; $html .= $this->form->get( 'checkgroup', array( 'name' => 'geo_zone[]', 'search' => true ) ); $html .= $this->form->get( 'checkgroup', array( 'name' => 'payment[]' ) ); $html .= $this->form->get( 'checkgroup', array( 'name' => 'shipping[]' ) ); $html .= ''; $html .= $this->pro_placeholder( __( 'More rules (product, category, country, customer, date/time & more) are available in Pro Version', 'extnify-fee' ), 'dashicons-info-outline' ); $html .= '
'; // Product Rules tab: placeholder. $html .= '
' . $this->pro_placeholder() . '
'; // Price Setting tab: free = a flat amount (plain number, or N% of subtotal). $html .= '
'; $html .= ''; $html .= $this->form->get( 'select', 'rate_type' ); $html .= $this->form->get( 'input', array( 'name' => 'cost', 'class' => 'ext-dep rate_type flat' ) ); $html .= ''; $html .= $this->pro_placeholder( __( 'Percentage bases, tiered ranges, min/max caps, equations & more are available in Pro Version', 'extnify-fee' ), 'dashicons-info-outline' ); $html .= '
'; // Event/Condition tab: placeholder. $html .= '
' . $this->pro_placeholder() . '
'; $html .= '
'; return $html; } // ── AJAX ──────────────────────────────────────────────────────────────────── private function check_ajax_auth() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'extnify-fee' ) ), 403 ); } check_ajax_referer( 'ext_method_action', 'nonce' ); } public function ajax_reorder_methods() { $this->check_ajax_auth(); $order = isset( $_POST['order'] ) ? array_map( 'intval', (array) $_POST['order'] ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in check_ajax_auth() foreach ( $order as $pos => $id ) { if ( $id > 0 ) { $this->model->updateSortOrder( $id, $pos ); } } wp_send_json_success(); } public function ajax_load_method() { $this->check_ajax_auth(); $id = isset( $_POST['method_id'] ) ? (int) $_POST['method_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in check_ajax_auth() $postfix = isset( $_POST['postfix'] ) ? sanitize_html_class( wp_unslash( $_POST['postfix'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in check_ajax_auth() $row = $id ? $this->model->getDataById( $id ) : null; $mdata = $row ? $row['method_data'] : array(); $this->form->setBasename( 'xfeepro' )->setLangs( $this->langs_data ); $id_postfix = $id ? (string) $id : ( $postfix ?: 'new' ); wp_send_json_success( array( 'html' => $this->get_form_data( $mdata, $id, $id_postfix ), 'id' => $id ) ); } public function ajax_save_method() { $this->check_ajax_auth(); $id = isset( $_POST['ext_method_id'] ) ? sanitize_text_field( wp_unslash( $_POST['ext_method_id'] ) ) : 'new'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in check_ajax_auth() $posted = isset( $_POST['xfeepro'] ) ? (array) wp_unslash( $_POST['xfeepro'] ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified in check_ajax_auth(); sanitized in sanitize_method() $is_new = ( 'new' === $id || ! $id ); $mdata = $this->sanitize_method( $posted, $is_new ? 0 : (int) $id ); $display = isset( $mdata['display'] ) ? $mdata['display'] : __( 'New Method', 'extnify-fee' ); if ( $is_new ) { $new_id = $this->model->addData( $mdata ); wp_send_json_success( array( 'id' => $new_id, 'display' => $display, 'is_new' => true ) ); } $this->model->updateData( (int) $id, $mdata ); wp_send_json_success( array( 'id' => (int) $id, 'display' => $display, 'is_new' => false ) ); } public function ajax_delete_method() { $this->check_ajax_auth(); $id = isset( $_POST['method_id'] ) ? (int) $_POST['method_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in check_ajax_auth() if ( $id ) { $this->model->deleteData( $id ); } wp_send_json_success( array( 'id' => $id ) ); } public function ajax_copy_method() { $this->check_ajax_auth(); $id = isset( $_POST['method_id'] ) ? (int) $_POST['method_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in check_ajax_auth() $new_id = $id ? $this->model->copyData( $id ) : false; if ( ! $new_id ) { wp_send_json_error( array( 'message' => __( 'Method not found.', 'extnify-fee' ) ) ); } $row = $this->model->getDataById( $new_id ); $display = isset( $row['method_data']['display'] ) ? $row['method_data']['display'] : 'Method #' . $new_id; wp_send_json_success( array( 'id' => $new_id, 'display' => $display ) ); } /** Read the debug log from the DB store as { time, level, message } entries. */ public function ajax_debug_log() { $this->check_ajax_auth(); $limit = isset( $_REQUEST['limit'] ) ? max( 1, min( 500, (int) $_REQUEST['limit'] ) ) : 60; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified in check_ajax_auth(); value is int-clamped $log = get_transient( 'x_feepro_debug_log' ); $entries = array(); if ( is_array( $log ) ) { foreach ( array_slice( $log, -$limit ) as $row ) { $entries[] = array( 'time' => isset( $row['time'] ) ? (string) $row['time'] : '', 'level' => isset( $row['level'] ) ? (string) $row['level'] : 'info', 'message' => wp_strip_all_tags( isset( $row['message'] ) ? (string) $row['message'] : '' ), ); } } wp_send_json_success( array( 'entries' => $entries ) ); } /** Clear the debug log. */ public function ajax_debug_clear() { $this->check_ajax_auth(); delete_transient( 'x_feepro_debug_log' ); wp_send_json_success(); } /** * Sanitise ONLY the free fields. IMPORTANT: this merges onto the existing row * so any Pro-configured fields already saved in the JSON are preserved (a free * user editing a Pro-made method won't wipe its Pro settings). */ private function sanitize_method( array $data, $existing_id = 0 ) { // Start from the existing row so Pro-only fields already saved in the JSON // are preserved when a free user edits a Pro-created method. $existing = array(); if ( $existing_id ) { $row = $this->model->getDataById( (int) $existing_id ); $existing = $row ? (array) $row['method_data'] : array(); } $clean = $existing; foreach ( array( 'display', 'name' ) as $k ) { if ( isset( $data[ $k ] ) ) { $clean[ $k ] = sanitize_text_field( $data[ $k ] ); } } $clean['tax_class_id'] = isset( $data['tax_class_id'] ) ? sanitize_text_field( $data['tax_class_id'] ) : ''; $clean['sort_order'] = isset( $data['sort_order'] ) ? absint( $data['sort_order'] ) : 0; $clean['status'] = ! empty( $data['status'] ) ? '1' : '0'; // Free supports only a flat amount (plain number or N% of subtotal). $clean['rate_type'] = 'flat'; $clean['cost'] = isset( $data['cost'] ) ? sanitize_text_field( $data['cost'] ) : ''; // Rules: Shipping Zone / Payment Method / Shipping Method. "For Any" wins when // its checkbox is ticked (…_all posts '1') OR nothing is selected — even if the // hidden per-item checkboxes still submitted their values. foreach ( array( 'geo_zone', 'payment', 'shipping' ) as $rule ) { $clean[ $rule ] = isset( $data[ $rule ] ) ? array_map( 'sanitize_text_field', (array) $data[ $rule ] ) : array(); $clean[ $rule . '_all' ] = ( ! empty( $data[ $rule . '_all' ] ) || empty( $clean[ $rule ] ) ) ? '1' : '0'; } return $clean; } // ── Assets ───────────────────────────────────────────────────────────────── public function enqueue_assets( $hook ) { if ( 'woocommerce_page_' . self::slug() !== $hook ) { return; } $theme_path = XFEE_PLUGIN_PATH . 'assets/css/theme.css'; $css_path = XFEE_PLUGIN_PATH . 'assets/css/admin.css'; $js_path = XFEE_PLUGIN_PATH . 'assets/js/admin.js'; // Shared Extnify theme (brand tokens + components) loads first… wp_enqueue_style( 'extnify-theme', XFEE_PLUGIN_URL . 'assets/css/theme.css', array(), file_exists( $theme_path ) ? filemtime( $theme_path ) : XFEE_PLUGIN_VERSION ); // …then this plugin's own overrides, so they win via the cascade. wp_enqueue_style( 'extnify-fee-admin', XFEE_PLUGIN_URL . 'assets/css/admin.css', array( 'extnify-theme' ), file_exists( $css_path ) ? filemtime( $css_path ) : XFEE_PLUGIN_VERSION ); wp_enqueue_script( 'extnify-fee-admin', XFEE_PLUGIN_URL . 'assets/js/admin.js', array( 'jquery', 'jquery-ui-sortable' ), file_exists( $js_path ) ? filemtime( $js_path ) : XFEE_PLUGIN_VERSION, true ); wp_localize_script( 'extnify-fee-admin', 'extnifyFeeAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'prefix' => self::group() . '_', 'nonce' => wp_create_nonce( 'ext_method_action' ), 'methodNonce' => wp_create_nonce( 'ext_method_action' ), 'i18n' => array( 'saving' => __( 'Saving…', 'extnify-fee' ), 'saved' => __( 'Saved!', 'extnify-fee' ), 'confirmDelete' => __( 'Remove this method?', 'extnify-fee' ), 'loadError' => __( 'Error loading method.', 'extnify-fee' ), 'saveError' => __( 'Error saving method.', 'extnify-fee' ), ), ) ); } // ── View loader ─────────────────────────────────────────────────────────── private function view( $template, array $data = array() ) { // basename() strips any path components, so $template can only resolve to a // file inside admin/views/ — no directory traversal via the include below. $file = XFEE_PLUGIN_PATH . 'admin/views/' . basename( (string) $template ) . '.php'; if ( ! file_exists( $file ) ) { return; } extract( $data, EXTR_SKIP ); // phpcs:ignore WordPress.PHP.DontExtract include $file; } }