PluginProbe
Live Chat & AI Chatbot – onWebChat / 3.7.1
Live Chat & AI Chatbot – onWebChat v3.7.1
3.9.2 3.9.3 3.9.1 3.9.0 3.8.4 3.8.2 3.8.1 3.8.0 3.7.2 3.7.1 3.7.0 3.6.0 3.5.5 trunk 1.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 All 48 releases
onwebchat / includes / woocommerce-sync.php

woocommerce-sync.php in Live Chat & AI Chatbot – onWebChat 3.7.1, at includes/woocommerce-sync.php

1,449 lines 55.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WooCommerce Product Sync Module
4 * Syncs WooCommerce products to onWebChat for AI bot training
5 */
6
7 if (!defined('ABSPATH')) {
8 exit; // Exit if accessed directly
9 }
10
11 class OnWebChat_WooCommerce_Sync {
12
13 private $api_endpoint_prod = 'https://www.onwebchat.com/api/integrations/woocommerce';
14 private $api_endpoint_dev = 'http://127.0.0.1:81/api/integrations/woocommerce';
15 private $max_description_length = 1000;
16 private $batch_size = 50;
17 private $use_testing_mode;
18
19 // Large-catalogue sync scope.
20 // Stores with more than CATEGORY_SELECT_THRESHOLD published products get a
21 // category picker so the merchant can choose what to sync. The sync is
22 // hard-capped at MAX_SYNC_PRODUCTS so we never try to embed an unbounded
23 // catalogue. Above the cap a category selection is required.
24 const CATEGORY_SELECT_THRESHOLD = 2000;
25 const MAX_SYNC_PRODUCTS = 9000;
26
27 /**
28 * Get the API endpoint based on testing mode
29 * @return string
30 */
31 private function get_api_endpoint() {
32 return $this->use_testing_mode ? $this->api_endpoint_dev : $this->api_endpoint_prod;
33 }
34
35 public function __construct() {
36 // Read testing mode from global constant (defined in onwebchat.php)
37 $this->use_testing_mode = defined('ONWEBCHAT_WC_TESTING_MODE') ? ONWEBCHAT_WC_TESTING_MODE : false;
38 // Initialize settings
39 add_action('admin_init', array($this, 'register_settings'));
40
41 // Show authentication error notice globally (not just on WooCommerce tab)
42 add_action('admin_notices', array($this, 'show_auth_error_notice'));
43
44 // Product hooks - use WooCommerce hooks that fire AFTER meta data is saved
45 add_action('woocommerce_update_product', array($this, 'on_product_update'), 10, 1);
46 add_action('woocommerce_new_product', array($this, 'on_product_update'), 10, 1);
47
48 // Lightweight availability hook: fires whenever a product's stock STATUS flips
49 // (including order-driven stock reductions that may not trigger a full product save).
50 // It pushes only the in/out-of-stock boolean to onWebChat, which updates it without
51 // re-embedding. Variations are intentionally not hooked: WooCommerce recomputes the
52 // parent product's stock status from its variations and fires this action for the
53 // parent, which is the entity synced to onWebChat.
54 add_action('woocommerce_product_set_stock_status', array($this, 'on_stock_status_change'), 10, 3);
55
56 // Handle product deletion (both trash and permanent delete)
57 add_action('wp_trash_post', array($this, 'on_product_trash'), 10, 1);
58 add_action('before_delete_post', array($this, 'on_product_delete'), 10, 2);
59
60 // Bulk sync via WP Cron
61 add_action('onwebchat_wc_bulk_sync_batch', array($this, 'process_bulk_sync_batch'));
62
63 // Admin AJAX handlers
64 add_action('wp_ajax_onwebchat_wc_sync_now', array($this, 'ajax_sync_existing_products'));
65 add_action('wp_ajax_onwebchat_wc_regenerate_secret', array($this, 'ajax_regenerate_secret'));
66 add_action('wp_ajax_onwebchat_wc_reset_sync_status', array($this, 'ajax_reset_sync_status'));
67 add_action('wp_ajax_onwebchat_wc_connect', array($this, 'ajax_connect_woocommerce'));
68 add_action('wp_ajax_onwebchat_wc_manual_process_batch', array($this, 'ajax_manual_process_batch'));
69 add_action('wp_ajax_onwebchat_wc_get_sync_status', array($this, 'ajax_get_sync_status'));
70 add_action('wp_ajax_onwebchat_wc_save_sync_enabled', array($this, 'ajax_save_sync_enabled'));
71 }
72
73 /**
74 * AJAX: Connect WooCommerce with authentication
75 */
76 public function ajax_connect_woocommerce() {
77 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
78
79 if (!current_user_can('manage_options')) {
80 wp_send_json_error('Insufficient permissions');
81 }
82
83 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
84 $password = isset($_POST['password']) ? sanitize_text_field($_POST['password']) : '';
85
86 if (empty($email) || empty($password)) {
87 wp_send_json_error('Email and password are required');
88 }
89
90 $result = $this->request_secret_with_auth($email, $password);
91
92 if ($result['success']) {
93 // Clear any previous authentication errors
94 delete_transient('onwebchat_wc_auth_error');
95
96 wp_send_json_success(array(
97 'message' => 'WooCommerce sync connected successfully!'
98 ));
99 } else {
100 wp_send_json_error($result['error']);
101 }
102 }
103
104 /**
105 * Show authentication error notice globally across all admin pages
106 * (Hidden when already on WooCommerce tab since it has its own error message)
107 */
108 public function show_auth_error_notice() {
109 $auth_error = get_transient('onwebchat_wc_auth_error');
110
111 // Don't show if we're already on the WooCommerce tab (it has its own error message)
112 $is_woocommerce_tab = isset($_GET['page']) && $_GET['page'] === 'onwebchat_settings'
113 && isset($_GET['tab']) && $_GET['tab'] === 'woocommerce';
114
115 if ($auth_error && class_exists('WooCommerce') && !$is_woocommerce_tab) {
116 ?>
117 <div class="notice notice-error">
118 <p>
119 <strong>⚠️ onWebChat WooCommerce Sync Error:</strong>
120 <?php echo esc_html($auth_error); ?>
121 <a href="<?php echo esc_url(admin_url('admin.php?page=onwebchat_settings&tab=woocommerce')); ?>" class="button button-small" style="margin-left: 10px;">
122 Fix Authentication
123 </a>
124 </p>
125 </div>
126 <?php
127 }
128 }
129
130 /**
131 * Register WooCommerce sync settings
132 */
133 public function register_settings() {
134 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_enabled');
135 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_mode');
136 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_secret');
137 register_setting('onwebchat_wc_sync', 'onwebchat_wc_last_bulk_sync');
138 register_setting('onwebchat_wc_sync', 'onwebchat_wc_excluded_categories');
139 // Persisted sync scope: comma-separated product_cat term IDs. Empty means
140 // the whole catalogue is in scope. Drives both bulk sync and ongoing
141 // per-product auto-sync.
142 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_categories');
143 }
144
145 /**
146 * Hook: Product update (WooCommerce specific hook - fires AFTER all meta is saved)
147 */
148 public function on_product_update($product_id) {
149 // Check if sync is enabled
150 if (!get_option('onwebchat_wc_sync_enabled', false)) {
151 return;
152 }
153
154 // Get product object (at this point all meta data including SKU is already saved)
155 $product = wc_get_product($product_id);
156 if (!$product) {
157 return;
158 }
159
160 // Only sync published products
161 if ($product->get_status() !== 'publish') {
162 return;
163 }
164
165 // Check if product category is excluded
166 if ($this->is_product_excluded($product)) {
167 return;
168 }
169
170 // Respect the merchant's sync scope. When a category selection is active
171 // and this product belongs to none of the scoped categories, remove it
172 // (it may have been moved out of a scoped category after being synced)
173 // and stop. Deletes always remove, regardless of scope.
174 if (!$this->product_in_scope($product)) {
175 $this->send_product_delete($product_id);
176 return;
177 }
178
179 // Prepare and send product data
180 $product_data = $this->prepare_product_data($product);
181 $this->send_product_upsert($product_data, $product_id);
182 }
183
184 /**
185 * Hook: product stock STATUS changed (in stock / out of stock / on backorder).
186 * Pushes only the availability boolean to onWebChat (no re-embed). "onbackorder"
187 * is treated as available since the store still accepts orders.
188 *
189 * @param int $product_id
190 * @param string $status 'instock' | 'outofstock' | 'onbackorder'
191 * @param WC_Product|null $product
192 */
193 public function on_stock_status_change($product_id, $status, $product = null) {
194 if (!get_option('onwebchat_wc_sync_enabled', false)) {
195 return;
196 }
197
198 if (!$product || !is_object($product)) {
199 $product = wc_get_product($product_id);
200 }
201 if (!$product) {
202 return;
203 }
204
205 // Only products that would actually be synced: published, not excluded, in scope.
206 // Out-of-scope / excluded products are not in onWebChat, so there is nothing to update.
207 if ($product->get_status() !== 'publish') {
208 return;
209 }
210 if ($this->is_product_excluded($product) || !$this->product_in_scope($product)) {
211 return;
212 }
213
214 $in_stock = ($status !== 'outofstock');
215 $this->send_product_stock($product_id, $in_stock);
216 }
217
218 /**
219 * Hook: Product trash (when moved to trash)
220 */
221 public function on_product_trash($post_id) {
222 // Check if it's a product
223 if (get_post_type($post_id) !== 'product') {
224 return;
225 }
226
227 if (!get_option('onwebchat_wc_sync_enabled', false)) {
228 return;
229 }
230
231 // Send delete request when product is trashed
232 $this->send_product_delete($post_id);
233 }
234
235 /**
236 * Hook: Product permanent delete
237 */
238 public function on_product_delete($post_id, $post) {
239 if ($post->post_type !== 'product') {
240 return;
241 }
242
243 if (!get_option('onwebchat_wc_sync_enabled', false)) {
244 return;
245 }
246
247 // Send delete request when product is permanently deleted
248 $this->send_product_delete($post_id);
249 }
250
251 /**
252 * Check if product is in excluded categories
253 */
254 private function is_product_excluded($product) {
255 $excluded_categories = get_option('onwebchat_wc_excluded_categories', array());
256 if (empty($excluded_categories)) {
257 return false;
258 }
259
260 $product_categories = $product->get_category_ids();
261 foreach ($product_categories as $cat_id) {
262 if (in_array($cat_id, $excluded_categories)) {
263 return true;
264 }
265 }
266
267 return false;
268 }
269
270 /**
271 * Get the saved sync scope as an array of product_cat term IDs.
272 * An empty array means the whole catalogue is in scope.
273 */
274 private function get_sync_scope() {
275 $raw = (string) get_option('onwebchat_wc_sync_categories', '');
276 if ($raw === '') {
277 return array();
278 }
279
280 $ids = array();
281 foreach (explode(',', $raw) as $id) {
282 $id = (int) trim($id);
283 if ($id > 0) {
284 $ids[$id] = $id; // de-duplicate
285 }
286 }
287
288 return array_values($ids);
289 }
290
291 /**
292 * Persist the sync scope. Pass an empty array to clear it (whole catalogue).
293 */
294 private function save_sync_scope($category_ids) {
295 $clean = array();
296 foreach ((array) $category_ids as $id) {
297 $id = (int) $id;
298 if ($id > 0) {
299 $clean[$id] = $id;
300 }
301 }
302
303 update_option('onwebchat_wc_sync_categories', implode(',', array_values($clean)));
304 }
305
306 /**
307 * Is the product within the current sync scope?
308 * No scope set means everything is in scope. The picker only offers
309 * top-level categories, and selecting one covers its whole subtree, so a
310 * product is in scope when any of its categories is a scoped category OR a
311 * descendant of one. This mirrors the bulk sync tax query
312 * (include_children = true).
313 */
314 private function product_in_scope($product) {
315 $scope = $this->get_sync_scope();
316 if (empty($scope)) {
317 return true;
318 }
319
320 foreach ($product->get_category_ids() as $cat_id) {
321 $cat_id = (int) $cat_id;
322 if (in_array($cat_id, $scope, true)) {
323 return true;
324 }
325 // Walk up to the root: a scoped ancestor puts the product in scope.
326 foreach (get_ancestors($cat_id, 'product_cat', 'taxonomy') as $ancestor_id) {
327 if (in_array((int) $ancestor_id, $scope, true)) {
328 return true;
329 }
330 }
331 }
332
333 return false;
334 }
335
336 /**
337 * Count published products within the given scope (empty = whole catalogue).
338 * Uses found_posts so we do not load every ID into memory.
339 */
340 private function count_products_in_scope($category_ids) {
341 $args = array(
342 'post_type' => 'product',
343 'post_status' => 'publish',
344 'posts_per_page' => 1,
345 'fields' => 'ids',
346 'no_found_rows' => false,
347 );
348
349 if (!empty($category_ids)) {
350 $args['tax_query'] = array(array(
351 'taxonomy' => 'product_cat',
352 'field' => 'term_id',
353 'terms' => array_map('intval', $category_ids),
354 'include_children' => true,
355 ));
356 }
357
358 $query = new WP_Query($args);
359 return (int) $query->found_posts;
360 }
361
362 /**
363 * Prepare product data for sync
364 */
365 private function prepare_product_data($product) {
366 $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_fallback_full');
367
368 // Get description based on sync mode
369 $description = '';
370 $short_description = strip_tags($product->get_short_description());
371
372 if ($sync_mode === 'short_only') {
373 $description = $short_description;
374 } else if ($sync_mode === 'short_fallback_full') {
375 if (!empty($short_description)) {
376 $description = $short_description;
377 } else {
378 // Fallback to first 60-80 words of full description
379 $full_description = strip_tags($product->get_description());
380 $words = str_word_count($full_description, 2);
381 $word_array = array_keys($words);
382
383 if (count($word_array) > 80) {
384 $end_pos = $word_array[79];
385 $description = substr($full_description, 0, $end_pos) . '...';
386 } else {
387 $description = $full_description;
388 }
389 }
390 }
391
392 // Enforce max length
393 if (strlen($description) > $this->max_description_length) {
394 $description = substr($description, 0, $this->max_description_length) . '...';
395 }
396
397 $sku = $product->get_sku();
398 $categories = $this->get_product_category_names($product);
399 $url = get_permalink($product->get_id());
400
401 // Structured fields. The server rebuilds the embedding text from these,
402 // so there is no need to send a pre-formatted "text" blob.
403 $data = array(
404 'product_id' => $product->get_id(),
405 'name' => $product->get_name(),
406 'short_description' => trim($description),
407 'url' => $url,
408 'sku' => $sku,
409 'categories' => $categories,
410 'currency' => get_woocommerce_currency(),
411 );
412
413 // Price (always sent). Variable products carry a min/max range.
414 $data['price'] = $product->get_price();
415
416 if ($product->is_type('variable')) {
417 // Raw min/max prices, consistent with get_price() used for simple products.
418 $data['price_min'] = $product->get_variation_price('min', false);
419 $data['price_max'] = $product->get_variation_price('max', false);
420 } else {
421 $regular_price = $product->get_regular_price();
422 if ($regular_price !== '') {
423 $data['regular_price'] = $regular_price;
424 }
425 // Only advertise a sale price while the sale is actually active.
426 if ($product->is_on_sale()) {
427 $data['sale_price'] = $product->get_sale_price();
428 }
429 }
430
431 // Stock availability
432 $data['in_stock'] = $product->is_in_stock();
433 if ($product->managing_stock()) {
434 $stock_qty = $product->get_stock_quantity();
435 if ($stock_qty !== null) {
436 $data['quantity'] = (int) $stock_qty;
437 }
438 }
439
440 // Brand (renders as "Brand:" on the server). Detect the common brand taxonomies.
441 $brand = $this->get_product_brand($product);
442 if (!empty($brand)) {
443 $data['manufacturer'] = $brand;
444 }
445
446 // Variation attributes / options (Color, Size, ...)
447 $attributes = $this->get_product_attributes($product);
448 if (!empty($attributes)) {
449 $data['attributes'] = $attributes;
450 }
451
452 // Tags
453 $tags = $this->get_product_tags($product);
454 if (!empty($tags)) {
455 $data['tags'] = $tags;
456 }
457
458 // Average rating and review count
459 $rating = (float) $product->get_average_rating();
460 if ($rating > 0) {
461 $data['rating'] = $rating;
462 $data['review_count'] = (int) $product->get_review_count();
463 }
464
465 return $data;
466 }
467
468 /**
469 * Get product category names
470 */
471 private function get_product_category_names($product) {
472 $categories = array();
473 $category_ids = $product->get_category_ids();
474
475 foreach ($category_ids as $cat_id) {
476 $term = get_term($cat_id, 'product_cat');
477 if ($term && !is_wp_error($term)) {
478 $categories[] = $term->name;
479 }
480 }
481
482 return $categories;
483 }
484
485 /**
486 * Get the product's brand name from whichever brand taxonomy is available.
487 * Supports WooCommerce 9.6+ native brands and the common brand plugins.
488 */
489 private function get_product_brand($product) {
490 $taxonomies = array('product_brand', 'pwb-brand', 'yith_product_brand', 'pa_brand');
491
492 foreach ($taxonomies as $taxonomy) {
493 if (!taxonomy_exists($taxonomy)) {
494 continue;
495 }
496
497 $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names'));
498 if (!is_wp_error($terms) && !empty($terms)) {
499 return $terms[0];
500 }
501 }
502
503 return '';
504 }
505
506 /**
507 * Get visible product attributes as an array of { name, options }.
508 * Works for both custom and taxonomy-based (global) attributes.
509 */
510 private function get_product_attributes($product) {
511 $result = array();
512
513 foreach ($product->get_attributes() as $attribute) {
514 if (!is_object($attribute) || !$attribute->get_visible()) {
515 continue;
516 }
517
518 $name = wc_attribute_label($attribute->get_name());
519
520 if ($attribute->is_taxonomy()) {
521 $options = wc_get_product_terms($product->get_id(), $attribute->get_name(), array('fields' => 'names'));
522 } else {
523 $options = $attribute->get_options();
524 }
525
526 $options = array_values(array_filter(array_map('trim', (array) $options)));
527
528 if (!empty($name) && !empty($options)) {
529 $result[] = array(
530 'name' => $name,
531 'options' => $options,
532 );
533 }
534 }
535
536 return $result;
537 }
538
539 /**
540 * Get product tag names.
541 */
542 private function get_product_tags($product) {
543 $tags = wp_get_post_terms($product->get_id(), 'product_tag', array('fields' => 'names'));
544
545 if (is_wp_error($tags) || empty($tags)) {
546 return array();
547 }
548
549 return $tags;
550 }
551
552 /**
553 * Send batch of products to API (optimized)
554 * @param array $products - Array of product data
555 */
556 private function send_product_batch($products) {
557 $chatId = get_option('onwebchat_plugin_option');
558 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
559
560 if (empty($chatId)) {
561 error_log('onWebChat WooCommerce Sync - Chat ID not configured');
562 return false;
563 }
564
565 // Ensure we have a secret (must be obtained via authenticated connection in WooCommerce settings)
566 $secret = $this->get_secret(false);
567 if (empty($secret)) {
568 error_log('onWebChat WooCommerce Sync - No secret configured. Please connect WooCommerce in the plugin settings.');
569 return array(
570 'success' => false,
571 'error' => 'No secret configured. Please connect WooCommerce integration.',
572 'needs_reconnect' => true
573 );
574 }
575
576 // Extract key part (before first slash if present)
577 $chatIdKey = explode('/', $chatId)[0];
578
579 $endpoint = $this->get_api_endpoint() . '/product/batch';
580 $payload = array(
581 'site_id' => $chatIdKey,
582 'site_url' => get_site_url(),
583 'products' => $products
584 );
585
586 // Generate authentication headers (same as send_authenticated_request)
587 $timestamp = time();
588 $nonce = base64_encode(random_bytes(16));
589 $body_json = wp_json_encode($payload);
590
591 // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
592 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
593 $signature = hash_hmac('sha256', $message, $secret);
594
595 // Send request
596 $request_args = array(
597 'method' => 'POST',
598 'timeout' => 30, // Longer timeout for batch operations
599 'headers' => array(
600 'Content-Type' => 'application/json',
601 'X-OWC-SiteId' => $chatIdKey,
602 'X-OWC-Timestamp' => $timestamp,
603 'X-OWC-Nonce' => $nonce,
604 'X-OWC-Signature' => $signature,
605 ),
606 'body' => $body_json,
607 );
608
609 // Disable SSL verification for local dev server
610 if ($this->use_testing_mode) {
611 $request_args['sslverify'] = false;
612 }
613
614 $response = wp_remote_post($endpoint, $request_args);
615
616 if (is_wp_error($response)) {
617 error_log('onWebChat WooCommerce Sync - Batch sync error: ' . $response->get_error_message());
618 return array(
619 'success' => false,
620 'error' => 'Network error: ' . $response->get_error_message()
621 );
622 }
623
624 $response_code = wp_remote_retrieve_response_code($response);
625 $body = json_decode(wp_remote_retrieve_body($response), true);
626
627 // If authentication failed (401), the secret is invalid or out of sync
628 if ($response_code === 401) {
629 // Clear the invalid secret
630 delete_option('onwebchat_wc_sync_secret');
631
632 error_log('onWebChat WooCommerce Sync - Authentication failed (401): Secret is invalid or out of sync. Please reconnect WooCommerce in the plugin settings.');
633
634 // Store admin notice about authentication failure
635 set_transient('onwebchat_wc_auth_error', 'Authentication failed. Your WooCommerce secret is invalid or out of sync. Please reconnect WooCommerce integration in the plugin settings.', 3600);
636
637 return array(
638 'success' => false,
639 'error' => 'Authentication failed. Secret is invalid. Please reconnect WooCommerce integration.',
640 'needs_reconnect' => true
641 );
642 }
643
644 if ($response_code === 200 && isset($body['success']) && $body['success']) {
645 // Clear any previous auth errors on success
646 delete_transient('onwebchat_wc_auth_error');
647 return $body; // Return full response with stats
648 }
649
650 $error_msg = 'Batch sync failed';
651 if (isset($body['error'])) {
652 $error_msg .= ': ' . $body['error'];
653 }
654 error_log('onWebChat WooCommerce Sync - ' . $error_msg . ' - Response: ' . print_r($body, true));
655
656 return array(
657 'success' => false,
658 'error' => $error_msg
659 );
660 }
661
662 /**
663 * Send sync completion notification to server (triggers Angular modal)
664 */
665 private function send_sync_completion_notification($total_stats) {
666 $chatId = get_option('onwebchat_plugin_option');
667 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
668
669 if (empty($chatId)) {
670 error_log('onWebChat WooCommerce Sync - Chat ID not configured');
671 return false;
672 }
673
674 $secret = $this->get_secret(false);
675 if (empty($secret)) {
676 error_log('onWebChat WooCommerce Sync - No secret configured');
677 return false;
678 }
679
680 $chatIdKey = explode('/', $chatId)[0];
681
682 $endpoint = $this->get_api_endpoint() . '/product/sync-complete';
683 $payload = array(
684 'site_id' => $chatIdKey,
685 'stats' => $total_stats
686 );
687
688 // Generate authentication headers
689 $timestamp = time();
690 $nonce = base64_encode(random_bytes(16));
691 $body_json = wp_json_encode($payload);
692 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
693 $signature = hash_hmac('sha256', $message, $secret);
694
695 $request_args = array(
696 'method' => 'POST',
697 'timeout' => 10,
698 'headers' => array(
699 'Content-Type' => 'application/json',
700 'X-OWC-SiteId' => $chatIdKey,
701 'X-OWC-Timestamp' => $timestamp,
702 'X-OWC-Nonce' => $nonce,
703 'X-OWC-Signature' => $signature,
704 ),
705 'body' => $body_json,
706 );
707
708 if ($this->use_testing_mode) {
709 $request_args['sslverify'] = false;
710 }
711
712 $response = wp_remote_post($endpoint, $request_args);
713
714 if (is_wp_error($response)) {
715 error_log('onWebChat WooCommerce Sync - Completion notification failed: ' . $response->get_error_message());
716 return false;
717 }
718
719 $response_code = wp_remote_retrieve_response_code($response);
720
721 // If authentication failed (401), the secret is invalid or out of sync
722 if ($response_code === 401) {
723 delete_option('onwebchat_wc_sync_secret');
724 error_log('onWebChat WooCommerce Sync - Completion notification authentication failed (401): Secret is invalid. Please reconnect WooCommerce.');
725 set_transient('onwebchat_wc_auth_error', 'Authentication failed. Your WooCommerce secret is invalid or out of sync. Please reconnect WooCommerce integration in the plugin settings.', 3600);
726 return false;
727 }
728
729 if ($response_code >= 200 && $response_code < 300) {
730 error_log('onWebChat WooCommerce Sync - Completion notification sent successfully');
731 return true;
732 }
733
734 error_log('onWebChat WooCommerce Sync - Completion notification failed with code: ' . $response_code);
735 return false;
736 }
737
738 /**
739 * Send product upsert to server (uses batch endpoint with single product)
740 */
741 private function send_product_upsert($product_data, $product_id) {
742 // Use batch endpoint with single product
743 $result = $this->send_product_batch(array($product_data));
744
745 if ($result && isset($result['success']) && $result['success']) {
746 // Clear any previous errors
747 delete_post_meta($product_id, '_onwebchat_sync_error');
748 update_post_meta($product_id, '_onwebchat_last_sync', current_time('timestamp'));
749 return true;
750 } else {
751 // Log error if batch failed
752 $error_message = 'Failed to sync product';
753 if ($result && isset($result['error'])) {
754 $error_message = $result['error'];
755 } elseif (!$result) {
756 $error_message = 'Batch sync request failed';
757 }
758 $this->log_error($product_id, $error_message);
759 return false;
760 }
761 }
762
763 /**
764 * Send product delete to server
765 */
766 private function send_product_delete($product_id) {
767 $chatId = get_option('onwebchat_plugin_option');
768 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
769
770 if (empty($chatId)) {
771 return false;
772 }
773
774 // Extract key part (before first slash if present)
775 $chatIdKey = explode('/', $chatId)[0];
776
777 $endpoint = $this->get_api_endpoint() . '/product/delete';
778 $payload = array(
779 'site_id' => $chatIdKey, // Use key part only
780 'site_url' => get_site_url(),
781 'product_id' => $product_id
782 );
783
784 $this->send_authenticated_request($endpoint, $payload, $product_id);
785 }
786
787 /**
788 * Send a lightweight availability update to onWebChat (no re-embed on the server).
789 */
790 private function send_product_stock($product_id, $in_stock) {
791 $chatId = get_option('onwebchat_plugin_option');
792 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
793
794 if (empty($chatId)) {
795 return false;
796 }
797
798 // Extract key part (before first slash if present)
799 $chatIdKey = explode('/', $chatId)[0];
800
801 $endpoint = $this->get_api_endpoint() . '/product/stock';
802 $payload = array(
803 'site_id' => $chatIdKey, // Use key part only
804 'site_url' => get_site_url(),
805 'product_id' => $product_id,
806 'in_stock' => (bool) $in_stock,
807 );
808
809 $this->send_authenticated_request($endpoint, $payload, $product_id);
810 }
811
812 /**
813 * Get cached secret from local options
814 * @param {bool} force_refresh - Not used (kept for compatibility), secret must be obtained via authenticated request
815 */
816 private function get_secret($force_refresh = false) {
817 // Always return cached secret - never fetch automatically
818 // Secret must be obtained via authenticated request in WooCommerce settings
819 $secret = get_option('onwebchat_wc_sync_secret');
820
821 if (!empty($secret)) {
822 return $secret;
823 }
824
825 // No secret available - user must authenticate in WooCommerce settings
826 return null;
827 }
828
829 /**
830 * Request secret from server with authentication
831 * This is called when user clicks "Connect WooCommerce" with their password
832 *
833 * @param {string} email - User's onWebChat email
834 * @param {string} password - User's onWebChat password
835 * @return {array} - ['success' => bool, 'secret' => string, 'error' => string]
836 */
837 public function request_secret_with_auth($email, $password) {
838 $chatId = get_option('onwebchat_plugin_option');
839 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
840
841 if (empty($chatId)) {
842 return array('success' => false, 'error' => 'No Chat ID configured');
843 }
844
845 // Extract key part (before first slash if present)
846 $key = explode('/', $chatId)[0];
847
848 // Request secret from server with authentication
849 $secret_endpoint = $this->get_api_endpoint() . '/secret';
850
851 $response = wp_remote_post($secret_endpoint, array(
852 'timeout' => 15,
853 'sslverify' => !$this->use_testing_mode,
854 'headers' => array(
855 'Content-Type' => 'application/json',
856 ),
857 'body' => wp_json_encode(array(
858 'email' => $email,
859 'password' => $password,
860 'site_key' => $key,
861 'version' => defined('ONWEBCHAT_PLUGIN_VERSION') ? ONWEBCHAT_PLUGIN_VERSION : '',
862 )),
863 ));
864
865 if (is_wp_error($response)) {
866 $error_message = $response->get_error_message();
867 error_log('onWebChat WooCommerce Sync - Connection error: ' . $error_message);
868 return array('success' => false, 'error' => 'Connection failed: ' . $error_message);
869 }
870
871 $status_code = wp_remote_retrieve_response_code($response);
872 $response_body_raw = wp_remote_retrieve_body($response);
873 $body = json_decode($response_body_raw, true);
874
875 // Log response for debugging. Redact the secret so it never lands in server/debug logs
876 // (a successful response body contains the HMAC secret).
877 $log_body = preg_replace('/("secret"\s*:\s*")[^"]*(")/i', '$1[REDACTED]$2', (string) $response_body_raw);
878 error_log('onWebChat WooCommerce Sync - API response: Status=' . $status_code . ', Body=' . substr($log_body, 0, 500));
879
880 // Handle specific HTTP status codes
881 if ($status_code === 401) {
882 return array('success' => false, 'error' => 'Invalid email or password');
883 }
884
885 if ($status_code === 403) {
886 return array('success' => false, 'error' => 'You do not have access to this site');
887 }
888
889 // Success case
890 if ($status_code >= 200 && $status_code < 300 && isset($body['success']) && $body['success']) {
891 $secret = isset($body['secret']) ? $body['secret'] : null;
892 if (empty($secret)) {
893 error_log('onWebChat WooCommerce Sync - Success response but no secret provided');
894 return array('success' => false, 'error' => 'Server response missing secret');
895 }
896 update_option('onwebchat_wc_sync_secret', $secret);
897
898 // Enable AI order-status lookup by default on connect and register our
899 // callback URL with onWebChat (best effort; the merchant can toggle it off).
900 update_option('onwebchat_wc_order_lookup_enabled', true);
901 global $onwebchat_wc_orders;
902 if (isset($onwebchat_wc_orders) && is_object($onwebchat_wc_orders)) {
903 $onwebchat_wc_orders->push_order_lookup_config(true);
904 }
905
906 return array('success' => true, 'secret' => $secret);
907 }
908
909 // Extract error message from various possible response formats
910 $error_message = 'Unknown error';
911
912 if (is_array($body)) {
913 // Try different possible error fields
914 if (isset($body['error'])) {
915 $error_message = is_string($body['error']) ? $body['error'] : json_encode($body['error']);
916 } elseif (isset($body['message'])) {
917 $error_message = is_string($body['message']) ? $body['message'] : json_encode($body['message']);
918 } elseif (isset($body['errors']) && is_array($body['errors'])) {
919 $error_message = implode(', ', $body['errors']);
920 }
921 } elseif (!empty($response_body_raw)) {
922 // If body is not JSON or empty, use raw response (truncated)
923 $error_message = 'Server returned: ' . substr(strip_tags($response_body_raw), 0, 200);
924 }
925
926 // Defense in depth: the remote error is shown in the admin UI, so strip any markup here too
927 // (the client also renders it as text). Prevents a malicious/MITM'd API response carrying HTML.
928 $error_message = sanitize_text_field($error_message);
929
930 // Include status code in error message if not already included
931 if ($status_code && strpos($error_message, 'HTTP') === false) {
932 $error_message = 'HTTP ' . $status_code . ': ' . $error_message;
933 }
934
935 error_log('onWebChat WooCommerce Sync - Connection failed: ' . $error_message);
936 return array('success' => false, 'error' => $error_message);
937 }
938
939 /**
940 * Send authenticated request with HMAC signature
941 */
942 private function send_authenticated_request($endpoint, $payload, $product_id = null) {
943 // Get cached secret (must be obtained via authenticated connection in WooCommerce settings)
944 $secret = $this->get_secret(false);
945
946 if (empty($secret)) {
947 if ($product_id) {
948 $this->log_error($product_id, 'No secret configured. Please connect WooCommerce in the plugin settings.');
949 }
950 return array('success' => false, 'error' => 'Secret not available. Please connect WooCommerce in plugin settings.');
951 }
952
953 $chatId = get_option('onwebchat_plugin_option');
954 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
955
956 // Extract key part (before first slash if present) for consistency with server
957 // e.g., "5f02c87b60726a4663b25463a424a034/1/1" -> "5f02c87b60726a4663b25463a424a034"
958 $chatIdKey = explode('/', $chatId)[0];
959
960 // Generate authentication headers
961 $timestamp = time();
962 $nonce = base64_encode(random_bytes(16));
963 $body_json = wp_json_encode($payload);
964
965 // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
966 // IMPORTANT: Use the key part (not full chat_id) to match server-side verification
967 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
968 $signature = hash_hmac('sha256', $message, $secret);
969
970 // Send request
971 $request_args = array(
972 'method' => 'POST',
973 'timeout' => 10,
974 'headers' => array(
975 'Content-Type' => 'application/json',
976 'X-OWC-SiteId' => $chatIdKey, // Use key part only
977 'X-OWC-Timestamp' => $timestamp,
978 'X-OWC-Nonce' => $nonce,
979 'X-OWC-Signature' => $signature,
980 ),
981 'body' => $body_json,
982 );
983
984 // Disable SSL verification for local dev server
985 if ($this->use_testing_mode) {
986 $request_args['sslverify'] = false;
987 }
988
989 $response = wp_remote_post($endpoint, $request_args);
990
991 // Handle response
992 if (is_wp_error($response)) {
993 $error_message = $response->get_error_message();
994 if ($product_id) {
995 $this->log_error($product_id, $error_message);
996 }
997 return array('success' => false, 'error' => $error_message);
998 }
999
1000 $status_code = wp_remote_retrieve_response_code($response);
1001
1002 // Success
1003 if ($status_code >= 200 && $status_code < 300) {
1004 return array('success' => true);
1005 }
1006
1007 // If authentication failed (401), the secret may be invalid
1008 if ($status_code === 401) {
1009 // Clear the invalid secret
1010 delete_option('onwebchat_wc_sync_secret');
1011
1012 if ($product_id) {
1013 $this->log_error($product_id, 'Authentication failed. Please reconnect WooCommerce in the plugin settings.');
1014 }
1015 return array('success' => false, 'error' => 'Authentication failed. Please reconnect WooCommerce in plugin settings.');
1016 }
1017
1018 // Error
1019 $error_body = wp_remote_retrieve_body($response);
1020 if ($product_id) {
1021 $this->log_error($product_id, "HTTP $status_code: $error_body");
1022 }
1023
1024 return array('success' => false, 'error' => "HTTP $status_code", 'status_code' => $status_code);
1025 }
1026
1027 /**
1028 * Log sync error to product meta
1029 */
1030 private function log_error($product_id, $error_message) {
1031 update_post_meta($product_id, '_onwebchat_sync_error', array(
1032 'message' => $error_message,
1033 'timestamp' => current_time('timestamp')
1034 ));
1035 }
1036
1037 /**
1038 * AJAX: Start bulk sync
1039 */
1040 public function ajax_sync_existing_products() {
1041 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1042
1043 if (!current_user_can('manage_options')) {
1044 wp_send_json_error('Insufficient permissions');
1045 }
1046
1047 // Check if sync is already in progress
1048 if (get_option('onwebchat_wc_bulk_in_progress', false)) {
1049 wp_send_json_error('A sync is already in progress. Please wait for it to complete.');
1050 }
1051
1052 // Rate limiting: prevent syncing more than once every 5 minutes
1053 $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0);
1054 $cooldown_period = 5 * 60; // 5 minutes in seconds //also in the file woocommerce.php // 5 * 60
1055 $time_since_last_sync = time() - $last_sync_time;
1056
1057 if ($time_since_last_sync < $cooldown_period) {
1058 $wait_time = $cooldown_period - $time_since_last_sync;
1059 $minutes = ceil($wait_time / 60);
1060 wp_send_json_error('Please wait ' . $minutes . ' minute(s) before syncing again.');
1061 }
1062
1063 // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue.
1064 $category_ids = array();
1065 if (isset($_POST['categories']) && $_POST['categories'] !== '') {
1066 foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) {
1067 $id = (int) trim($id);
1068 if ($id > 0) {
1069 $category_ids[] = $id;
1070 }
1071 }
1072 }
1073
1074 // Above the hard cap a category selection is required: refuse an
1075 // unrestricted "sync all" when the catalogue is larger than the cap.
1076 $published_total = $this->count_products_in_scope(array());
1077 if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) {
1078 wp_send_json_error(sprintf(
1079 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.',
1080 number_format_i18n($published_total),
1081 number_format_i18n(self::MAX_SYNC_PRODUCTS)
1082 ));
1083 }
1084
1085 // Remember the merchant's choice so ongoing auto-sync stays within it:
1086 // selected categories become the sync scope; an unrestricted "sync all"
1087 // clears the scope (the whole catalogue is in scope again).
1088 $this->save_sync_scope($category_ids);
1089
1090 // Store the current sync start time
1091 update_option('onwebchat_wc_last_sync_start', time());
1092
1093 // Reset bulk sync progress
1094 update_option('onwebchat_wc_bulk_page', 0);
1095 update_option('onwebchat_wc_bulk_done', 0);
1096
1097 // Count total products within scope, capped at the hard limit.
1098 $total = $this->count_products_in_scope($category_ids);
1099 if ($total > self::MAX_SYNC_PRODUCTS) {
1100 $total = self::MAX_SYNC_PRODUCTS;
1101 }
1102
1103 update_option('onwebchat_wc_bulk_total', $total);
1104 update_option('onwebchat_wc_bulk_done', 0); // Initialize progress counter
1105 update_option('onwebchat_wc_bulk_in_progress', true);
1106
1107 // Process sync directly instead of using unreliable WP Cron
1108 $sync_result = $this->do_bulk_sync_all($category_ids);
1109
1110 wp_send_json_success(array(
1111 'message' => 'Bulk sync completed',
1112 'total' => $total,
1113 'result' => $sync_result
1114 ));
1115 }
1116
1117 /**
1118 * Process all products in bulk sync directly (not via cron).
1119 *
1120 * @param array $category_ids Sync scope (product_cat term IDs). Empty = whole catalogue.
1121 */
1122 private function do_bulk_sync_all($category_ids = array()) {
1123 $total = get_option('onwebchat_wc_bulk_total', 0);
1124 $page = 0;
1125 $total_done = 0;
1126 $considered = 0; // products fetched so far, used to enforce the hard cap
1127 $all_stats = array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0);
1128 $max = self::MAX_SYNC_PRODUCTS;
1129
1130 // Process all products in batches
1131 while (true) {
1132 $args = array(
1133 'post_type' => 'product',
1134 'post_status' => 'publish',
1135 'posts_per_page' => $this->batch_size,
1136 'paged' => $page + 1,
1137 'orderby' => 'ID',
1138 'order' => 'ASC',
1139 );
1140
1141 // Restrict to the chosen top-level categories and their subtrees, to
1142 // match the per-product scope check and the counts shown in the picker.
1143 if (!empty($category_ids)) {
1144 $args['tax_query'] = array(array(
1145 'taxonomy' => 'product_cat',
1146 'field' => 'term_id',
1147 'terms' => array_map('intval', $category_ids),
1148 'include_children' => true,
1149 ));
1150 }
1151
1152 $query = new WP_Query($args);
1153
1154 if (!$query->have_posts()) {
1155 break;
1156 }
1157
1158 // Collect products in this batch, honoring the hard cap.
1159 $products_batch = array();
1160 $reached_cap = false;
1161 foreach ($query->posts as $post) {
1162 if ($considered >= $max) {
1163 $reached_cap = true;
1164 break;
1165 }
1166 $considered++;
1167 $product = wc_get_product($post->ID);
1168 if ($product && !$this->is_product_excluded($product)) {
1169 $products_batch[] = $this->prepare_product_data($product);
1170 }
1171 }
1172
1173 // Send batch
1174 if (!empty($products_batch)) {
1175 $result = $this->send_product_batch($products_batch);
1176 if ($result && isset($result['stats'])) {
1177 $total_done += $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
1178 $all_stats['created'] += $result['stats']['created'];
1179 $all_stats['updated'] += $result['stats']['updated'];
1180 $all_stats['skipped'] += $result['stats']['skipped'];
1181 $all_stats['errors'] += $result['stats']['errors'];
1182 } else {
1183 // Fallback
1184 $total_done += count($products_batch);
1185 }
1186
1187 // Update progress after each batch so AJAX polling can see it
1188 update_option('onwebchat_wc_bulk_done', $total_done);
1189
1190 // Wait 4 seconds before next batch
1191 sleep(4);
1192 }
1193
1194 wp_reset_postdata();
1195 $page++;
1196
1197 // Stop once the hard cap is reached.
1198 if ($reached_cap || $considered >= $max) {
1199 break;
1200 }
1201
1202 // Safety check - don't loop forever. The cap allows up to
1203 // MAX_SYNC_PRODUCTS / batch_size batches, so keep a generous guard.
1204 if ($page > ($max / $this->batch_size) + 10) {
1205 break;
1206 }
1207 }
1208
1209 // Send completion notification to Angular dashboard with total stats
1210 $this->send_sync_completion_notification($all_stats);
1211
1212 // Mark sync as complete
1213 update_option('onwebchat_wc_bulk_in_progress', false);
1214 update_option('onwebchat_wc_bulk_done', $total_done);
1215 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1216
1217 return array(
1218 'done' => $total_done,
1219 'total' => $total,
1220 'stats' => $all_stats
1221 );
1222 }
1223
1224 /**
1225 * Process bulk sync batch (via WP Cron) - Uses batch API endpoint
1226 */
1227 public function process_bulk_sync_batch() {
1228 error_log('onWebChat WooCommerce Sync - process_bulk_sync_batch called');
1229
1230 if (!get_option('onwebchat_wc_bulk_in_progress', false)) {
1231 error_log('onWebChat WooCommerce Sync - Sync not in progress, exiting');
1232 return;
1233 }
1234
1235 $page = get_option('onwebchat_wc_bulk_page', 0);
1236 $done = get_option('onwebchat_wc_bulk_done', 0);
1237 $total = get_option('onwebchat_wc_bulk_total', 0);
1238
1239 error_log('onWebChat WooCommerce Sync - Starting batch: page=' . $page . ', done=' . $done . ', total=' . $total);
1240
1241 // Get batch of products
1242 $args = array(
1243 'post_type' => 'product',
1244 'post_status' => 'publish',
1245 'posts_per_page' => $this->batch_size,
1246 'paged' => $page + 1,
1247 'orderby' => 'ID',
1248 'order' => 'ASC',
1249 );
1250
1251 // Restrict to the saved sync scope and its subcategories, consistent
1252 // with the synchronous bulk sync path.
1253 $scope = $this->get_sync_scope();
1254 if (!empty($scope)) {
1255 $args['tax_query'] = array(array(
1256 'taxonomy' => 'product_cat',
1257 'field' => 'term_id',
1258 'terms' => array_map('intval', $scope),
1259 'include_children' => true,
1260 ));
1261 }
1262
1263 $query = new WP_Query($args);
1264
1265 if ($query->have_posts()) {
1266 // Collect all products in this batch
1267 $products_batch = array();
1268
1269 foreach ($query->posts as $post) {
1270 $product = wc_get_product($post->ID);
1271 if ($product && !$this->is_product_excluded($product)) {
1272 $products_batch[] = $this->prepare_product_data($product);
1273 }
1274 }
1275
1276 // Send entire batch in one request
1277 $batch_done = 0;
1278 if (!empty($products_batch)) {
1279 $result = $this->send_product_batch($products_batch);
1280 error_log('onWebChat WooCommerce Sync - Batch result: ' . print_r($result, true));
1281 if ($result && isset($result['stats'])) {
1282 // Count created + updated + skipped as "done"
1283 $batch_done = $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
1284 error_log('onWebChat WooCommerce Sync - Batch done: ' . $batch_done);
1285 } else {
1286 // Fallback: assume all sent
1287 $batch_done = count($products_batch);
1288 error_log('onWebChat WooCommerce Sync - No stats in result, using fallback count: ' . $batch_done);
1289 }
1290 }
1291
1292 // Update progress
1293 $new_done = $done + $batch_done;
1294 error_log('onWebChat WooCommerce Sync - Progress update: done=' . $done . ' + batch_done=' . $batch_done . ' = new_done=' . $new_done . ' / total=' . $total);
1295 update_option('onwebchat_wc_bulk_page', $page + 1);
1296 update_option('onwebchat_wc_bulk_done', $new_done);
1297
1298 // Check if we've processed all products
1299 if ($new_done >= $total || !$query->have_posts()) {
1300 // Sync complete
1301 error_log('onWebChat WooCommerce Sync - Marking sync as complete');
1302 update_option('onwebchat_wc_bulk_in_progress', false);
1303 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1304 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
1305 } else {
1306 // Schedule next batch
1307 error_log('onWebChat WooCommerce Sync - Scheduling next batch in 60 seconds');
1308 wp_schedule_single_event(time() + 60, 'onwebchat_wc_bulk_sync_batch');
1309 }
1310 } else {
1311 // No more products - sync complete
1312 update_option('onwebchat_wc_bulk_in_progress', false);
1313 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1314 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
1315 }
1316
1317 wp_reset_postdata();
1318 }
1319
1320 /**
1321 * AJAX: Regenerate secret (fetch from server)
1322 */
1323 public function ajax_regenerate_secret() {
1324 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1325
1326 if (!current_user_can('manage_options')) {
1327 wp_send_json_error('Insufficient permissions');
1328 }
1329
1330 // Clear cached secret - user will need to re-authenticate
1331 delete_option('onwebchat_wc_sync_secret');
1332
1333 // Clear any authentication error notices
1334 delete_transient('onwebchat_wc_auth_error');
1335
1336 wp_send_json_success(array(
1337 'message' => 'Secret cleared. Please reconnect WooCommerce with your credentials.',
1338 'needs_reconnect' => true
1339 ));
1340 }
1341
1342 /**
1343 * AJAX: Reset sync status
1344 */
1345 public function ajax_reset_sync_status() {
1346 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1347
1348 if (!current_user_can('manage_options')) {
1349 wp_send_json_error('Insufficient permissions');
1350 }
1351
1352 $total = get_option('onwebchat_wc_bulk_total', 0);
1353
1354 // Mark sync as complete
1355 update_option('onwebchat_wc_bulk_in_progress', false);
1356 update_option('onwebchat_wc_bulk_done', $total);
1357 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1358
1359 wp_send_json_success(array(
1360 'message' => 'Sync status reset successfully'
1361 ));
1362 }
1363
1364 /**
1365 * AJAX handler to get current sync status
1366 */
1367 public function ajax_get_sync_status() {
1368 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1369
1370 if (!current_user_can('manage_options')) {
1371 wp_send_json_error('Insufficient permissions');
1372 }
1373
1374 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
1375 $done = get_option('onwebchat_wc_bulk_done', 0);
1376 $total = get_option('onwebchat_wc_bulk_total', 0);
1377
1378 wp_send_json_success(array(
1379 'in_progress' => $in_progress,
1380 'done' => $done,
1381 'total' => $total
1382 ));
1383 }
1384
1385 /**
1386 * AJAX handler to save sync enabled setting
1387 */
1388 public function ajax_save_sync_enabled() {
1389 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1390
1391 if (!current_user_can('manage_options')) {
1392 wp_send_json_error('Insufficient permissions');
1393 }
1394
1395 $sync_enabled = isset($_POST['sync_enabled']) && $_POST['sync_enabled'] === '1';
1396
1397 update_option('onwebchat_wc_sync_enabled', $sync_enabled);
1398
1399 wp_send_json_success(array(
1400 'message' => $sync_enabled ? 'WooCommerce product sync enabled' : 'WooCommerce product sync disabled',
1401 'enabled' => $sync_enabled
1402 ));
1403 }
1404
1405 /**
1406 * Get sync status for admin display
1407 */
1408 public function get_sync_status() {
1409 $last_sync = get_option('onwebchat_wc_last_bulk_sync', 0);
1410 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
1411 $done = get_option('onwebchat_wc_bulk_done', 0);
1412 $total = get_option('onwebchat_wc_bulk_total', 0);
1413
1414 return array(
1415 'last_sync' => $last_sync,
1416 'in_progress' => $in_progress,
1417 'done' => $done,
1418 'total' => $total,
1419 );
1420 }
1421
1422 /**
1423 * AJAX: Manually process batch (for debugging)
1424 */
1425 public function ajax_manual_process_batch() {
1426 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1427
1428 if (!current_user_can('manage_options')) {
1429 wp_send_json_error('Insufficient permissions');
1430 }
1431
1432 // Manually trigger the cron job
1433 error_log('onWebChat WooCommerce Sync - Manual batch process triggered via AJAX');
1434 $this->process_bulk_sync_batch();
1435
1436 // Return current status
1437 $status = $this->get_sync_status();
1438 wp_send_json_success(array(
1439 'message' => 'Batch processed',
1440 'status' => $status
1441 ));
1442 }
1443 }
1444
1445 // Initialize the sync module
1446 global $onwebchat_wc_sync;
1447 $onwebchat_wc_sync = new OnWebChat_WooCommerce_Sync();
1448
1449