PluginProbe
Live Chat & AI Chatbot – onWebChat / 3.8.0
Live Chat & AI Chatbot – onWebChat v3.8.0
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.8.0, at includes/woocommerce-sync.php

1,451 lines 56.0 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 = 1500;
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 the first 200 words of the full description.
379 // Split with a Unicode-aware regex: str_word_count() does not
380 // recognize non-latin (e.g. Greek) words, so the old word cut
381 // was unreliable on multibyte text.
382 $full_description = strip_tags($product->get_description());
383 $words = preg_split('/\s+/u', trim($full_description), -1, PREG_SPLIT_NO_EMPTY);
384
385 if (is_array($words) && count($words) > 200) {
386 $description = implode(' ', array_slice($words, 0, 200)) . '...';
387 } else {
388 $description = $full_description;
389 }
390 }
391 }
392
393 // Enforce max length by characters, not bytes: a byte-based substr()
394 // can cut a multibyte UTF-8 character (e.g. Greek text) in half.
395 if (mb_strlen($description, 'UTF-8') > $this->max_description_length) {
396 $description = mb_substr($description, 0, $this->max_description_length, 'UTF-8') . '...';
397 }
398
399 $sku = $product->get_sku();
400 $categories = $this->get_product_category_names($product);
401 $url = get_permalink($product->get_id());
402
403 // Structured fields. The server rebuilds the embedding text from these,
404 // so there is no need to send a pre-formatted "text" blob.
405 $data = array(
406 'product_id' => $product->get_id(),
407 'name' => $product->get_name(),
408 'short_description' => trim($description),
409 'url' => $url,
410 'sku' => $sku,
411 'categories' => $categories,
412 'currency' => get_woocommerce_currency(),
413 );
414
415 // Price (always sent). Variable products carry a min/max range.
416 $data['price'] = $product->get_price();
417
418 if ($product->is_type('variable')) {
419 // Raw min/max prices, consistent with get_price() used for simple products.
420 $data['price_min'] = $product->get_variation_price('min', false);
421 $data['price_max'] = $product->get_variation_price('max', false);
422 } else {
423 $regular_price = $product->get_regular_price();
424 if ($regular_price !== '') {
425 $data['regular_price'] = $regular_price;
426 }
427 // Only advertise a sale price while the sale is actually active.
428 if ($product->is_on_sale()) {
429 $data['sale_price'] = $product->get_sale_price();
430 }
431 }
432
433 // Stock availability
434 $data['in_stock'] = $product->is_in_stock();
435 if ($product->managing_stock()) {
436 $stock_qty = $product->get_stock_quantity();
437 if ($stock_qty !== null) {
438 $data['quantity'] = (int) $stock_qty;
439 }
440 }
441
442 // Brand (renders as "Brand:" on the server). Detect the common brand taxonomies.
443 $brand = $this->get_product_brand($product);
444 if (!empty($brand)) {
445 $data['manufacturer'] = $brand;
446 }
447
448 // Variation attributes / options (Color, Size, ...)
449 $attributes = $this->get_product_attributes($product);
450 if (!empty($attributes)) {
451 $data['attributes'] = $attributes;
452 }
453
454 // Tags
455 $tags = $this->get_product_tags($product);
456 if (!empty($tags)) {
457 $data['tags'] = $tags;
458 }
459
460 // Average rating and review count
461 $rating = (float) $product->get_average_rating();
462 if ($rating > 0) {
463 $data['rating'] = $rating;
464 $data['review_count'] = (int) $product->get_review_count();
465 }
466
467 return $data;
468 }
469
470 /**
471 * Get product category names
472 */
473 private function get_product_category_names($product) {
474 $categories = array();
475 $category_ids = $product->get_category_ids();
476
477 foreach ($category_ids as $cat_id) {
478 $term = get_term($cat_id, 'product_cat');
479 if ($term && !is_wp_error($term)) {
480 $categories[] = $term->name;
481 }
482 }
483
484 return $categories;
485 }
486
487 /**
488 * Get the product's brand name from whichever brand taxonomy is available.
489 * Supports WooCommerce 9.6+ native brands and the common brand plugins.
490 */
491 private function get_product_brand($product) {
492 $taxonomies = array('product_brand', 'pwb-brand', 'yith_product_brand', 'pa_brand');
493
494 foreach ($taxonomies as $taxonomy) {
495 if (!taxonomy_exists($taxonomy)) {
496 continue;
497 }
498
499 $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names'));
500 if (!is_wp_error($terms) && !empty($terms)) {
501 return $terms[0];
502 }
503 }
504
505 return '';
506 }
507
508 /**
509 * Get visible product attributes as an array of { name, options }.
510 * Works for both custom and taxonomy-based (global) attributes.
511 */
512 private function get_product_attributes($product) {
513 $result = array();
514
515 foreach ($product->get_attributes() as $attribute) {
516 if (!is_object($attribute) || !$attribute->get_visible()) {
517 continue;
518 }
519
520 $name = wc_attribute_label($attribute->get_name());
521
522 if ($attribute->is_taxonomy()) {
523 $options = wc_get_product_terms($product->get_id(), $attribute->get_name(), array('fields' => 'names'));
524 } else {
525 $options = $attribute->get_options();
526 }
527
528 $options = array_values(array_filter(array_map('trim', (array) $options)));
529
530 if (!empty($name) && !empty($options)) {
531 $result[] = array(
532 'name' => $name,
533 'options' => $options,
534 );
535 }
536 }
537
538 return $result;
539 }
540
541 /**
542 * Get product tag names.
543 */
544 private function get_product_tags($product) {
545 $tags = wp_get_post_terms($product->get_id(), 'product_tag', array('fields' => 'names'));
546
547 if (is_wp_error($tags) || empty($tags)) {
548 return array();
549 }
550
551 return $tags;
552 }
553
554 /**
555 * Send batch of products to API (optimized)
556 * @param array $products - Array of product data
557 */
558 private function send_product_batch($products) {
559 $chatId = get_option('onwebchat_plugin_option');
560 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
561
562 if (empty($chatId)) {
563 error_log('onWebChat WooCommerce Sync - Chat ID not configured');
564 return false;
565 }
566
567 // Ensure we have a secret (must be obtained via authenticated connection in WooCommerce settings)
568 $secret = $this->get_secret(false);
569 if (empty($secret)) {
570 error_log('onWebChat WooCommerce Sync - No secret configured. Please connect WooCommerce in the plugin settings.');
571 return array(
572 'success' => false,
573 'error' => 'No secret configured. Please connect WooCommerce integration.',
574 'needs_reconnect' => true
575 );
576 }
577
578 // Extract key part (before first slash if present)
579 $chatIdKey = explode('/', $chatId)[0];
580
581 $endpoint = $this->get_api_endpoint() . '/product/batch';
582 $payload = array(
583 'site_id' => $chatIdKey,
584 'site_url' => get_site_url(),
585 'products' => $products
586 );
587
588 // Generate authentication headers (same as send_authenticated_request)
589 $timestamp = time();
590 $nonce = base64_encode(random_bytes(16));
591 $body_json = wp_json_encode($payload);
592
593 // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
594 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
595 $signature = hash_hmac('sha256', $message, $secret);
596
597 // Send request
598 $request_args = array(
599 'method' => 'POST',
600 'timeout' => 30, // Longer timeout for batch operations
601 'headers' => array(
602 'Content-Type' => 'application/json',
603 'X-OWC-SiteId' => $chatIdKey,
604 'X-OWC-Timestamp' => $timestamp,
605 'X-OWC-Nonce' => $nonce,
606 'X-OWC-Signature' => $signature,
607 ),
608 'body' => $body_json,
609 );
610
611 // Disable SSL verification for local dev server
612 if ($this->use_testing_mode) {
613 $request_args['sslverify'] = false;
614 }
615
616 $response = wp_remote_post($endpoint, $request_args);
617
618 if (is_wp_error($response)) {
619 error_log('onWebChat WooCommerce Sync - Batch sync error: ' . $response->get_error_message());
620 return array(
621 'success' => false,
622 'error' => 'Network error: ' . $response->get_error_message()
623 );
624 }
625
626 $response_code = wp_remote_retrieve_response_code($response);
627 $body = json_decode(wp_remote_retrieve_body($response), true);
628
629 // If authentication failed (401), the secret is invalid or out of sync
630 if ($response_code === 401) {
631 // Clear the invalid secret
632 delete_option('onwebchat_wc_sync_secret');
633
634 error_log('onWebChat WooCommerce Sync - Authentication failed (401): Secret is invalid or out of sync. Please reconnect WooCommerce in the plugin settings.');
635
636 // Store admin notice about authentication failure
637 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);
638
639 return array(
640 'success' => false,
641 'error' => 'Authentication failed. Secret is invalid. Please reconnect WooCommerce integration.',
642 'needs_reconnect' => true
643 );
644 }
645
646 if ($response_code === 200 && isset($body['success']) && $body['success']) {
647 // Clear any previous auth errors on success
648 delete_transient('onwebchat_wc_auth_error');
649 return $body; // Return full response with stats
650 }
651
652 $error_msg = 'Batch sync failed';
653 if (isset($body['error'])) {
654 $error_msg .= ': ' . $body['error'];
655 }
656 error_log('onWebChat WooCommerce Sync - ' . $error_msg . ' - Response: ' . print_r($body, true));
657
658 return array(
659 'success' => false,
660 'error' => $error_msg
661 );
662 }
663
664 /**
665 * Send sync completion notification to server (triggers Angular modal)
666 */
667 private function send_sync_completion_notification($total_stats) {
668 $chatId = get_option('onwebchat_plugin_option');
669 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
670
671 if (empty($chatId)) {
672 error_log('onWebChat WooCommerce Sync - Chat ID not configured');
673 return false;
674 }
675
676 $secret = $this->get_secret(false);
677 if (empty($secret)) {
678 error_log('onWebChat WooCommerce Sync - No secret configured');
679 return false;
680 }
681
682 $chatIdKey = explode('/', $chatId)[0];
683
684 $endpoint = $this->get_api_endpoint() . '/product/sync-complete';
685 $payload = array(
686 'site_id' => $chatIdKey,
687 'stats' => $total_stats
688 );
689
690 // Generate authentication headers
691 $timestamp = time();
692 $nonce = base64_encode(random_bytes(16));
693 $body_json = wp_json_encode($payload);
694 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
695 $signature = hash_hmac('sha256', $message, $secret);
696
697 $request_args = array(
698 'method' => 'POST',
699 'timeout' => 10,
700 'headers' => array(
701 'Content-Type' => 'application/json',
702 'X-OWC-SiteId' => $chatIdKey,
703 'X-OWC-Timestamp' => $timestamp,
704 'X-OWC-Nonce' => $nonce,
705 'X-OWC-Signature' => $signature,
706 ),
707 'body' => $body_json,
708 );
709
710 if ($this->use_testing_mode) {
711 $request_args['sslverify'] = false;
712 }
713
714 $response = wp_remote_post($endpoint, $request_args);
715
716 if (is_wp_error($response)) {
717 error_log('onWebChat WooCommerce Sync - Completion notification failed: ' . $response->get_error_message());
718 return false;
719 }
720
721 $response_code = wp_remote_retrieve_response_code($response);
722
723 // If authentication failed (401), the secret is invalid or out of sync
724 if ($response_code === 401) {
725 delete_option('onwebchat_wc_sync_secret');
726 error_log('onWebChat WooCommerce Sync - Completion notification authentication failed (401): Secret is invalid. Please reconnect WooCommerce.');
727 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);
728 return false;
729 }
730
731 if ($response_code >= 200 && $response_code < 300) {
732 error_log('onWebChat WooCommerce Sync - Completion notification sent successfully');
733 return true;
734 }
735
736 error_log('onWebChat WooCommerce Sync - Completion notification failed with code: ' . $response_code);
737 return false;
738 }
739
740 /**
741 * Send product upsert to server (uses batch endpoint with single product)
742 */
743 private function send_product_upsert($product_data, $product_id) {
744 // Use batch endpoint with single product
745 $result = $this->send_product_batch(array($product_data));
746
747 if ($result && isset($result['success']) && $result['success']) {
748 // Clear any previous errors
749 delete_post_meta($product_id, '_onwebchat_sync_error');
750 update_post_meta($product_id, '_onwebchat_last_sync', current_time('timestamp'));
751 return true;
752 } else {
753 // Log error if batch failed
754 $error_message = 'Failed to sync product';
755 if ($result && isset($result['error'])) {
756 $error_message = $result['error'];
757 } elseif (!$result) {
758 $error_message = 'Batch sync request failed';
759 }
760 $this->log_error($product_id, $error_message);
761 return false;
762 }
763 }
764
765 /**
766 * Send product delete to server
767 */
768 private function send_product_delete($product_id) {
769 $chatId = get_option('onwebchat_plugin_option');
770 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
771
772 if (empty($chatId)) {
773 return false;
774 }
775
776 // Extract key part (before first slash if present)
777 $chatIdKey = explode('/', $chatId)[0];
778
779 $endpoint = $this->get_api_endpoint() . '/product/delete';
780 $payload = array(
781 'site_id' => $chatIdKey, // Use key part only
782 'site_url' => get_site_url(),
783 'product_id' => $product_id
784 );
785
786 $this->send_authenticated_request($endpoint, $payload, $product_id);
787 }
788
789 /**
790 * Send a lightweight availability update to onWebChat (no re-embed on the server).
791 */
792 private function send_product_stock($product_id, $in_stock) {
793 $chatId = get_option('onwebchat_plugin_option');
794 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
795
796 if (empty($chatId)) {
797 return false;
798 }
799
800 // Extract key part (before first slash if present)
801 $chatIdKey = explode('/', $chatId)[0];
802
803 $endpoint = $this->get_api_endpoint() . '/product/stock';
804 $payload = array(
805 'site_id' => $chatIdKey, // Use key part only
806 'site_url' => get_site_url(),
807 'product_id' => $product_id,
808 'in_stock' => (bool) $in_stock,
809 );
810
811 $this->send_authenticated_request($endpoint, $payload, $product_id);
812 }
813
814 /**
815 * Get cached secret from local options
816 * @param {bool} force_refresh - Not used (kept for compatibility), secret must be obtained via authenticated request
817 */
818 private function get_secret($force_refresh = false) {
819 // Always return cached secret - never fetch automatically
820 // Secret must be obtained via authenticated request in WooCommerce settings
821 $secret = get_option('onwebchat_wc_sync_secret');
822
823 if (!empty($secret)) {
824 return $secret;
825 }
826
827 // No secret available - user must authenticate in WooCommerce settings
828 return null;
829 }
830
831 /**
832 * Request secret from server with authentication
833 * This is called when user clicks "Connect WooCommerce" with their password
834 *
835 * @param {string} email - User's onWebChat email
836 * @param {string} password - User's onWebChat password
837 * @return {array} - ['success' => bool, 'secret' => string, 'error' => string]
838 */
839 public function request_secret_with_auth($email, $password) {
840 $chatId = get_option('onwebchat_plugin_option');
841 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
842
843 if (empty($chatId)) {
844 return array('success' => false, 'error' => 'No Chat ID configured');
845 }
846
847 // Extract key part (before first slash if present)
848 $key = explode('/', $chatId)[0];
849
850 // Request secret from server with authentication
851 $secret_endpoint = $this->get_api_endpoint() . '/secret';
852
853 $response = wp_remote_post($secret_endpoint, array(
854 'timeout' => 15,
855 'sslverify' => !$this->use_testing_mode,
856 'headers' => array(
857 'Content-Type' => 'application/json',
858 ),
859 'body' => wp_json_encode(array(
860 'email' => $email,
861 'password' => $password,
862 'site_key' => $key,
863 'version' => defined('ONWEBCHAT_PLUGIN_VERSION') ? ONWEBCHAT_PLUGIN_VERSION : '',
864 )),
865 ));
866
867 if (is_wp_error($response)) {
868 $error_message = $response->get_error_message();
869 error_log('onWebChat WooCommerce Sync - Connection error: ' . $error_message);
870 return array('success' => false, 'error' => 'Connection failed: ' . $error_message);
871 }
872
873 $status_code = wp_remote_retrieve_response_code($response);
874 $response_body_raw = wp_remote_retrieve_body($response);
875 $body = json_decode($response_body_raw, true);
876
877 // Log response for debugging. Redact the secret so it never lands in server/debug logs
878 // (a successful response body contains the HMAC secret).
879 $log_body = preg_replace('/("secret"\s*:\s*")[^"]*(")/i', '$1[REDACTED]$2', (string) $response_body_raw);
880 error_log('onWebChat WooCommerce Sync - API response: Status=' . $status_code . ', Body=' . substr($log_body, 0, 500));
881
882 // Handle specific HTTP status codes
883 if ($status_code === 401) {
884 return array('success' => false, 'error' => 'Invalid email or password');
885 }
886
887 if ($status_code === 403) {
888 return array('success' => false, 'error' => 'You do not have access to this site');
889 }
890
891 // Success case
892 if ($status_code >= 200 && $status_code < 300 && isset($body['success']) && $body['success']) {
893 $secret = isset($body['secret']) ? $body['secret'] : null;
894 if (empty($secret)) {
895 error_log('onWebChat WooCommerce Sync - Success response but no secret provided');
896 return array('success' => false, 'error' => 'Server response missing secret');
897 }
898 update_option('onwebchat_wc_sync_secret', $secret);
899
900 // Enable AI order-status lookup by default on connect and register our
901 // callback URL with onWebChat (best effort; the merchant can toggle it off).
902 update_option('onwebchat_wc_order_lookup_enabled', true);
903 global $onwebchat_wc_orders;
904 if (isset($onwebchat_wc_orders) && is_object($onwebchat_wc_orders)) {
905 $onwebchat_wc_orders->push_order_lookup_config(true);
906 }
907
908 return array('success' => true, 'secret' => $secret);
909 }
910
911 // Extract error message from various possible response formats
912 $error_message = 'Unknown error';
913
914 if (is_array($body)) {
915 // Try different possible error fields
916 if (isset($body['error'])) {
917 $error_message = is_string($body['error']) ? $body['error'] : json_encode($body['error']);
918 } elseif (isset($body['message'])) {
919 $error_message = is_string($body['message']) ? $body['message'] : json_encode($body['message']);
920 } elseif (isset($body['errors']) && is_array($body['errors'])) {
921 $error_message = implode(', ', $body['errors']);
922 }
923 } elseif (!empty($response_body_raw)) {
924 // If body is not JSON or empty, use raw response (truncated)
925 $error_message = 'Server returned: ' . substr(strip_tags($response_body_raw), 0, 200);
926 }
927
928 // Defense in depth: the remote error is shown in the admin UI, so strip any markup here too
929 // (the client also renders it as text). Prevents a malicious/MITM'd API response carrying HTML.
930 $error_message = sanitize_text_field($error_message);
931
932 // Include status code in error message if not already included
933 if ($status_code && strpos($error_message, 'HTTP') === false) {
934 $error_message = 'HTTP ' . $status_code . ': ' . $error_message;
935 }
936
937 error_log('onWebChat WooCommerce Sync - Connection failed: ' . $error_message);
938 return array('success' => false, 'error' => $error_message);
939 }
940
941 /**
942 * Send authenticated request with HMAC signature
943 */
944 private function send_authenticated_request($endpoint, $payload, $product_id = null) {
945 // Get cached secret (must be obtained via authenticated connection in WooCommerce settings)
946 $secret = $this->get_secret(false);
947
948 if (empty($secret)) {
949 if ($product_id) {
950 $this->log_error($product_id, 'No secret configured. Please connect WooCommerce in the plugin settings.');
951 }
952 return array('success' => false, 'error' => 'Secret not available. Please connect WooCommerce in plugin settings.');
953 }
954
955 $chatId = get_option('onwebchat_plugin_option');
956 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
957
958 // Extract key part (before first slash if present) for consistency with server
959 // e.g., "5f02c87b60726a4663b25463a424a034/1/1" -> "5f02c87b60726a4663b25463a424a034"
960 $chatIdKey = explode('/', $chatId)[0];
961
962 // Generate authentication headers
963 $timestamp = time();
964 $nonce = base64_encode(random_bytes(16));
965 $body_json = wp_json_encode($payload);
966
967 // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
968 // IMPORTANT: Use the key part (not full chat_id) to match server-side verification
969 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
970 $signature = hash_hmac('sha256', $message, $secret);
971
972 // Send request
973 $request_args = array(
974 'method' => 'POST',
975 'timeout' => 10,
976 'headers' => array(
977 'Content-Type' => 'application/json',
978 'X-OWC-SiteId' => $chatIdKey, // Use key part only
979 'X-OWC-Timestamp' => $timestamp,
980 'X-OWC-Nonce' => $nonce,
981 'X-OWC-Signature' => $signature,
982 ),
983 'body' => $body_json,
984 );
985
986 // Disable SSL verification for local dev server
987 if ($this->use_testing_mode) {
988 $request_args['sslverify'] = false;
989 }
990
991 $response = wp_remote_post($endpoint, $request_args);
992
993 // Handle response
994 if (is_wp_error($response)) {
995 $error_message = $response->get_error_message();
996 if ($product_id) {
997 $this->log_error($product_id, $error_message);
998 }
999 return array('success' => false, 'error' => $error_message);
1000 }
1001
1002 $status_code = wp_remote_retrieve_response_code($response);
1003
1004 // Success
1005 if ($status_code >= 200 && $status_code < 300) {
1006 return array('success' => true);
1007 }
1008
1009 // If authentication failed (401), the secret may be invalid
1010 if ($status_code === 401) {
1011 // Clear the invalid secret
1012 delete_option('onwebchat_wc_sync_secret');
1013
1014 if ($product_id) {
1015 $this->log_error($product_id, 'Authentication failed. Please reconnect WooCommerce in the plugin settings.');
1016 }
1017 return array('success' => false, 'error' => 'Authentication failed. Please reconnect WooCommerce in plugin settings.');
1018 }
1019
1020 // Error
1021 $error_body = wp_remote_retrieve_body($response);
1022 if ($product_id) {
1023 $this->log_error($product_id, "HTTP $status_code: $error_body");
1024 }
1025
1026 return array('success' => false, 'error' => "HTTP $status_code", 'status_code' => $status_code);
1027 }
1028
1029 /**
1030 * Log sync error to product meta
1031 */
1032 private function log_error($product_id, $error_message) {
1033 update_post_meta($product_id, '_onwebchat_sync_error', array(
1034 'message' => $error_message,
1035 'timestamp' => current_time('timestamp')
1036 ));
1037 }
1038
1039 /**
1040 * AJAX: Start bulk sync
1041 */
1042 public function ajax_sync_existing_products() {
1043 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1044
1045 if (!current_user_can('manage_options')) {
1046 wp_send_json_error('Insufficient permissions');
1047 }
1048
1049 // Check if sync is already in progress
1050 if (get_option('onwebchat_wc_bulk_in_progress', false)) {
1051 wp_send_json_error('A sync is already in progress. Please wait for it to complete.');
1052 }
1053
1054 // Rate limiting: prevent syncing more than once every 5 minutes
1055 $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0);
1056 $cooldown_period = 5 * 60; // 5 minutes in seconds //also in the file woocommerce.php // 5 * 60
1057 $time_since_last_sync = time() - $last_sync_time;
1058
1059 if ($time_since_last_sync < $cooldown_period) {
1060 $wait_time = $cooldown_period - $time_since_last_sync;
1061 $minutes = ceil($wait_time / 60);
1062 wp_send_json_error('Please wait ' . $minutes . ' minute(s) before syncing again.');
1063 }
1064
1065 // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue.
1066 $category_ids = array();
1067 if (isset($_POST['categories']) && $_POST['categories'] !== '') {
1068 foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) {
1069 $id = (int) trim($id);
1070 if ($id > 0) {
1071 $category_ids[] = $id;
1072 }
1073 }
1074 }
1075
1076 // Above the hard cap a category selection is required: refuse an
1077 // unrestricted "sync all" when the catalogue is larger than the cap.
1078 $published_total = $this->count_products_in_scope(array());
1079 if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) {
1080 wp_send_json_error(sprintf(
1081 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.',
1082 number_format_i18n($published_total),
1083 number_format_i18n(self::MAX_SYNC_PRODUCTS)
1084 ));
1085 }
1086
1087 // Remember the merchant's choice so ongoing auto-sync stays within it:
1088 // selected categories become the sync scope; an unrestricted "sync all"
1089 // clears the scope (the whole catalogue is in scope again).
1090 $this->save_sync_scope($category_ids);
1091
1092 // Store the current sync start time
1093 update_option('onwebchat_wc_last_sync_start', time());
1094
1095 // Reset bulk sync progress
1096 update_option('onwebchat_wc_bulk_page', 0);
1097 update_option('onwebchat_wc_bulk_done', 0);
1098
1099 // Count total products within scope, capped at the hard limit.
1100 $total = $this->count_products_in_scope($category_ids);
1101 if ($total > self::MAX_SYNC_PRODUCTS) {
1102 $total = self::MAX_SYNC_PRODUCTS;
1103 }
1104
1105 update_option('onwebchat_wc_bulk_total', $total);
1106 update_option('onwebchat_wc_bulk_done', 0); // Initialize progress counter
1107 update_option('onwebchat_wc_bulk_in_progress', true);
1108
1109 // Process sync directly instead of using unreliable WP Cron
1110 $sync_result = $this->do_bulk_sync_all($category_ids);
1111
1112 wp_send_json_success(array(
1113 'message' => 'Bulk sync completed',
1114 'total' => $total,
1115 'result' => $sync_result
1116 ));
1117 }
1118
1119 /**
1120 * Process all products in bulk sync directly (not via cron).
1121 *
1122 * @param array $category_ids Sync scope (product_cat term IDs). Empty = whole catalogue.
1123 */
1124 private function do_bulk_sync_all($category_ids = array()) {
1125 $total = get_option('onwebchat_wc_bulk_total', 0);
1126 $page = 0;
1127 $total_done = 0;
1128 $considered = 0; // products fetched so far, used to enforce the hard cap
1129 $all_stats = array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0);
1130 $max = self::MAX_SYNC_PRODUCTS;
1131
1132 // Process all products in batches
1133 while (true) {
1134 $args = array(
1135 'post_type' => 'product',
1136 'post_status' => 'publish',
1137 'posts_per_page' => $this->batch_size,
1138 'paged' => $page + 1,
1139 'orderby' => 'ID',
1140 'order' => 'ASC',
1141 );
1142
1143 // Restrict to the chosen top-level categories and their subtrees, to
1144 // match the per-product scope check and the counts shown in the picker.
1145 if (!empty($category_ids)) {
1146 $args['tax_query'] = array(array(
1147 'taxonomy' => 'product_cat',
1148 'field' => 'term_id',
1149 'terms' => array_map('intval', $category_ids),
1150 'include_children' => true,
1151 ));
1152 }
1153
1154 $query = new WP_Query($args);
1155
1156 if (!$query->have_posts()) {
1157 break;
1158 }
1159
1160 // Collect products in this batch, honoring the hard cap.
1161 $products_batch = array();
1162 $reached_cap = false;
1163 foreach ($query->posts as $post) {
1164 if ($considered >= $max) {
1165 $reached_cap = true;
1166 break;
1167 }
1168 $considered++;
1169 $product = wc_get_product($post->ID);
1170 if ($product && !$this->is_product_excluded($product)) {
1171 $products_batch[] = $this->prepare_product_data($product);
1172 }
1173 }
1174
1175 // Send batch
1176 if (!empty($products_batch)) {
1177 $result = $this->send_product_batch($products_batch);
1178 if ($result && isset($result['stats'])) {
1179 $total_done += $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
1180 $all_stats['created'] += $result['stats']['created'];
1181 $all_stats['updated'] += $result['stats']['updated'];
1182 $all_stats['skipped'] += $result['stats']['skipped'];
1183 $all_stats['errors'] += $result['stats']['errors'];
1184 } else {
1185 // Fallback
1186 $total_done += count($products_batch);
1187 }
1188
1189 // Update progress after each batch so AJAX polling can see it
1190 update_option('onwebchat_wc_bulk_done', $total_done);
1191
1192 // Wait 4 seconds before next batch
1193 sleep(4);
1194 }
1195
1196 wp_reset_postdata();
1197 $page++;
1198
1199 // Stop once the hard cap is reached.
1200 if ($reached_cap || $considered >= $max) {
1201 break;
1202 }
1203
1204 // Safety check - don't loop forever. The cap allows up to
1205 // MAX_SYNC_PRODUCTS / batch_size batches, so keep a generous guard.
1206 if ($page > ($max / $this->batch_size) + 10) {
1207 break;
1208 }
1209 }
1210
1211 // Send completion notification to Angular dashboard with total stats
1212 $this->send_sync_completion_notification($all_stats);
1213
1214 // Mark sync as complete
1215 update_option('onwebchat_wc_bulk_in_progress', false);
1216 update_option('onwebchat_wc_bulk_done', $total_done);
1217 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1218
1219 return array(
1220 'done' => $total_done,
1221 'total' => $total,
1222 'stats' => $all_stats
1223 );
1224 }
1225
1226 /**
1227 * Process bulk sync batch (via WP Cron) - Uses batch API endpoint
1228 */
1229 public function process_bulk_sync_batch() {
1230 error_log('onWebChat WooCommerce Sync - process_bulk_sync_batch called');
1231
1232 if (!get_option('onwebchat_wc_bulk_in_progress', false)) {
1233 error_log('onWebChat WooCommerce Sync - Sync not in progress, exiting');
1234 return;
1235 }
1236
1237 $page = get_option('onwebchat_wc_bulk_page', 0);
1238 $done = get_option('onwebchat_wc_bulk_done', 0);
1239 $total = get_option('onwebchat_wc_bulk_total', 0);
1240
1241 error_log('onWebChat WooCommerce Sync - Starting batch: page=' . $page . ', done=' . $done . ', total=' . $total);
1242
1243 // Get batch of products
1244 $args = array(
1245 'post_type' => 'product',
1246 'post_status' => 'publish',
1247 'posts_per_page' => $this->batch_size,
1248 'paged' => $page + 1,
1249 'orderby' => 'ID',
1250 'order' => 'ASC',
1251 );
1252
1253 // Restrict to the saved sync scope and its subcategories, consistent
1254 // with the synchronous bulk sync path.
1255 $scope = $this->get_sync_scope();
1256 if (!empty($scope)) {
1257 $args['tax_query'] = array(array(
1258 'taxonomy' => 'product_cat',
1259 'field' => 'term_id',
1260 'terms' => array_map('intval', $scope),
1261 'include_children' => true,
1262 ));
1263 }
1264
1265 $query = new WP_Query($args);
1266
1267 if ($query->have_posts()) {
1268 // Collect all products in this batch
1269 $products_batch = array();
1270
1271 foreach ($query->posts as $post) {
1272 $product = wc_get_product($post->ID);
1273 if ($product && !$this->is_product_excluded($product)) {
1274 $products_batch[] = $this->prepare_product_data($product);
1275 }
1276 }
1277
1278 // Send entire batch in one request
1279 $batch_done = 0;
1280 if (!empty($products_batch)) {
1281 $result = $this->send_product_batch($products_batch);
1282 error_log('onWebChat WooCommerce Sync - Batch result: ' . print_r($result, true));
1283 if ($result && isset($result['stats'])) {
1284 // Count created + updated + skipped as "done"
1285 $batch_done = $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
1286 error_log('onWebChat WooCommerce Sync - Batch done: ' . $batch_done);
1287 } else {
1288 // Fallback: assume all sent
1289 $batch_done = count($products_batch);
1290 error_log('onWebChat WooCommerce Sync - No stats in result, using fallback count: ' . $batch_done);
1291 }
1292 }
1293
1294 // Update progress
1295 $new_done = $done + $batch_done;
1296 error_log('onWebChat WooCommerce Sync - Progress update: done=' . $done . ' + batch_done=' . $batch_done . ' = new_done=' . $new_done . ' / total=' . $total);
1297 update_option('onwebchat_wc_bulk_page', $page + 1);
1298 update_option('onwebchat_wc_bulk_done', $new_done);
1299
1300 // Check if we've processed all products
1301 if ($new_done >= $total || !$query->have_posts()) {
1302 // Sync complete
1303 error_log('onWebChat WooCommerce Sync - Marking sync as complete');
1304 update_option('onwebchat_wc_bulk_in_progress', false);
1305 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1306 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
1307 } else {
1308 // Schedule next batch
1309 error_log('onWebChat WooCommerce Sync - Scheduling next batch in 60 seconds');
1310 wp_schedule_single_event(time() + 60, 'onwebchat_wc_bulk_sync_batch');
1311 }
1312 } else {
1313 // No more products - sync complete
1314 update_option('onwebchat_wc_bulk_in_progress', false);
1315 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1316 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
1317 }
1318
1319 wp_reset_postdata();
1320 }
1321
1322 /**
1323 * AJAX: Regenerate secret (fetch from server)
1324 */
1325 public function ajax_regenerate_secret() {
1326 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1327
1328 if (!current_user_can('manage_options')) {
1329 wp_send_json_error('Insufficient permissions');
1330 }
1331
1332 // Clear cached secret - user will need to re-authenticate
1333 delete_option('onwebchat_wc_sync_secret');
1334
1335 // Clear any authentication error notices
1336 delete_transient('onwebchat_wc_auth_error');
1337
1338 wp_send_json_success(array(
1339 'message' => 'Secret cleared. Please reconnect WooCommerce with your credentials.',
1340 'needs_reconnect' => true
1341 ));
1342 }
1343
1344 /**
1345 * AJAX: Reset sync status
1346 */
1347 public function ajax_reset_sync_status() {
1348 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1349
1350 if (!current_user_can('manage_options')) {
1351 wp_send_json_error('Insufficient permissions');
1352 }
1353
1354 $total = get_option('onwebchat_wc_bulk_total', 0);
1355
1356 // Mark sync as complete
1357 update_option('onwebchat_wc_bulk_in_progress', false);
1358 update_option('onwebchat_wc_bulk_done', $total);
1359 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1360
1361 wp_send_json_success(array(
1362 'message' => 'Sync status reset successfully'
1363 ));
1364 }
1365
1366 /**
1367 * AJAX handler to get current sync status
1368 */
1369 public function ajax_get_sync_status() {
1370 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1371
1372 if (!current_user_can('manage_options')) {
1373 wp_send_json_error('Insufficient permissions');
1374 }
1375
1376 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
1377 $done = get_option('onwebchat_wc_bulk_done', 0);
1378 $total = get_option('onwebchat_wc_bulk_total', 0);
1379
1380 wp_send_json_success(array(
1381 'in_progress' => $in_progress,
1382 'done' => $done,
1383 'total' => $total
1384 ));
1385 }
1386
1387 /**
1388 * AJAX handler to save sync enabled setting
1389 */
1390 public function ajax_save_sync_enabled() {
1391 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1392
1393 if (!current_user_can('manage_options')) {
1394 wp_send_json_error('Insufficient permissions');
1395 }
1396
1397 $sync_enabled = isset($_POST['sync_enabled']) && $_POST['sync_enabled'] === '1';
1398
1399 update_option('onwebchat_wc_sync_enabled', $sync_enabled);
1400
1401 wp_send_json_success(array(
1402 'message' => $sync_enabled ? 'WooCommerce product sync enabled' : 'WooCommerce product sync disabled',
1403 'enabled' => $sync_enabled
1404 ));
1405 }
1406
1407 /**
1408 * Get sync status for admin display
1409 */
1410 public function get_sync_status() {
1411 $last_sync = get_option('onwebchat_wc_last_bulk_sync', 0);
1412 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
1413 $done = get_option('onwebchat_wc_bulk_done', 0);
1414 $total = get_option('onwebchat_wc_bulk_total', 0);
1415
1416 return array(
1417 'last_sync' => $last_sync,
1418 'in_progress' => $in_progress,
1419 'done' => $done,
1420 'total' => $total,
1421 );
1422 }
1423
1424 /**
1425 * AJAX: Manually process batch (for debugging)
1426 */
1427 public function ajax_manual_process_batch() {
1428 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1429
1430 if (!current_user_can('manage_options')) {
1431 wp_send_json_error('Insufficient permissions');
1432 }
1433
1434 // Manually trigger the cron job
1435 error_log('onWebChat WooCommerce Sync - Manual batch process triggered via AJAX');
1436 $this->process_bulk_sync_batch();
1437
1438 // Return current status
1439 $status = $this->get_sync_status();
1440 wp_send_json_success(array(
1441 'message' => 'Batch processed',
1442 'status' => $status
1443 ));
1444 }
1445 }
1446
1447 // Initialize the sync module
1448 global $onwebchat_wc_sync;
1449 $onwebchat_wc_sync = new OnWebChat_WooCommerce_Sync();
1450
1451