Variation IDs already queued during this request. */ private static $queued_variation_syncs = array(); /** @var array> Latest admin/API update for each product in this request. */ private static $pending_immediate_updates = array(); /** @var bool Whether the end-of-request delivery callback has been registered. */ private static $immediate_flush_registered = false; /** * Boot the plugin hooks. */ public static function init(): void { self::migrate_legacy_storage(); self::migrate_default_api_url(); add_action( 'before_woocommerce_init', array( __CLASS__, 'declare_woocommerce_compatibility' ) ); add_action( 'admin_menu', array( __CLASS__, 'add_settings_page' ) ); add_action( 'admin_init', array( __CLASS__, 'register_settings' ) ); add_action( 'admin_init', array( __CLASS__, 'process_webhook_debug_actions' ) ); add_action( 'admin_init', array( __CLASS__, 'process_update_history_actions' ) ); add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_assets' ) ); add_action( 'wp_footer', array( __CLASS__, 'render_widget' ) ); add_action( 'admin_notices', array( __CLASS__, 'render_woocommerce_missing_notice' ) ); add_action( 'add_meta_boxes_product', array( __CLASS__, 'add_product_availability_meta_box' ) ); add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'sync_product_stock_status' ), 10, 3 ); add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'sync_product_stock_status' ), 10, 3 ); add_action( 'woocommerce_product_object_updated_props', array( __CLASS__, 'sync_product_update' ), 10, 2 ); add_action( 'woocommerce_save_product_variation', array( __CLASS__, 'sync_saved_variation' ), 20, 1 ); add_action( 'woocommerce_update_product_variation', array( __CLASS__, 'sync_saved_variation' ), 20, 1 ); add_action( 'woocommerce_new_product_variation', array( __CLASS__, 'sync_saved_variation' ), 20, 1 ); add_action( 'woocommerce_reduce_order_stock', array( __CLASS__, 'sync_order_stock_statuses' ), 20, 1 ); add_action( self::ASYNC_HOOK, array( __CLASS__, 'process_async_update' ), 10, 6 ); add_filter( 'plugin_action_links_' . plugin_basename( self::get_plugin_file() ), array( __CLASS__, 'add_settings_link' ) ); } /** * Copy settings and histories from the plugin's previous technical slug. * Legacy data is retained as a rollback-safe backup. */ private static function migrate_legacy_storage(): void { $legacy_options = get_option( self::LEGACY_OPTION_KEY, null ); if ( null === get_option( self::OPTION_KEY, null ) && is_array( $legacy_options ) ) { add_option( self::OPTION_KEY, $legacy_options, '', false ); } $legacy_debug = get_option( self::LEGACY_DEBUG_LOG_OPTION, null ); if ( null === get_option( self::DEBUG_LOG_OPTION, null ) && is_array( $legacy_debug ) ) { add_option( self::DEBUG_LOG_OPTION, $legacy_debug, '', false ); } $legacy_history = get_option( self::LEGACY_UPDATE_HISTORY_OPTION, null ); if ( null === get_option( self::UPDATE_HISTORY_OPTION, null ) && is_array( $legacy_history ) ) { add_option( self::UPDATE_HISTORY_OPTION, $legacy_history, '', false ); } } /** * Move sites using the previous plugin default to the current production URL. * Custom endpoint overrides are deliberately left unchanged. */ private static function migrate_default_api_url(): void { $options = get_option( self::OPTION_KEY, array() ); if ( ! is_array( $options ) || empty( $options['api_url'] ) || self::LEGACY_API_URL !== $options['api_url'] ) { return; } $options['api_url'] = self::API_URL; update_option( self::OPTION_KEY, $options ); } /** * Remove pending background actions when the plugin is deactivated. */ public static function deactivate(): void { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( self::ASYNC_HOOK, array(), self::ASYNC_GROUP ); } wp_clear_scheduled_hook( self::ASYNC_HOOK ); } /** * Declare compatibility with WooCommerce features when WooCommerce is active. */ public static function declare_woocommerce_compatibility(): void { if ( class_exists( 'Automattic\\WooCommerce\\Utilities\\FeaturesUtil' ) ) { \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', self::get_plugin_file(), true ); } } /** * Show a clear admin notice when WooCommerce is not active. */ public static function render_woocommerce_missing_notice(): void { if ( class_exists( 'WooCommerce' ) || ! current_user_can( 'activate_plugins' ) ) { return; } ?>

'array', 'sanitize_callback' => array( __CLASS__, 'sanitize_options' ), 'default' => self::get_default_options(), ) ); } /** * Sanitize settings submitted from the admin page. * * @param mixed $options Raw options. * @return array */ public static function sanitize_options( $options ): array { if ( ! is_array( $options ) ) { $options = array(); } $defaults = self::get_default_options(); $current = self::get_options(); $sanitized = array(); $sanitized['phone'] = isset( $options['phone'] ) ? preg_replace( '/[^0-9]/', '', (string) $options['phone'] ) : $current['phone']; $position = isset( $options['position'] ) ? sanitize_key( (string) $options['position'] ) : $current['position']; if ( ! array_key_exists( $position, self::get_positions() ) ) { $position = $defaults['position']; } $sanitized['position'] = $position; $sanitized['message'] = isset( $options['message'] ) ? sanitize_textarea_field( (string) $options['message'] ) : $current['message']; $api_key = isset( $options['api_key'] ) ? sanitize_text_field( (string) $options['api_key'] ) : ''; $sanitized['api_key'] = '' !== $api_key && self::API_KEY_MASK !== $api_key ? $api_key : $current['api_key']; $api_url = isset( $options['api_url'] ) ? esc_url_raw( (string) $options['api_url'] ) : null; if ( null === $api_url ) { $sanitized['api_url'] = $current['api_url']; } elseif ( '' !== $api_url && 'https' === wp_parse_url( $api_url, PHP_URL_SCHEME ) ) { $sanitized['api_url'] = $api_url; } else { $sanitized['api_url'] = $current['api_url']; add_settings_error( self::OPTION_KEY, 'agent_zappy_invalid_api_url', __( 'The product webhook must be a valid HTTPS URL.', 'agent-zappy' ), 'error' ); } $auto_sync = isset( $options['auto_sync'] ) ? sanitize_key( (string) $options['auto_sync'] ) : $current['auto_sync']; $sanitized['auto_sync'] = 'no' === $auto_sync ? 'no' : 'yes'; $auto_sync_price = isset( $options['auto_sync_price'] ) ? sanitize_key( (string) $options['auto_sync_price'] ) : $current['auto_sync_price']; $sanitized['auto_sync_price'] = 'yes' === $auto_sync_price ? 'yes' : 'no'; $debug_enabled = isset( $options['debug_enabled'] ) ? sanitize_key( (string) $options['debug_enabled'] ) : $current['debug_enabled']; $sanitized['debug_enabled'] = 'yes' === $debug_enabled ? 'yes' : 'no'; return $sanitized; } /** * Clear saved webhook debug entries from the settings screen. */ public static function process_webhook_debug_actions(): void { if ( ! isset( $_POST['agent_zappy_clear_webhook_debug'] ) || ! current_user_can( 'manage_options' ) ) { return; } check_admin_referer( 'agent_zappy_clear_webhook_debug', 'agent_zappy_clear_webhook_debug_nonce' ); update_option( self::DEBUG_LOG_OPTION, array(), false ); self::add_admin_message( __( 'Webhook debug history cleared.', 'agent-zappy' ), 'success' ); } /** * Clear the customer-friendly product update history. */ public static function process_update_history_actions(): void { if ( ! isset( $_POST['agent_zappy_clear_update_history'] ) || ! current_user_can( 'manage_options' ) ) { return; } check_admin_referer( 'agent_zappy_clear_update_history', 'agent_zappy_clear_update_history_nonce' ); update_option( self::UPDATE_HISTORY_OPTION, array(), false ); self::add_admin_message( __( 'Recent product update history cleared.', 'agent-zappy' ), 'success' ); } /** * Add the Agent Zappy availability box to WooCommerce products. */ public static function add_product_availability_meta_box(): void { add_meta_box( 'agent-zappy-product-availability', __( 'Agent Zappy Availability', 'agent-zappy' ), array( __CLASS__, 'render_product_availability_meta_box' ), 'product', 'side', 'high' ); } /** * Render the Agent Zappy WooCommerce product availability box. * * @param WP_Post $post Product post. */ public static function render_product_availability_meta_box( $post ): void { if ( ! function_exists( 'wc_get_product' ) ) { esc_html_e( 'WooCommerce is required to send product availability updates.', 'agent-zappy' ); return; } $product = wc_get_product( $post->ID ); if ( ! $product ) { esc_html_e( 'Unable to load this WooCommerce product.', 'agent-zappy' ); return; } $product_identifier = self::get_product_id_for_api( $product ); $stock_status = $product->get_stock_status(); $last_sync = get_post_meta( $post->ID, self::SYNC_META_KEY, true ); $product_settings_url = admin_url( 'admin.php?page=' . self::MENU_SLUG ); ?>



Agent Zappy settings.', 'agent-zappy' ), esc_url( $product_settings_url ) ) ); ?>

$product_identifier, 'stock_status' => $stock_status, ); if ( null !== $price_minor_units ) { $payload['price'] = $price_minor_units; } $request_args = array( 'timeout' => max( 1, min( 15, $timeout ) ), 'redirection' => 0, 'reject_unsafe_urls' => true, 'limit_response_size' => 100000, 'headers' => array( 'x-wc-zappy' => $api_key, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode( $payload ), ); $response = self::send_availability_request( $api_url, $request_args, $retry_timeout ); if ( is_wp_error( $response ) ) { self::record_webhook_debug( $api_url, $payload, 0, '', $response->get_error_message() ); self::record_update_history( $payload, false ); return new WP_Error( 'agent_zappy_request_failed', sprintf( /* translators: %s: WordPress HTTP API error message. */ __( 'Agent Zappy API request failed: %s', 'agent-zappy' ), $response->get_error_message() ) ); } $status_code = wp_remote_retrieve_response_code( $response ); $response_body = wp_remote_retrieve_body( $response ); self::record_webhook_debug( $api_url, $payload, $status_code, $response_body ); if ( $status_code < 200 || $status_code >= 300 ) { self::record_update_history( $payload, false ); return new WP_Error( 'agent_zappy_request_rejected', sprintf( /* translators: 1: HTTP status code, 2: API response body. */ __( 'Agent Zappy API rejected the update. HTTP %1$d. Response: %2$s', 'agent-zappy' ), $status_code, $response_body ? wp_strip_all_tags( $response_body ) : __( 'No response body.', 'agent-zappy' ) ), array( 'status_code' => $status_code ) ); } self::record_update_history( $payload, true ); return true; } /** * Store a simple, bounded history of product webhook deliveries. * This intentionally excludes the endpoint, API key, and response body. * * @param array $payload Request JSON payload. * @param bool $successful Whether the endpoint accepted the update. */ private static function record_update_history( array $payload, bool $successful ): void { $entries = get_option( self::UPDATE_HISTORY_OPTION, array() ); if ( ! is_array( $entries ) ) { $entries = array(); } $entry = array( 'time' => current_time( 'mysql' ), 'product_id' => sanitize_text_field( (string) ( $payload['id'] ?? '' ) ), 'stock_status' => sanitize_key( (string) ( $payload['stock_status'] ?? '' ) ), 'successful' => $successful, ); if ( isset( $payload['price'] ) ) { $entry['price'] = (int) $payload['price']; } array_unshift( $entries, $entry ); update_option( self::UPDATE_HISTORY_OPTION, array_slice( $entries, 0, 20 ), false ); } /** * Get the latest customer-friendly product update entries. * * @return array> */ private static function get_update_history_entries(): array { $entries = get_option( self::UPDATE_HISTORY_OPTION, array() ); return is_array( $entries ) ? array_slice( $entries, 0, 20 ) : array(); } /** * Get a friendly label for a WooCommerce stock status. * * @param string $stock_status WooCommerce stock status. */ private static function get_stock_status_label( string $stock_status ): string { $labels = array( 'instock' => __( 'In stock', 'agent-zappy' ), 'outofstock' => __( 'Out of stock', 'agent-zappy' ), 'onbackorder' => __( 'On backorder', 'agent-zappy' ), ); return $labels[ $stock_status ] ?? ucwords( str_replace( array( '-', '_' ), ' ', $stock_status ) ); } /** * Explain a webhook price in both API units and store-currency format. * * @param int $price_minor_units Price sent to the API. */ private static function get_sent_price_label( int $price_minor_units ): string { $decimals = function_exists( 'wc_get_price_decimals' ) ? max( 0, (int) wc_get_price_decimals() ) : 2; $display_amount = $price_minor_units / pow( 10, $decimals ); $formatted = function_exists( 'wc_price' ) ? wp_specialchars_decode( wp_strip_all_tags( wc_price( $display_amount ) ), ENT_QUOTES ) : number_format_i18n( $display_amount, $decimals ); if ( 2 === $decimals ) { $unit = _n( 'cent', 'cents', abs( $price_minor_units ), 'agent-zappy' ); } else { $unit = __( 'smallest currency units', 'agent-zappy' ); } return sprintf( /* translators: 1: integer sent to API, 2: cents or smallest currency units, 3: formatted WooCommerce price. */ __( 'Price sent: %1$s %2$s (%3$s)', 'agent-zappy' ), number_format_i18n( $price_minor_units, 0 ), $unit, $formatted ); } /** * Store a bounded history of webhook activity for administrators. * The authentication header is deliberately never included. * * @param string $api_url Endpoint URL. * @param array $payload Request JSON payload. * @param int $status_code HTTP response status, or zero for transport errors. * @param string $response_body Response body. * @param string $error Transport error, if any. */ private static function record_webhook_debug( string $api_url, array $payload, int $status_code, string $response_body = '', string $error = '' ): void { $options = self::get_options(); if ( 'yes' !== $options['debug_enabled'] ) { return; } $entries = get_option( self::DEBUG_LOG_OPTION, array() ); if ( ! is_array( $entries ) ) { $entries = array(); } array_unshift( $entries, array( 'time' => current_time( 'mysql' ), 'url' => esc_url_raw( $api_url ), 'payload' => $payload, 'status_code' => $status_code, 'response_body' => substr( wp_strip_all_tags( $response_body ), 0, 4000 ), 'error' => sanitize_text_field( $error ), ) ); update_option( self::DEBUG_LOG_OPTION, array_slice( $entries, 0, 20 ), false ); } /** * Get recent webhook debug entries. * * @return array> */ private static function get_webhook_debug_entries(): array { $entries = get_option( self::DEBUG_LOG_OPTION, array() ); return is_array( $entries ) ? $entries : array(); } /** * Send the API request with one timeout retry for transient network issues. * * @param string $api_url Endpoint URL. * @param array $request_args WP HTTP API args. * @param bool $retry_timeout Whether to retry one timeout. * @return array|WP_Error */ private static function send_availability_request( string $api_url, array $request_args, bool $retry_timeout = true ) { $response = wp_safe_remote_post( $api_url, $request_args ); if ( $retry_timeout && is_wp_error( $response ) && self::is_timeout_error( $response ) ) { $response = wp_safe_remote_post( $api_url, $request_args ); } return $response; } /** * Determine whether a WP error represents a request timeout. * * @param WP_Error $error Request error. * @return bool */ private static function is_timeout_error( WP_Error $error ): bool { if ( 'http_request_timeout' === $error->get_error_code() ) { return true; } return false !== stripos( $error->get_error_message(), 'cURL error 28' ); } /** * Automatically sync Agent Zappy when WooCommerce stock status changes. * * @param int $product_id Product or variation ID. * @param string $stock_status WooCommerce stock status. * @param WC_Product|null $product Product object when WooCommerce provides it. */ public static function sync_product_stock_status( $product_id, $stock_status, $product = null ): void { if ( ! $product && function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $product_id ); } if ( ! $product || ! is_a( $product, 'WC_Product' ) ) { return; } self::sync_product_availability( $product, (string) $stock_status ); } /** * Sync a variation saved through the WooCommerce product-editor variation panel. * * @param int $variation_id WooCommerce variation ID. */ public static function sync_saved_variation( $variation_id ): void { $variation_id = (int) $variation_id; if ( isset( self::$queued_variation_syncs[ $variation_id ] ) ) { return; } if ( ! function_exists( 'wc_get_product' ) ) { return; } $variation = wc_get_product( $variation_id ); if ( ! $variation || ! is_a( $variation, 'WC_Product_Variation' ) ) { return; } $options = self::get_options(); if ( 'yes' !== $options['auto_sync'] || '' === self::get_saved_api_key() || '' === $options['api_url'] ) { return; } self::$queued_variation_syncs[ $variation_id ] = true; self::sync_product_availability( $variation, (string) $variation->get_stock_status() ); } /** * Sync line-item product availability after WooCommerce reduces stock for an order. * * This protects the purchase flow even when a store configuration/plugin reduces * stock quantity to out of stock without separately firing the stock-status action. * * @param WC_Order $order WooCommerce order whose stock was reduced. */ public static function sync_order_stock_statuses( $order ): void { if ( ! $order || ! is_a( $order, 'WC_Order' ) ) { return; } foreach ( $order->get_items() as $item ) { if ( ! is_a( $item, 'WC_Order_Item_Product' ) ) { continue; } $product = $item->get_product(); if ( ! $product || ! is_a( $product, 'WC_Product' ) ) { continue; } $stock_status = (string) $product->get_stock_status(); if ( 'outofstock' !== $stock_status ) { continue; } self::sync_product_availability( $product, $stock_status ); } } /** * Sync stock status and price changes saved through a WooCommerce product object. * * @param WC_Product $product Product object. * @param array $updated_props Updated properties. */ public static function sync_product_update( $product, array $updated_props ): void { if ( ! $product || ! is_a( $product, 'WC_Product' ) ) { return; } $options = self::get_options(); $updated_props = array_map( 'strval', $updated_props ); $stock_props = array( 'stock_status', 'stock_quantity', 'manage_stock' ); if ( 'yes' === $options['auto_sync'] && 0 < count( array_intersect( $stock_props, $updated_props ) ) ) { self::sync_product_availability( $product, (string) $product->get_stock_status() ); return; } if ( 'yes' !== $options['auto_sync_price'] || '' === self::get_saved_api_key() || '' === $options['api_url'] ) { return; } $price_props = array( 'price', 'regular_price', 'sale_price' ); if ( 0 === count( array_intersect( $price_props, $updated_props ) ) ) { return; } $product_id = (int) $product->get_id(); $product_identifier = self::get_product_id_for_api( $product ); $stock_status = (string) $product->get_stock_status(); $price_minor_units = self::to_price_minor_units( (float) $product->get_price() ); self::enqueue_async_update( $product_id, $product_identifier, $stock_status, $price_minor_units ); } /** * Sync a WooCommerce product stock status to Agent Zappy. * * @param WC_Product $product WooCommerce product. * @param string $stock_status WooCommerce stock status. */ private static function sync_product_availability( $product, string $stock_status ): void { $options = self::get_options(); if ( 'yes' !== $options['auto_sync'] || '' === self::get_saved_api_key() || '' === $options['api_url'] ) { return; } $product_id = (int) $product->get_id(); $product_identifier = self::get_product_id_for_api( $product ); $price_minor_units = self::to_price_minor_units( (float) $product->get_price() ); self::enqueue_async_update( $product_id, $product_identifier, $stock_status, $price_minor_units ); } /** * Queue an async availability/price update to avoid blocking user flows. * * @param int $product_id Product ID. * @param string $product_identifier WooCommerce product or variation ID. * @param string $stock_status WooCommerce stock status. * @param int|null $price_minor_units Optional price in the store currency's minor units. */ private static function enqueue_async_update( int $product_id, string $product_identifier, string $stock_status, ?int $price_minor_units ): void { $sync_version = (int) get_post_meta( $product_id, self::SYNC_VERSION_META_KEY, true ) + 1; update_post_meta( $product_id, self::SYNC_VERSION_META_KEY, $sync_version ); $args = array( $product_id, $product_identifier, $stock_status, $price_minor_units, 1, $sync_version ); if ( self::should_deliver_immediately() ) { self::$pending_immediate_updates[ $product_id ] = $args; if ( ! self::$immediate_flush_registered ) { self::$immediate_flush_registered = true; add_action( 'shutdown', array( __CLASS__, 'flush_immediate_updates' ), 20 ); } self::record_availability_sync_result( $product_id, 'info', __( 'Agent Zappy will send this update as soon as WooCommerce finishes saving.', 'agent-zappy' ) ); return; } $scheduled = false; if ( function_exists( 'as_enqueue_async_action' ) ) { $scheduled = 0 < (int) as_enqueue_async_action( self::ASYNC_HOOK, $args, self::ASYNC_GROUP ); } else { $scheduled = (bool) wp_schedule_single_event( time() + 1, self::ASYNC_HOOK, $args ); } if ( ! $scheduled ) { self::record_availability_sync_result( $product_id, 'error', __( 'Agent Zappy could not queue the webhook update.', 'agent-zappy' ) ); return; } self::record_availability_sync_result( $product_id, 'info', sprintf( /* translators: %s: WooCommerce product ID. */ __( 'Queued background sync for WooCommerce product ID %s.', 'agent-zappy' ), $product_identifier ) ); } /** * Deliver admin and REST product saves without depending on WP-Cron. * Repeated WooCommerce hooks are collapsed to the final state per product. */ public static function flush_immediate_updates(): void { $updates = self::$pending_immediate_updates; self::$pending_immediate_updates = array(); foreach ( $updates as $update ) { list( $product_id, $product_identifier, $stock_status, $price_minor_units, $attempt, $sync_version ) = $update; if ( (int) get_post_meta( $product_id, self::SYNC_VERSION_META_KEY, true ) !== $sync_version ) { continue; } $result = self::send_product_update( $product_identifier, $stock_status, $price_minor_units, 5, false ); if ( is_wp_error( $result ) ) { if ( self::should_retry_async_error( $result ) ) { self::schedule_async_retry( $product_id, $product_identifier, $stock_status, $price_minor_units, $attempt + 1, $sync_version ); self::record_availability_sync_result( $product_id, 'info', __( 'The immediate update could not connect. Agent Zappy queued an automatic retry.', 'agent-zappy' ) ); continue; } self::record_availability_sync_result( $product_id, 'error', $result->get_error_message() ); continue; } self::record_availability_sync_result( $product_id, 'success', sprintf( /* translators: %s: WooCommerce product or variation ID. */ __( 'Agent Zappy sent the latest update for product ID %s.', 'agent-zappy' ), $product_identifier ) ); } } /** * Determine whether this request should deliver immediately after saving. */ private static function should_deliver_immediately(): bool { if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) { return false; } return is_admin() || ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ); } /** * Process a queued async availability/price update. * * @param int $product_id Product ID. * @param string $product_identifier WooCommerce product or variation ID. * @param string $stock_status WooCommerce stock status. * @param int|null $price_minor_units Optional price in the store currency's minor units. * @param int $sync_version Product sync version used to discard stale jobs. */ public static function process_async_update( int $product_id, string $product_identifier, string $stock_status, ?int $price_minor_units = null, int $attempt = 1, int $sync_version = 0 ): void { if ( 0 !== $sync_version && (int) get_post_meta( $product_id, self::SYNC_VERSION_META_KEY, true ) !== $sync_version ) { return; } // Jobs queued before the webhook contract used yes/no availability values. // Reload the product so those jobs send the current native WooCommerce state. if ( in_array( $stock_status, array( 'yes', 'no' ), true ) && function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $product_id ); if ( $product && is_a( $product, 'WC_Product' ) ) { $product_identifier = self::get_product_id_for_api( $product ); $stock_status = (string) $product->get_stock_status(); $price_minor_units = self::to_price_minor_units( (float) $product->get_price() ); } } $result = self::send_product_update( $product_identifier, $stock_status, $price_minor_units ); if ( is_wp_error( $result ) ) { if ( $attempt < self::ASYNC_MAX_ATTEMPTS && self::should_retry_async_error( $result ) ) { self::schedule_async_retry( $product_id, $product_identifier, $stock_status, $price_minor_units, $attempt + 1, $sync_version ); /* translators: 1: Retry attempt number, 2: Maximum attempts, 3: Error message. */ self::record_availability_sync_result( $product_id, 'info', sprintf( __( 'Background sync retry %1$d/%2$d scheduled: %3$s', 'agent-zappy' ), $attempt + 1, self::ASYNC_MAX_ATTEMPTS, $result->get_error_message() ) ); return; } self::record_availability_sync_result( $product_id, 'error', $result->get_error_message() ); return; } self::record_availability_sync_result( $product_id, 'success', sprintf( /* translators: 1: WooCommerce product ID, 2: WooCommerce stock status. */ __( 'Background sync sent WooCommerce product ID %1$s to Agent Zappy with stock status %2$s.', 'agent-zappy' ), $product_identifier, $stock_status ) ); } /** * Schedule a retry for async updates with basic backoff. */ private static function schedule_async_retry( int $product_id, string $product_identifier, string $stock_status, ?int $price_minor_units, int $attempt, int $sync_version ): void { $delays = array( 60, 300, 900 ); $delay = $delays[ min( max( 0, $attempt - 2 ), count( $delays ) - 1 ) ]; $args = array( $product_id, $product_identifier, $stock_status, $price_minor_units, $attempt, $sync_version ); if ( function_exists( 'as_schedule_single_action' ) ) { as_schedule_single_action( time() + $delay, self::ASYNC_HOOK, $args, self::ASYNC_GROUP ); return; } wp_schedule_single_event( time() + $delay, self::ASYNC_HOOK, $args ); } /** * Determine whether async errors should be retried. */ private static function should_retry_async_error( WP_Error $error ): bool { if ( self::is_timeout_error( $error ) ) { return true; } if ( 'agent_zappy_request_failed' === $error->get_error_code() ) { return true; } $data = $error->get_error_data(); $status_code = is_array( $data ) && isset( $data['status_code'] ) ? (int) $data['status_code'] : 0; return 429 === $status_code || $status_code >= 500; } /** * Get queued async update count for health display. */ private static function get_async_queue_count(): int { if ( function_exists( 'as_get_scheduled_actions' ) ) { $ids = as_get_scheduled_actions( array( 'hook' => self::ASYNC_HOOK, 'group' => self::ASYNC_GROUP, 'status' => 'pending', 'per_page' => 100 ) ); return is_array( $ids ) ? count( $ids ) : 0; } $cron = _get_cron_array(); if ( ! is_array( $cron ) ) { return 0; } $count = 0; foreach ( $cron as $events ) { if ( isset( $events[ self::ASYNC_HOOK ] ) ) { $count += count( $events[ self::ASYNC_HOOK ] ); } } return $count; } /** * Get the WooCommerce product ID to send to Agent Zappy. * * @param WC_Product $product WooCommerce product. * @return string */ private static function get_product_id_for_api( $product ): string { return (string) $product->get_id(); } /** * Convert a decimal WooCommerce price to the store currency's minor units. * * @param float $price Price value. * @return int */ private static function to_price_minor_units( float $price ): int { $decimals = function_exists( 'wc_get_price_decimals' ) ? (int) wc_get_price_decimals() : 2; return (int) round( $price * pow( 10, max( 0, $decimals ) ) ); } /** * Store the latest Agent Zappy sync result on the WooCommerce product. * * @param int $product_id Product ID. * @param string $status Sync status. * @param string $message Sync message. */ private static function record_availability_sync_result( int $product_id, string $status, string $message ): void { update_post_meta( $product_id, self::SYNC_META_KEY, array( 'status' => $status, 'message' => $message, 'time' => current_time( 'mysql' ), ) ); } /** * Add an admin message for the Agent Zappy settings screen. * * @param string $message Message text. * @param string $type Message type. */ private static function add_admin_message( string $message, string $type ): void { add_settings_error( 'agent_zappy_messages', 'agent_zappy_message', $message, $type ); } /** * Add a settings link on the Plugins screen. * * @param array $links Existing plugin links. * @return array */ public static function add_settings_link( array $links ): array { $settings_url = admin_url( 'admin.php?page=' . self::MENU_SLUG ); array_unshift( $links, sprintf( '%2$s', esc_url( $settings_url ), esc_html__( 'Settings', 'agent-zappy' ) ) ); return $links; } /** * Enqueue front-end styles only when the widget can render. */ public static function enqueue_assets(): void { $options = self::get_options(); if ( empty( $options['phone'] ) || is_admin() ) { return; } wp_enqueue_style( 'agent-zappy', self::get_asset_url( 'css/widget.css' ), array(), self::VERSION ); } /** * Enqueue Agent Zappy admin styles on plugin screens. * * @param string $hook_suffix Current admin page hook suffix. */ public static function enqueue_admin_assets( string $hook_suffix ): void { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $is_agent_zappy_screen = 'toplevel_page_' . self::MENU_SLUG === $hook_suffix; $is_product_screen = $screen && 'product' === $screen->post_type; if ( ! $is_agent_zappy_screen && ! $is_product_screen ) { return; } wp_enqueue_style( 'agent-zappy-admin', self::get_asset_url( 'css/admin.css' ), array(), self::VERSION ); if ( $is_agent_zappy_screen ) { wp_enqueue_script( 'agent-zappy-admin', self::get_asset_url( 'js/admin.js' ), array(), self::VERSION, true ); } } /** * Render the floating WhatsApp widget in the site footer. */ public static function render_widget(): void { $options = self::get_options(); $phone = $options['phone']; if ( empty( $phone ) || is_admin() ) { return; } $position = array_key_exists( $options['position'], self::get_positions() ) ? $options['position'] : 'bottom-right'; $wa_url = 'https://wa.me/' . rawurlencode( $phone ); $message = $options['message']; if ( function_exists( 'is_product' ) && is_product() && function_exists( 'wc_get_product' ) ) { $product = wc_get_product( get_queried_object_id() ); if ( $product ) { $product_name = sanitize_text_field( wp_strip_all_tags( $product->get_name() ) ); if ( '' !== $product_name ) { $message = sprintf( /* translators: %s: WooCommerce product name. */ __( 'Hi, I need some info on %s', 'agent-zappy' ), $product_name ); } } } if ( '' !== $message ) { $wa_url = add_query_arg( 'text', $message, $wa_url ); } ?>

1

2

data-agent-zappy-masked="true" />

*/ private static function get_options(): array { $options = get_option( self::OPTION_KEY, array() ); if ( ! is_array( $options ) ) { $options = array(); } return wp_parse_args( $options, self::get_default_options() ); } /** * Return only the API key explicitly stored by the current settings UI. * Display masks and legacy credential fields are never accepted. */ private static function get_saved_api_key(): string { $options = get_option( self::OPTION_KEY, array() ); if ( ! is_array( $options ) || ! isset( $options['api_key'] ) ) { return ''; } $api_key = trim( (string) $options['api_key'] ); return self::API_KEY_MASK === $api_key ? '' : $api_key; } /** * Default plugin options. * * @return array */ private static function get_default_options(): array { return array( 'phone' => '', 'position' => 'bottom-right', 'message' => '', 'api_key' => '', 'api_url' => self::API_URL, 'auto_sync' => 'yes', 'auto_sync_price' => 'yes', 'debug_enabled' => 'no', ); } /** * Available widget positions. * * @return array */ private static function get_positions(): array { return array( 'bottom-right' => __( 'Bottom right', 'agent-zappy' ), 'bottom-left' => __( 'Bottom left', 'agent-zappy' ), 'top-right' => __( 'Top right', 'agent-zappy' ), 'top-left' => __( 'Top left', 'agent-zappy' ), ); } } if ( ! class_exists( 'ZA_WooCommerce_WhatsApp_Widget', false ) ) { class_alias( 'Agent_Zappy_Plugin', 'ZA_WooCommerce_WhatsApp_Widget' ); } Agent_Zappy_Plugin::init(); register_deactivation_hook( AGENT_ZAPPY_PLUGIN_FILE, array( 'Agent_Zappy_Plugin', 'deactivate' ) ); }