Changeset 3730094
- Timestamp:
- 10/06/2026 06:11:53 AM (3 weeks ago)
- Location:
- emporiqa
- Files:
-
- 10 added
- 38 edited
- 1 copied
-
assets/screenshot-8.jpg (modified) ( previous)
-
assets/screenshot-9.jpg (modified) ( previous)
-
tags/1.2.0 (copied) (copied from emporiqa/trunk )
-
tags/1.2.0/admin/css/emporiqa-admin.css (modified) (1 diff)
-
tags/1.2.0/admin/js/emporiqa-admin.js (modified) (7 diffs)
-
tags/1.2.0/assets/js/emporiqa-cart.js (modified) (4 diffs)
-
tags/1.2.0/assets/js/emporiqa-customer-token.js (added)
-
tags/1.2.0/changelog.txt (modified) (1 diff)
-
tags/1.2.0/cli/class-emporiqa-cli.php (modified) (8 diffs)
-
tags/1.2.0/emporiqa.php (modified) (9 diffs)
-
tags/1.2.0/includes/class-emporiqa-actions.php (added)
-
tags/1.2.0/includes/class-emporiqa-admin.php (modified) (7 diffs)
-
tags/1.2.0/includes/class-emporiqa-cart-ajax.php (modified) (9 diffs)
-
tags/1.2.0/includes/class-emporiqa-connect.php (modified) (7 diffs)
-
tags/1.2.0/includes/class-emporiqa-customer-prices.php (added)
-
tags/1.2.0/includes/class-emporiqa-formatter.php (modified) (10 diffs)
-
tags/1.2.0/includes/class-emporiqa-order-status.php (added)
-
tags/1.2.0/includes/class-emporiqa-order-tracking.php (modified) (6 diffs)
-
tags/1.2.0/includes/class-emporiqa-order-webhook.php (modified) (2 diffs)
-
tags/1.2.0/includes/class-emporiqa-signature.php (added)
-
tags/1.2.0/includes/class-emporiqa-sync.php (modified) (17 diffs)
-
tags/1.2.0/includes/class-emporiqa-webhook.php (modified) (11 diffs)
-
tags/1.2.0/includes/class-emporiqa-widget.php (modified) (8 diffs)
-
tags/1.2.0/languages/emporiqa.pot (modified) (12 diffs)
-
tags/1.2.0/readme.txt (modified) (4 diffs)
-
tags/1.2.0/uninstall.php (modified) (2 diffs)
-
trunk/admin/css/emporiqa-admin.css (modified) (1 diff)
-
trunk/admin/js/emporiqa-admin.js (modified) (7 diffs)
-
trunk/assets/js/emporiqa-cart.js (modified) (4 diffs)
-
trunk/assets/js/emporiqa-customer-token.js (added)
-
trunk/changelog.txt (modified) (1 diff)
-
trunk/cli/class-emporiqa-cli.php (modified) (8 diffs)
-
trunk/emporiqa.php (modified) (9 diffs)
-
trunk/includes/class-emporiqa-actions.php (added)
-
trunk/includes/class-emporiqa-admin.php (modified) (7 diffs)
-
trunk/includes/class-emporiqa-cart-ajax.php (modified) (9 diffs)
-
trunk/includes/class-emporiqa-connect.php (modified) (7 diffs)
-
trunk/includes/class-emporiqa-customer-prices.php (added)
-
trunk/includes/class-emporiqa-formatter.php (modified) (10 diffs)
-
trunk/includes/class-emporiqa-order-status.php (added)
-
trunk/includes/class-emporiqa-order-tracking.php (modified) (6 diffs)
-
trunk/includes/class-emporiqa-order-webhook.php (modified) (2 diffs)
-
trunk/includes/class-emporiqa-signature.php (added)
-
trunk/includes/class-emporiqa-sync.php (modified) (17 diffs)
-
trunk/includes/class-emporiqa-webhook.php (modified) (11 diffs)
-
trunk/includes/class-emporiqa-widget.php (modified) (8 diffs)
-
trunk/languages/emporiqa.pot (modified) (12 diffs)
-
trunk/readme.txt (modified) (4 diffs)
-
trunk/uninstall.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
emporiqa/tags/1.2.0/admin/css/emporiqa-admin.css
r3506790 r3730094 371 371 color: #1d2327; 372 372 } 373 374 /* Ready-made rules: On / Not added */ 375 .emporiqa-rule-state { 376 display: inline-block; 377 padding: 2px 8px; 378 border-radius: 3px; 379 background: #f0f0f1; 380 color: #50575e; 381 font-weight: 600; 382 vertical-align: middle; 383 } 384 385 .emporiqa-rule-state.emporiqa-rule-on { 386 background: #edfaef; 387 color: #00701a; 388 } -
emporiqa/tags/1.2.0/admin/js/emporiqa-admin.js
r3601468 r3730094 12 12 var syncRunning = false; 13 13 var currentXhr = null; 14 15 // Consecutive waits for one batch before it counts as failed. 16 var MAX_BATCH_WAITS = 30; 14 17 15 18 /** … … 335 338 var totalItems = productCount + pageCount; 336 339 337 // Build work queue: for each session, calculate pages. 340 // Build work queue: one entry per session. Each entry is 341 // paged by id (after_id) until the server says it is done, 342 // so items changing mid-sync never shift the pages. 338 343 var workQueue = []; 339 344 var sessionCount = sessions.length; 340 var s , itemCount, totalPages, p;345 var s; 341 346 342 347 for ( s = 0; s < sessionCount; s++ ) { 343 itemCount = 0;344 if ( 'products' === sessions[ s ].entity ) {345 itemCount = productCount;346 } else if ( 'pages' === sessions[ s ].entity ) {347 itemCount = pageCount;348 }349 350 totalPages = Math.ceil( itemCount / itemsPerBatch );351 348 addLogEntry( 352 349 sprintf( … … 357 354 ); 358 355 359 for ( p = 1; p <= totalPages; p++ ) { 360 workQueue.push( 361 { 362 entity: sessions[ s ].entity, 363 language: sessions[ s ].language, 364 session_id: sessions[ s ].session_id, 365 page: p, 366 items_per_batch: itemsPerBatch 367 } 368 ); 369 } 356 workQueue.push( 357 { 358 entity: sessions[ s ].entity, 359 language: sessions[ s ].language, 360 session_id: sessions[ s ].session_id, 361 after_id: 0, 362 items_per_batch: itemsPerBatch, 363 waits: 0 364 } 365 ); 370 366 } 371 367 … … 401 397 402 398 var work = workQueue[ batchIndex ]; 403 batchIndex++;404 399 405 400 currentXhr = $.post( … … 411 406 language: work.language, 412 407 session_id: work.session_id, 413 page: work.page,408 after_id: work.after_id, 414 409 items_per_batch: work.items_per_batch 415 410 } 416 411 ).done( 417 412 function ( batchResponse ) { 413 var waitSeconds = batchResponse.success && batchResponse.data ? parseInt( batchResponse.data.retry_after, 10 ) || 0 : 0; 414 if ( waitSeconds > 0 && work.waits < MAX_BATCH_WAITS ) { 415 // Emporiqa asked to wait (its queue is full): send the 416 // same batch again; the server holds it as pending. 417 work.waits++; 418 addLogEntry( 419 sprintf( 420 i18n.batchWait || 'Emporiqa is busy and asked to wait. Sending this %1$s batch again in %2$d seconds.', 421 work.entity, 422 waitSeconds 423 ), 424 'warning' 425 ); 426 setTimeout( processBatch, waitSeconds * 1000 ); 427 return; 428 } 429 work.waits = 0; 430 418 431 if ( batchResponse.success && batchResponse.data ) { 419 432 var batchProcessed = batchResponse.data.processed || 0; … … 456 469 457 470 if ( totalItems > 0 ) { 458 updateProgress( ( processed / totalItems ) * 100 ); 471 updateProgress( Math.min( 99, ( processed / totalItems ) * 100 ) ); 472 } 473 474 var batchData = ( batchResponse.success && batchResponse.data ) || {}; 475 var lastId = parseInt( batchData.last_id, 10 ) || 0; 476 if ( batchData.done || lastId <= work.after_id ) { 477 batchIndex++; 478 } else { 479 work.after_id = lastId; 459 480 } 460 481 … … 475 496 ); 476 497 } 498 // Where the next batch starts is unknown, so the rest 499 // of this session is skipped; the failure above keeps 500 // the session from being finalized. 501 batchIndex++; 477 502 processBatch(); 478 503 } -
emporiqa/tags/1.2.0/assets/js/emporiqa-cart.js
r3491860 r3730094 35 35 * Automatically updates the nonce from each response to keep 36 36 * long-lived browser sessions working beyond the default 24h expiry. 37 * 38 * @param {string} action The WordPress AJAX action name. 39 * @param {Object|null} data Additional key-value pairs for the request body. 37 * A nonce refusal is retried once with the fresh nonce it carries: a page 38 * served from a full-page cache can hold an expired nonce, or a guest's 39 * nonce for a signed-in shopper, and the refused request changed nothing. 40 * 41 * @param {string} action The WordPress AJAX action name. 42 * @param {Object|null} data Additional key-value pairs for the request body. 43 * @param {boolean} retried True on the retry after a nonce refusal. 40 44 * @return {Promise<Object>} Parsed JSON response matching the EmporiqaCartHandler spec. 41 45 */ 42 function emporiqaAjax( action, data ) {46 function emporiqaAjax( action, data, retried ) { 43 47 var formData = new FormData(); 44 48 formData.append( 'action', action ); … … 62 66 config.nonce = result.nonce; 63 67 } 64 return result; 65 } ); 66 } 67 68 /** 69 * Trigger WooCommerce mini-cart fragment refresh after cart changes. 70 */ 71 function refreshCartFragments() { 68 if ( ! retried && result && result.success === false && result.code === 'invalid_nonce' && result.nonce ) { 69 return emporiqaAjax( action, data, true ); 70 } 71 return result; 72 } ); 73 } 74 75 /** 76 * Tell the page's mini-cart that the cart changed. 77 * 78 * Classic themes refresh their cart fragments on wc_fragment_refresh. 79 * The Mini-Cart block of block themes ignores that and refetches the 80 * cart on wc-blocks_added_to_cart / wc-blocks_removed_from_cart. 81 * 82 * @param {boolean} removed True when items were removed (remove, clear). 83 */ 84 function refreshCartFragments( removed ) { 72 85 if ( typeof jQuery !== 'undefined' ) { 73 86 jQuery( document.body ).trigger( 'wc_fragment_refresh' ); 87 } 88 if ( typeof document !== 'undefined' && document.body && typeof window.Event === 'function' ) { 89 document.body.dispatchEvent( 90 new window.Event( removed ? 'wc-blocks_removed_from_cart' : 'wc-blocks_added_to_cart', { bubbles: true } ) 91 ); 74 92 } 75 93 } … … 153 171 return emporiqaAjax( 'emporiqa_remove_from_cart', data ).then( function( result ) { 154 172 if ( result.success ) { 155 refreshCartFragments( );173 refreshCartFragments( true ); 156 174 } 157 175 return result; … … 167 185 return emporiqaAjax( 'emporiqa_clear_cart' ).then( function( result ) { 168 186 if ( result.success ) { 169 refreshCartFragments( );187 refreshCartFragments( true ); 170 188 } 171 189 return result; -
emporiqa/tags/1.2.0/changelog.txt
r3678094 r3730094 2 2 3 3 Older Emporiqa releases. The two most recent versions are in readme.txt. 4 5 = 1.1.9 = 6 * Fix: a full sync now finalizes only when every batch succeeds, so a failed batch can no longer drop items still present in your store from the chat's index. Nothing in your WooCommerce store was ever modified. 7 * Fix: variable products with parent-managed stock no longer over-report quantity, and decimal quantities are no longer truncated in the cart. 4 8 5 9 = 1.1.8 = -
emporiqa/tags/1.2.0/cli/class-emporiqa-cli.php
r3601468 r3730094 89 89 $synced = 0; 90 90 $errors = 0; 91 $ page = 1;91 $after_id = 0; 92 92 $per_page = 100; 93 93 94 // Keyset by id, not OFFSET: a product disabled mid-sync must not 95 // shift the pages and get a live one skipped (and then deleted from 96 // Emporiqa when the session completes). 94 97 while ( true ) { 95 98 $query_args = array( 96 'status' => 'publish', 97 'limit' => $per_page, 98 'page' => $page, 99 'type' => Emporiqa_Admin::get_product_types(), 99 'status' => 'publish', 100 'limit' => $per_page, 101 'orderby' => 'ID', 102 'order' => 'ASC', 103 'type' => Emporiqa_Admin::get_product_types(), 104 'emporiqa_after_id' => $after_id, 100 105 ); 101 106 … … 113 118 } 114 119 115 $products = wc_get_products( $query_args ); 120 $products = Emporiqa_Sync::query_after_id( 121 function () use ( $query_args ) { 122 return wc_get_products( $query_args ); 123 } 124 ); 116 125 if ( empty( $products ) ) { 117 126 break; … … 119 128 120 129 foreach ( $products as $product ) { 130 $after_id = max( $after_id, (int) $product->get_id() ); 121 131 $formatted = $formatter->format_product_consolidated( $product, $session_id ); 122 132 foreach ( $formatted as $item ) { … … 142 152 143 153 wp_cache_flush(); 144 ++$page;145 154 } 146 155 … … 233 242 $synced = 0; 234 243 $errors = 0; 235 $ paged = 1;244 $after_id = 0; 236 245 $per_page = 100; 237 246 238 247 while ( true ) { 239 248 $query_args = array( 240 'post_type' => $post_types, 241 'post_status' => 'publish', 242 'posts_per_page' => $per_page, 243 'paged' => $paged, 244 'suppress_filters' => false, 249 'post_type' => $post_types, 250 'post_status' => 'publish', 251 'posts_per_page' => $per_page, 252 'orderby' => 'ID', 253 'order' => 'ASC', 254 'no_found_rows' => true, 255 'suppress_filters' => false, 256 'emporiqa_after_id' => $after_id, 245 257 ); 246 258 … … 252 264 } 253 265 254 $pages = get_posts( $query_args ); 266 $pages = Emporiqa_Sync::query_after_id( 267 function () use ( $query_args ) { 268 return get_posts( $query_args ); 269 } 270 ); 255 271 if ( empty( $pages ) ) { 256 272 break; … … 258 274 259 275 foreach ( $pages as $page ) { 276 $after_id = max( $after_id, (int) $page->ID ); 260 277 $formatted = $formatter->format_page_consolidated( $page, $session_id ); 261 278 if ( ! empty( $formatted ) ) { … … 281 298 282 299 wp_cache_flush(); 283 ++$paged;284 300 } 285 301 -
emporiqa/tags/1.2.0/emporiqa.php
r3678232 r3730094 3 3 * Plugin Name: Emporiqa 4 4 * Description: Integrates WooCommerce with Emporiqa chat assistant. 5 * Version: 1. 1.105 * Version: 1.2.0 6 6 * Author: Emporiqa 7 7 * Author URI: https://emporiqa.com … … 10 10 * Text Domain: emporiqa 11 11 * Domain Path: /languages 12 * Requires at least: 6. 012 * Requires at least: 6.2 13 13 * Tested up to: 7.1 14 14 * Requires PHP: 7.4 15 15 * Requires Plugins: woocommerce 16 16 * WC requires at least: 8.0 17 * WC tested up to: 1 0.917 * WC tested up to: 11.1 18 18 * 19 19 * @package Emporiqa … … 25 25 } 26 26 27 define( 'EMPORIQA_VERSION', '1. 1.10' );27 define( 'EMPORIQA_VERSION', '1.2.0' ); 28 28 define( 'EMPORIQA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); 29 29 define( 'EMPORIQA_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); … … 36 36 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-widget.php'; 37 37 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-order-tracking.php'; 38 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-signature.php'; 39 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-order-status.php'; 40 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-customer-prices.php'; 41 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-actions.php'; 38 42 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-cart-ajax.php'; 39 43 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-order-webhook.php'; … … 94 98 */ 95 99 private Emporiqa_Order_Tracking $order_tracking; 100 101 /** 102 * Ready-made rules endpoints (Order status, customer prices, verify). 103 * 104 * @var Emporiqa_Actions 105 */ 106 private Emporiqa_Actions $actions; 96 107 97 108 /** … … 206 217 $this->widget = new Emporiqa_Widget(); 207 218 $this->order_tracking = new Emporiqa_Order_Tracking(); 219 $this->actions = new Emporiqa_Actions(); 208 220 $this->cart_ajax = new Emporiqa_Cart_Ajax(); 209 221 $this->order_webhook = new Emporiqa_Order_Webhook(); … … 229 241 private function init_hooks(): void { 230 242 add_action( 'woocommerce_init', array( $this, 'init_woocommerce_hooks' ) ); 243 244 // Quantity tiers of the Tiered Price Table plugin, when it is active. 245 add_filter( 'emporiqa_price_tiers', array( 'Emporiqa_Formatter', 'tiered_price_table_tiers' ), 5, 2 ); 231 246 232 247 // Page-save entry point. Inside the handler we skip when Polylang is … … 269 284 add_action( 'woocommerce_new_product', array( $this, 'handle_product_created' ) ); 270 285 add_action( 'woocommerce_update_product', array( $this, 'handle_product_updated' ) ); 271 add_action( 'wp_trash_post', array( $this, 'handle_product_trashed' ) ); 286 // Before WooCommerce's own wp_trash_post (priority 10) trashes the 287 // variations, so the variation deletes can still be found and sent. 288 add_action( 'wp_trash_post', array( $this, 'handle_product_trashed' ), 5 ); 272 289 add_action( 'untrashed_post', array( $this, 'handle_product_untrashed' ) ); 273 290 add_action( 'woocommerce_new_product_variation', array( $this, 'handle_variation_created' ) ); … … 449 466 * wp_trash_post fires when a product is moved to trash. WooCommerce's 450 467 * woocommerce_update_product does NOT fire on trash, so this is needed. 451 * Cannot go through format_product_consolidated because WooCommerce 452 * trashes child variations before this hook fires at priority 10. 468 * Runs at priority 5, before WooCommerce trashes the child variations at 469 * priority 10, so queue_product_delete() still sees them and deletes 470 * them too; afterwards get_children() no longer returns them. 453 471 * 454 472 * @since 1.0.0 -
emporiqa/tags/1.2.0/includes/class-emporiqa-admin.php
r3678094 r3730094 34 34 */ 35 35 private string $option_name = 'emporiqa_options'; 36 37 /** 38 * Option holding what Emporiqa last said about ready-made rules. 39 */ 40 public const RULES_STATUS_OPTION = 'emporiqa_rules_status'; 36 41 37 42 /** … … 156 161 } 157 162 163 // The checkbox is on the page only where ready-made rules are offered; 164 // elsewhere a save keeps the stored value (on by default). 165 if ( isset( $input['order_tracking_shown'] ) ) { 166 $sanitized['order_tracking'] = ! empty( $input['order_tracking'] ); 167 } else { 168 $existing = get_option( $this->option_name, array() ); 169 $sanitized['order_tracking'] = ! is_array( $existing ) || ! isset( $existing['order_tracking'] ) || (bool) $existing['order_tracking']; 170 } 171 158 172 $batch_size = absint( $input['batch_size'] ?? 25 ); 159 173 if ( $batch_size < 1 || $batch_size > 100 ) { … … 242 256 243 257 <?php $this->render_connection_section( $options ); ?> 258 <?php $this->render_rules_section( $options ); ?> 244 259 <?php $this->render_advanced_section( $options ); ?> 245 260 … … 338 353 } 339 354 340 $this->render_order_tracking_row(); 355 // Where ready-made rules are offered, order tracking moves under 356 // Advanced as the old way; elsewhere it stays here as before. 357 if ( ! self::rules_status()['available'] ) { 358 $this->render_order_tracking_row(); 359 } 341 360 $this->render_section_close(); 361 } 362 363 /** 364 * Render the Ready-made rules section: shown once Emporiqa has said, at 365 * connect or on Test connection, that this store has them. 366 * 367 * @since 1.2.0 368 * 369 * @param array $options Current options. 370 */ 371 private function render_rules_section( array $options ): void { 372 $status = self::rules_status(); 373 if ( ! $status['available'] ) { 374 return; 375 } 376 $live = in_array( 'order_status', $status['live'], true ); 377 $address = Emporiqa_Actions::base_url(); 378 $platform_base = rtrim( (string) apply_filters( 'emporiqa_connect_base_url', 'https://emporiqa.com' ), '/' ); 379 380 $this->render_section_open( 'emporiqa-section-rules', __( 'Ready-made rules', 'emporiqa' ), true ); 381 ?> 382 <p style="margin:0 0 12px 0;color:#50575e;"> 383 <?php esc_html_e( 'Rules tell the chat what to do when a shopper asks for something specific. The rule below reads your WooCommerce orders through this plugin. Click Open in Emporiqa to test it and switch it on there. Rules that do not need your shop, such as return requests, are added in Emporiqa under Settings > Rules.', 'emporiqa' ); ?> 384 </p> 385 <?php if ( $live && ! empty( $options['order_tracking'] ) ) : ?> 386 <div class="notice notice-info inline" style="margin:0 0 12px 0;"> 387 <p><?php esc_html_e( 'The Order status rule is on, so the old order tracking under Advanced is no longer needed. First remove its address in your Emporiqa dashboard (Settings > Integration > For your developer > Order tracking API URL), then untick Order tracking under Advanced and click Save Changes.', 'emporiqa' ); ?></p> 388 </div> 389 <?php endif; ?> 390 <table class="form-table" role="presentation"> 391 <tr> 392 <th scope="row"><label for="emporiqa_order_status_address"><?php esc_html_e( 'Order status address', 'emporiqa' ); ?></label></th> 393 <td> 394 <?php if ( '' !== $address ) : ?> 395 <div class="emporiqa-readonly-field"> 396 <input type="text" id="emporiqa_order_status_address" value="<?php echo esc_attr( $address ); ?>" class="regular-text" readonly /> 397 <button type="button" class="button emporiqa-copy-btn" data-copy-target="emporiqa_order_status_address"> 398 <?php esc_html_e( 'Copy', 'emporiqa' ); ?> 399 </button> 400 </div> 401 <p class="description"><?php esc_html_e( 'Emporiqa fills this in for you when you add the Order status rule. If it ever asks for the address, use this one.', 'emporiqa' ); ?></p> 402 <?php else : ?> 403 <p class="description"><?php esc_html_e( 'Your site is not served over https, so Emporiqa cannot call it. Switch the site to https (Settings > General), then come back here.', 'emporiqa' ); ?></p> 404 <?php endif; ?> 405 </td> 406 </tr> 407 <tr> 408 <th scope="row"><?php esc_html_e( 'Order status', 'emporiqa' ); ?></th> 409 <td> 410 <?php if ( $live ) : ?> 411 <span class="emporiqa-rule-state emporiqa-rule-on"><?php esc_html_e( 'On', 'emporiqa' ); ?></span> 412 <?php else : ?> 413 <span class="emporiqa-rule-state"><?php esc_html_e( 'Not added', 'emporiqa' ); ?></span> 414 <?php endif; ?> 415 <a href="<?php echo esc_url( $platform_base . '/platform/rules/?add=order_status' ); ?>" class="button" target="_blank" rel="noopener noreferrer" style="margin-left:8px;"> 416 <?php esc_html_e( 'Open in Emporiqa', 'emporiqa' ); ?> 417 <span class="dashicons dashicons-external" style="vertical-align:middle;font-size:14px;line-height:1;margin-left:2px;"></span> 418 </a> 419 <p class="description"> 420 <?php esc_html_e( 'Answers "Where is my order?" from your WooCommerce orders. A shopper signed in to your shop only gives the order number; a guest also gives the email used for the order.', 'emporiqa' ); ?> 421 </p> 422 <p class="description"> 423 <?php esc_html_e( 'On and Not added show what Emporiqa said at your last connect or Test connection (Sync tab). Run Test connection after changing a rule to refresh them.', 'emporiqa' ); ?> 424 </p> 425 </td> 426 </tr> 427 </table> 428 <?php 429 $this->render_section_close(); 430 } 431 432 /** 433 * The old order tracking under Advanced, where ready-made rules are 434 * offered: its address and an on/off checkbox, labelled as replaced. 435 * 436 * @param array $options Current options. 437 */ 438 private function render_legacy_order_tracking_rows( array $options ): void { 439 $tracking_url = rest_url( 'emporiqa/api/order/tracking' ); 440 ?> 441 <tr> 442 <th scope="row"><?php esc_html_e( 'Order tracking (old)', 'emporiqa' ); ?></th> 443 <td> 444 <input type="hidden" name="<?php echo esc_attr( $this->option_name ); ?>[order_tracking_shown]" value="1" /> 445 <label> 446 <input type="checkbox" name="<?php echo esc_attr( $this->option_name ); ?>[order_tracking]" value="1" 447 <?php checked( ! empty( $options['order_tracking'] ) ); ?> /> 448 <?php esc_html_e( 'Answer order lookups at the old order tracking address.', 'emporiqa' ); ?> 449 </label> 450 <div class="emporiqa-readonly-field" style="margin-top:8px;"> 451 <input type="text" id="order_tracking_url" value="<?php echo esc_attr( $tracking_url ); ?>" class="regular-text" readonly /> 452 <button type="button" class="button emporiqa-copy-btn" data-copy-target="order_tracking_url"> 453 <?php esc_html_e( 'Copy', 'emporiqa' ); ?> 454 </button> 455 </div> 456 <p class="description"> 457 <?php esc_html_e( 'Replaced by the Order status rule above. Leave it on while your Emporiqa dashboard still has this address. To switch it off, first remove the address in your Emporiqa dashboard (Settings > Integration > For your developer > Order tracking API URL), then untick this and click Save Changes.', 'emporiqa' ); ?> 458 </p> 459 </td> 460 </tr> 461 <?php 462 } 463 464 /** 465 * What Emporiqa last said about ready-made rules. 466 * 467 * @since 1.2.0 468 * 469 * @return array{available: bool, live: string[]} 470 */ 471 public static function rules_status(): array { 472 $stored = get_option( self::RULES_STATUS_OPTION, array() ); 473 $live = is_array( $stored ) && isset( $stored['live'] ) && is_array( $stored['live'] ) ? $stored['live'] : array(); 474 return array( 475 'available' => is_array( $stored ) && ! empty( $stored['available'] ), 476 'live' => array_values( array_filter( $live, 'is_string' ) ), 477 ); 478 } 479 480 /** 481 * Keep what Emporiqa said about ready-made rules: whether this store is 482 * offered them, and which are live. Both /connect/exchange and the Test 483 * connection dry run carry it; an answer without the key (an older 484 * platform) changes nothing. 485 * 486 * Kept in its own option, not in emporiqa_options, so saving the 487 * settings form never drops it. 488 * 489 * @since 1.2.0 490 * 491 * @param array $answer Decoded Emporiqa answer. 492 */ 493 public static function store_rules_status( array $answer ): void { 494 if ( ! array_key_exists( 'rules_available', $answer ) ) { 495 return; 496 } 497 $live = array(); 498 if ( isset( $answer['live_rules'] ) && is_array( $answer['live_rules'] ) ) { 499 foreach ( $answer['live_rules'] as $key ) { 500 if ( is_string( $key ) && preg_match( '/^[a-z_]{1,40}$/D', $key ) ) { 501 $live[] = $key; 502 } 503 } 504 } 505 $available = (bool) $answer['rules_available']; 506 update_option( 507 self::RULES_STATUS_OPTION, 508 array( 509 'available' => $available, 510 'live' => $available ? $live : array(), 511 ), 512 false 513 ); 342 514 } 343 515 … … 644 816 </td> 645 817 </tr> 818 <?php 819 if ( self::rules_status()['available'] ) { 820 $this->render_legacy_order_tracking_rows( $options ); 821 } 822 ?> 646 823 <tr> 647 824 <th scope="row"><label for="webhook_url"><?php esc_html_e( 'Emporiqa server URL', 'emporiqa' ); ?></label></th> … … 1067 1244 /* translators: %s: entity type */ 1068 1245 'batchFailed' => __( 'Batch failed for %s', 'emporiqa' ), 1246 /* translators: 1: entity type (products/pages), 2: seconds */ 1247 'batchWait' => __( 'Emporiqa is busy and asked to wait. Sending this %1$s batch again in %2$d seconds.', 'emporiqa' ), 1069 1248 /* translators: %s: entity type (products/pages) */ 1070 1249 'sessionComplete' => __( 'Completed %s sync session', 'emporiqa' ), … … 1184 1363 'page_post_types' => array( 'page' ), 1185 1364 'batch_size' => 25, 1365 'order_tracking' => true, 1186 1366 ); 1187 1367 } -
emporiqa/tags/1.2.0/includes/class-emporiqa-cart-ajax.php
r3601468 r3730094 26 26 * Nonce action used for all cart AJAX requests. 27 27 * 28 * Starts with "woocommerce" because WooCommerce binds a guest's nonce to 29 * their cart session only for such actions; any other guest nonce is the 30 * same for every signed-out visitor. 31 * 28 32 * @since 1.0.0 29 33 * @var string 30 34 */ 31 private const NONCE_ACTION = 'emporiqa_cart_nonce'; 35 public const NONCE_ACTION = 'woocommerce-emporiqa-cart'; 36 37 /** 38 * Error code sent when a cart change comes from another site. 39 * 40 * @since 1.2.0 41 * @var string 42 */ 43 public const CROSS_SITE_CODE = 'cross_site'; 44 45 /** 46 * Error code sent when the nonce check fails. 47 * 48 * @since 1.2.0 49 * @var string 50 */ 51 public const INVALID_NONCE_CODE = 'invalid_nonce'; 32 52 33 53 /** … … 65 85 */ 66 86 public function add_to_cart(): void { 87 $this->refuse_cross_site(); 67 88 $this->verify_nonce(); 68 89 $this->ensure_cart_loaded(); … … 174 195 */ 175 196 public function update_cart(): void { 197 $this->refuse_cross_site(); 176 198 $this->verify_nonce(); 177 199 $this->ensure_cart_loaded(); … … 215 237 */ 216 238 public function remove_from_cart(): void { 239 $this->refuse_cross_site(); 217 240 $this->verify_nonce(); 218 241 $this->ensure_cart_loaded(); … … 248 271 */ 249 272 public function clear_cart(): void { 273 $this->refuse_cross_site(); 250 274 $this->verify_nonce(); 251 275 $this->ensure_cart_loaded(); … … 314 338 private function verify_nonce(): void { 315 339 if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) { 316 $this->send_error( __( 'Security check failed. Please refresh the page and try again.', 'emporiqa' ) ); 317 } 340 // The code lets emporiqa-cart.js retry once with the fresh nonce: 341 // a page served from a full-page cache can carry an expired one. 342 $this->send_error( __( 'Security check failed. Please refresh the page and try again.', 'emporiqa' ), self::INVALID_NONCE_CODE ); 343 } 344 } 345 346 /** 347 * Refuse a cart change that another site's page sent. 348 * 349 * @since 1.2.0 350 */ 351 private function refuse_cross_site(): void { 352 if ( self::is_cross_site( $_SERVER, array( home_url(), site_url() ) ) ) { 353 $this->send_error( __( 'Security check failed. Please refresh the page and try again.', 'emporiqa' ), self::CROSS_SITE_CODE ); 354 } 355 } 356 357 /** 358 * Whether a request says it came from another site: Sec-Fetch-Site (when 359 * the browser sends it) is neither same-origin nor none, or Origin (when 360 * sent) is not one of the site's own hosts. 361 * 362 * @since 1.2.0 363 * 364 * @param array $server $_SERVER. 365 * @param string[] $own_urls The site's own URLs (home_url(), site_url()). 366 * @return bool 367 */ 368 public static function is_cross_site( array $server, array $own_urls ): bool { 369 $fetch_site = isset( $server['HTTP_SEC_FETCH_SITE'] ) ? strtolower( (string) $server['HTTP_SEC_FETCH_SITE'] ) : ''; 370 if ( '' !== $fetch_site && 'same-origin' !== $fetch_site && 'none' !== $fetch_site ) { 371 return true; 372 } 373 $origin = isset( $server['HTTP_ORIGIN'] ) ? (string) $server['HTTP_ORIGIN'] : ''; 374 if ( '' === $origin ) { 375 return false; 376 } 377 $host = Emporiqa_Widget::host_of( $origin ); 378 return '' === $host || ! in_array( $host, array_map( array( 'Emporiqa_Widget', 'host_of' ), $own_urls ), true ); 318 379 } 319 380 … … 352 413 'name' => $product->get_name(), 353 414 'quantity' => self::parse_quantity( $item['quantity'] ), 354 'unit_price' => (float) $product->get_price(),415 'unit_price' => $this->displayed_unit_price( $product ), 355 416 'image_url' => $image_url ? $image_url : null, 356 417 'product_url' => get_permalink( $item['product_id'] ), … … 364 425 'currency' => emporiqa_get_active_currency(), 365 426 ); 427 } 428 429 /** 430 * A cart line's unit price as the cart page shows it. 431 * 432 * Same rule as WC_Cart::get_product_price(): including or excluding tax 433 * per the shop's "Display prices during cart and checkout" setting. Read 434 * as the shopper whose cart it is, at their tax address, because this is 435 * the price they will pay, unlike the synced catalogue price, which is a 436 * guest's. 437 * 438 * @since 1.2.0 439 * 440 * @param \WC_Product $product The cart line's product. 441 * @return float 442 */ 443 private function displayed_unit_price( \WC_Product $product ): float { 444 $price = WC()->cart->display_prices_including_tax() 445 ? wc_get_price_including_tax( $product ) 446 : wc_get_price_excluding_tax( $product ); 447 return round( (float) $price, wc_get_price_decimals() ); 366 448 } 367 449 … … 400 482 * 401 483 * @param string $message The error message. 402 */ 403 private function send_error( string $message ): void { 484 * @param string $code Machine-readable reason, '' when there is none. 485 */ 486 private function send_error( string $message, string $code = '' ): void { 404 487 wp_send_json( 405 488 array( 406 489 'success' => false, 407 490 'error' => $message, 491 'code' => $code, 408 492 'checkoutUrl' => $this->get_checkout_url(), 409 493 'unit_price' => 0, -
emporiqa/tags/1.2.0/includes/class-emporiqa-connect.php
r3601468 r3730094 66 66 67 67 /** 68 * How long a claimed state stays "exchanging". Emporiqa proves the shop 69 * origin during /connect/exchange by asking actions/verify for 70 * HMAC(verifier, nonce), so the verifier must outlive the claim until the 71 * exchange returns; the TTL only bounds a request that never returned. 72 */ 73 private const EXCHANGING_TTL = 120; 74 75 /** 76 * /connect/exchange waits on the origin proof (at most 4 s on Emporiqa's 77 * side) and on the secret rotation; past this timeout the plugin would 78 * keep its old secret while Emporiqa already rotated to the new one. 79 */ 80 private const EXCHANGE_TIMEOUT = 15; 81 82 /** 68 83 * Constructor — wires the admin_init handler that routes 69 84 * ?emporiqa_action= requests. … … 214 229 $this->base_url() . '/connect/exchange', 215 230 array( 216 'timeout' => 10,231 'timeout' => self::EXCHANGE_TIMEOUT, 217 232 'headers' => array( 218 233 'Content-Type' => 'application/json', … … 222 237 'body' => wp_json_encode( 223 238 array( 224 'code' => $code, 225 'code_verifier' => $verifier, 226 'shop_origin' => $shop_origin, 239 'code' => $code, 240 'code_verifier' => $verifier, 241 'shop_origin' => $shop_origin, 242 'actions_base_url' => Emporiqa_Actions::base_url(), 227 243 ) 228 244 ), 229 245 ) 230 246 ); 247 // The exchange has returned: the origin proof can no longer be asked for. 248 $this->finish_nonce( $state ); 231 249 232 250 if ( is_wp_error( $resp ) ) { … … 244 262 $status = (int) wp_remote_retrieve_response_code( $resp ); 245 263 $body = json_decode( wp_remote_retrieve_body( $resp ), true ); 264 265 // A 409 carries a sentence for the merchant (a move or a reconnect 266 // waiting for an owner's email confirmation); show it as it is. 267 if ( 409 === $status && is_array( $body ) && ! empty( $body['error'] ) && is_string( $body['error'] ) ) { 268 $this->set_error( 'exchange_409', sanitize_text_field( $body['error'] ) ); 269 $this->redirect_to_settings(); 270 } 246 271 247 272 if ( 200 !== $status || ! is_array( $body ) || empty( $body['webhook_secret'] ) || empty( $body['store_id'] ) ) { … … 257 282 $this->redirect_to_settings(); 258 283 } 284 285 // Whether Emporiqa offers ready-made rules to this store, and which are live. 286 Emporiqa_Admin::store_rules_status( $body ); 259 287 260 288 $this->persist_credentials( … … 414 442 415 443 /** 416 * Atomically read + delete the state→verifier transient. Returns the 417 * verifier on hit, null on miss/expired/already-used. 444 * Claim a pending state for the exchange and return its verifier; null on 445 * miss, expiry or a state already claimed (a double callback cannot 446 * replay). The row is kept, marked exchanging, until finish_nonce(), so 447 * the origin proof can be answered while the exchange is in flight. 418 448 * 419 449 * @param string $state Public state from the callback. … … 422 452 $key = self::NONCE_PREFIX . hash( 'sha256', $state ); 423 453 $data = get_transient( $key ); 424 if ( ! is_array( $data ) || empty( $data['verifier'] ) ) {454 if ( ! is_array( $data ) || empty( $data['verifier'] ) || ! empty( $data['exchanging_at'] ) ) { 425 455 return null; 426 456 } 427 // Delete first so a double-callback can't replay. 428 delete_transient( $key ); 457 $data['exchanging_at'] = time(); 458 if ( ! set_transient( $key, $data, self::EXCHANGING_TTL ) ) { 459 return null; 460 } 429 461 return (string) $data['verifier']; 462 } 463 464 /** 465 * The verifier of an exchange in flight for $state, else null. Pending 466 * states never answer: only an exchange this site itself started can. 467 * 468 * @since 1.2.0 469 * 470 * @param string $state Public state sent by Emporiqa's origin proof. 471 * @return string|null 472 */ 473 public static function exchanging_verifier( string $state ): ?string { 474 $data = get_transient( self::NONCE_PREFIX . hash( 'sha256', $state ) ); 475 if ( ! is_array( $data ) || empty( $data['verifier'] ) || empty( $data['exchanging_at'] ) ) { 476 return null; 477 } 478 if ( time() - (int) $data['exchanging_at'] > self::EXCHANGING_TTL ) { 479 return null; 480 } 481 return (string) $data['verifier']; 482 } 483 484 /** 485 * Drop the state once the exchange has returned. 486 * 487 * @param string $state Public state from the callback. 488 */ 489 private function finish_nonce( string $state ): void { 490 delete_transient( self::NONCE_PREFIX . hash( 'sha256', $state ) ); 430 491 } 431 492 -
emporiqa/tags/1.2.0/includes/class-emporiqa-formatter.php
r3601468 r3730094 39 39 */ 40 40 private array $excluded_post_ids = array(); 41 42 /** 43 * How many as_shopper() calls are running; above 0, prices are already read as a shopper. 44 * 45 * @var int 46 */ 47 private static int $shopper_depth = 0; 41 48 42 49 /** … … 416 423 $brand_data[ $channel ] = $this->get_product_brand( $product, $languages[0] ); 417 424 418 if ( $is_variable && ! empty( $published_variations ) ) { 419 // Representative price for the parent. Emporiqa derives the 420 // real "from–to" range from the variation events at read time, 421 // so this single value only needs the right currency and a 422 // sensible "from" price. Pick the cheapest variation and take 423 // BOTH its current and regular price from that same variation — 424 // mixing the min current price with another variation's regular 425 // price would fabricate a discount the customer can't actually 426 // get. 427 $representative = $published_variations[0]; 428 $min_price = null; 429 foreach ( $published_variations as $v ) { 430 $vp = $v->get_price(); 431 if ( '' === $vp ) { 432 continue; 433 } 434 $vp = (float) $vp; 435 if ( null === $min_price || $vp < $min_price ) { 436 $min_price = $vp; 437 $representative = $v; 438 } 439 } 440 $price = $min_price ?? 0.0; 441 $regular_price = '' !== $representative->get_regular_price() ? (float) $representative->get_regular_price() : $price; 442 } elseif ( $is_grouped ) { 443 // C2: Grouped products — the cheapest child's price. Derived 444 // from ALL children, not only in-stock ones, so an 445 // all-out-of-stock grouped product still reports its real 446 // lowest price instead of 0. current_price and regular_price 447 // are both taken from the SAME (cheapest) child so we never 448 // pair a low current price with a different child's higher 449 // regular price and fabricate a discount. Unlike variable 450 // products, grouped children sync as separate products, so 451 // Emporiqa does not range-derive the parent — this value is 452 // shown directly. 453 $price = null; 454 $regular_price = null; 455 foreach ( $grouped_children as $child ) { 456 $cp = $child->get_price(); 457 $child_price = '' !== $cp ? (float) $cp : null; 458 if ( null === $child_price ) { 459 continue; 460 } 461 if ( null === $price || $child_price < $price ) { 462 $price = $child_price; 463 $cr = $child->get_regular_price(); 464 $regular_price = '' !== $cr ? (float) $cr : $child_price; 465 } 466 } 467 $price = $price ?? 0.0; 468 $regular_price = $regular_price ?? $price; 469 } else { 470 $price = '' !== $product->get_price() ? (float) $product->get_price() : 0.0; 471 $regular_price = '' !== $product->get_regular_price() ? (float) $product->get_regular_price() : $price; 472 } 473 474 $price_entry = array( 475 'currency' => $currency, 476 'current_price' => $price, 477 'regular_price' => $regular_price, 425 // Prices are read as a guest at the shop's base location; see as_shopper(). 426 $price_entry = $this->as_shopper( 427 0, 428 fn() => $this->headline_entry( $product, $published_variations, $grouped_children, $currency ) 478 429 ); 479 430 … … 673 624 $var_images = array_values( array_unique( $var_images ) ); 674 625 675 $price = '' !== $variation->get_price() ? (float) $variation->get_price() : 0.0; 676 $regular_price = '' !== $variation->get_regular_price() ? (float) $variation->get_regular_price() : $price; 626 $price_entry = $this->price_entry( $variation, $currency ); 677 627 678 628 $names = array(); 679 $descriptions = array();680 629 $links = array(); 681 630 $attributes = array(); 682 $cat_data = array();683 $brand_data = array();684 631 $price_data = array(); 685 632 $avail_data = array(); … … 689 636 $max_qty_data = array(); 690 637 638 // Descriptions, categories and brand are not built here: Emporiqa stores a 639 // variation as a lean row and reads them from the parent. 691 640 foreach ( $channels as $channel ) { 692 $names[ $channel ] = array(); 693 $descriptions[ $channel ] = array(); 694 $links[ $channel ] = array(); 695 $attributes[ $channel ] = array(); 696 $cat_data[ $channel ] = array(); 641 $names[ $channel ] = array(); 642 $links[ $channel ] = array(); 643 $attributes[ $channel ] = array(); 697 644 698 645 foreach ( $languages as $lang ) { … … 705 652 $names[ $channel ][ $lang ] = $title; 706 653 707 $descriptions[ $channel ][ $lang ] = $this->get_translated_variation_description( $variation, $parent_product, $lang );708 709 654 $links[ $channel ][ $lang ] = $this->get_translated_link( $parent_product, $lang ); 710 655 711 656 $attributes[ $channel ][ $lang ] = empty( $clean_attributes ) ? new \stdClass() : $clean_attributes; 712 713 $cat_data[ $channel ][ $lang ] = $this->get_product_categories( $parent_product, $lang ); 714 } 715 716 $this->apply_translation_fallbacks( $cat_data[ $channel ], $languages ); 657 } 658 717 659 $this->apply_translation_fallbacks( $names[ $channel ], $languages ); 718 $this->apply_translation_fallbacks( $descriptions[ $channel ], $languages );719 660 $this->apply_translation_fallbacks( $links[ $channel ], $languages ); 720 661 721 $brand_data[ $channel ] = $this->get_product_brand( $parent_product, $languages[0] ); 722 723 $price_data[ $channel ] = array( 724 array( 725 'currency' => $currency, 726 'current_price' => $price, 727 'regular_price' => $regular_price, 728 ), 729 ); 662 $price_data[ $channel ] = array( $price_entry ); 730 663 731 664 $avail_data[ $channel ] = $this->get_availability_status( $variation ); … … 744 677 'channels' => $channels, 745 678 'names' => $names, 746 'descriptions' => $descriptions,747 679 'links' => $links, 748 680 'attributes' => $attributes, 749 'categories' => $cat_data,750 'brands' => $brand_data,751 681 'prices' => $price_data, 752 682 'availability_statuses' => $avail_data, … … 759 689 'is_virtual' => $variation->is_virtual(), 760 690 'parent_sku' => $parent_sku, 761 'is_parent' => false,762 'variation_attributes' => new \stdClass(),763 691 ); 764 692 … … 791 719 * @return array List of channel identifiers. 792 720 */ 793 p rivatefunction get_product_channels( \WC_Product $product ): array {721 public function get_product_channels( \WC_Product $product ): array { 794 722 /** 795 723 * Filter the product sales channels. … … 978 906 979 907 return implode( ' > ', $names ); 908 } 909 910 /** 911 * The `prices` entry for a product as a whole: its own price, or for a 912 * variable product its cheapest priced variation's and for a grouped 913 * product its cheapest priced child's (all children, so an all-out-of-stock 914 * group still shows its real lowest price). Current and regular price both 915 * come from that one item, so a low price is never paired with another 916 * item's higher regular price, which would fabricate a discount. Quantity 917 * tiers are kept only for the product's own price: a variation's or a 918 * child's tiers are not the parent's. Emporiqa shows a variable product's 919 * full range from its variations. 920 * 921 * @since 1.2.0 922 * 923 * @param \WC_Product $product The product. 924 * @param \WC_Product[] $variations Published variations (variable products). 925 * @param \WC_Product[] $children Children (grouped products). 926 * @param string $currency Currency code. 927 * @return array 928 */ 929 public function headline_entry( \WC_Product $product, array $variations, array $children, string $currency ): array { 930 if ( $product->is_type( 'variable' ) && ! empty( $variations ) ) { 931 $pool = $variations; 932 } elseif ( $product->is_type( 'grouped' ) ) { 933 $pool = $children; 934 } else { 935 return $this->price_entry( $product, $currency ); 936 } 937 938 $cheapest = null; 939 $lowest = null; 940 foreach ( $pool as $item ) { 941 $price = $item->get_price(); 942 if ( '' !== $price && ( null === $lowest || (float) $price < $lowest ) ) { 943 $lowest = (float) $price; 944 $cheapest = $item; 945 } 946 } 947 $entry = $this->price_entry( $cheapest, $currency ); 948 unset( $entry['tier_prices'] ); 949 return $entry; 950 } 951 952 /** 953 * Run price reads as one shopper: a signed-out visitor ($user_id 0) or a 954 * signed-in customer. 955 * 956 * A sync runs as whoever triggered it: the shop manager saving a product, 957 * a signed-in user whose request ran the queue, the WP-CLI user. Role, 958 * B2B, wholesale and dynamic pricing plugins filter the price getters by 959 * the current user, session or cart, and WooCommerce works out tax from 960 * the current customer's address, so without this a wholesale or staff 961 * price, or another country's tax, could be synced as the public price. 962 * 963 * For the duration of $read the current user is $user_id, WC()->customer 964 * is that customer (none for a guest, a copy read from the database, never 965 * a session, for a customer), WC()->session and WC()->cart are unset, and 966 * the tax location is fixed: $country when given, else the customer's own 967 * taxable address, else the shop's base address. Everything is put back 968 * afterwards, also when $read throws. Nested calls run $read as-is. 969 * 970 * @since 1.2.0 971 * 972 * @param int $user_id 0 for a guest, else a WordPress user id. 973 * @param callable $read Reads and returns the prices. 974 * @param string $country ISO country for tax, '' for the default. 975 * @return mixed What $read returns. 976 */ 977 public function as_shopper( int $user_id, callable $read, string $country = '' ) { 978 if ( self::$shopper_depth > 0 ) { 979 return $read(); 980 } 981 982 global $current_user; 983 $previous_user = $current_user; 984 $previous_user_id = get_current_user_id(); 985 $swap_user = $previous_user_id !== $user_id; 986 $wc = function_exists( 'WC' ) ? WC() : null; 987 $previous = $wc ? array( $wc->customer, $wc->session, $wc->cart ) : array(); 988 $location = null; 989 990 ++self::$shopper_depth; 991 try { 992 if ( $swap_user ) { 993 wp_set_current_user( $user_id ); 994 } 995 if ( $wc ) { 996 $wc->session = null; 997 $wc->cart = null; 998 $wc->customer = $user_id > 0 ? new \WC_Customer( $user_id ) : null; 999 1000 $fixed = $this->tax_location( $wc, $country ); 1001 if ( null !== $fixed ) { 1002 $location = static function () use ( $fixed ) { 1003 return $fixed; 1004 }; 1005 add_filter( 'woocommerce_get_tax_location', $location, PHP_INT_MAX ); 1006 } 1007 } 1008 return $read(); 1009 } finally { 1010 if ( null !== $location ) { 1011 remove_filter( 'woocommerce_get_tax_location', $location, PHP_INT_MAX ); 1012 } 1013 if ( $wc ) { 1014 list( $wc->customer, $wc->session, $wc->cart ) = $previous; 1015 } 1016 if ( $swap_user ) { 1017 wp_set_current_user( $previous_user_id ); 1018 $current_user = $previous_user; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Puts back the exact user object the caller had. 1019 } 1020 --self::$shopper_depth; 1021 } 1022 } 1023 1024 /** 1025 * The tax location as_shopper() fixes, null when WooCommerce knows no countries. 1026 * 1027 * @param object $wc WC(). 1028 * @param string $country ISO country, or ''. 1029 * @return array|null array( country, state, postcode, city ). 1030 */ 1031 private function tax_location( $wc, string $country ): ?array { 1032 if ( '' !== $country ) { 1033 return array( $country, '', '', '' ); 1034 } 1035 if ( $wc->customer ) { 1036 $own = $wc->customer->get_taxable_address(); 1037 if ( is_array( $own ) && ! empty( $own[0] ) ) { 1038 return $own; 1039 } 1040 } 1041 $countries = $wc->countries; 1042 if ( ! $countries ) { 1043 return null; 1044 } 1045 return array( $countries->get_base_country(), $countries->get_base_state(), $countries->get_base_postcode(), $countries->get_base_city() ); 1046 } 1047 1048 /** 1049 * One `prices` entry for a product or variation, as the shop shows it. 1050 * 1051 * Its current_price and regular_price follow the shop's "Display prices in 1052 * the shop" tax setting, so the chat quotes what the product page shows 1053 * even when prices are entered the other way (entered excluding tax, 1054 * shown including it, as many EU shops do). With taxes on and a taxable 1055 * product, price_incl_tax and price_excl_tax carry both, for the tax 1056 * location as_shopper() fixed. A missing price is 0, as before. Outside 1057 * as_shopper() it is read as a guest. 1058 * 1059 * Quantity tiers come from the `emporiqa_price_tiers` filter (WooCommerce 1060 * has none of its own): each tier is the unit price the cart charges at 1061 * that quantity, with tax as current_price, at full precision. 1062 * 1063 * @since 1.2.0 1064 * 1065 * @param \WC_Product|null $source The product or variation whose price is sent; null when none has one. 1066 * @param string $currency Currency code. 1067 * @return array 1068 */ 1069 public function price_entry( ?\WC_Product $source, string $currency ): array { 1070 if ( 0 === self::$shopper_depth ) { 1071 return $this->as_shopper( 0, fn() => $this->price_entry( $source, $currency ) ); 1072 } 1073 1074 $raw = $source ? (string) $source->get_price() : ''; 1075 $regular_raw = $source ? (string) $source->get_regular_price() : ''; 1076 if ( '' === $regular_raw ) { 1077 $regular_raw = $raw; 1078 } 1079 1080 $entry = array( 1081 'currency' => $currency, 1082 'current_price' => '' !== $raw ? (float) $raw : 0.0, 1083 'regular_price' => '' !== $regular_raw ? (float) $regular_raw : 0.0, 1084 ); 1085 if ( '' === $raw ) { 1086 return $entry; 1087 } 1088 1089 $taxed = function_exists( 'wc_tax_enabled' ) && wc_tax_enabled() && $source->is_taxable(); 1090 $display_incl = $taxed && 'incl' === get_option( 'woocommerce_tax_display_shop', 'excl' ); 1091 $shown = static function ( string $amount, int $qty ) use ( $source, $taxed, $display_incl ): float { 1092 if ( ! $taxed ) { 1093 return (float) $amount; 1094 } 1095 $args = array( 1096 'qty' => $qty, 1097 'price' => $amount, 1098 ); 1099 $line = $display_incl ? wc_get_price_including_tax( $source, $args ) : wc_get_price_excluding_tax( $source, $args ); 1100 return (float) $line / $qty; 1101 }; 1102 1103 if ( $taxed ) { 1104 $decimals = function_exists( 'wc_get_price_decimals' ) ? (int) wc_get_price_decimals() : 2; 1105 $one = array( 1106 'qty' => 1, 1107 'price' => $raw, 1108 ); 1109 1110 $entry['price_incl_tax'] = round( (float) wc_get_price_including_tax( $source, $one ), $decimals ); 1111 $entry['price_excl_tax'] = round( (float) wc_get_price_excluding_tax( $source, $one ), $decimals ); 1112 $entry['current_price'] = $display_incl ? $entry['price_incl_tax'] : $entry['price_excl_tax']; 1113 $entry['regular_price'] = round( $shown( $regular_raw, 1 ), $decimals ); 1114 } 1115 1116 $tiers = $this->price_tiers( $source, $shown ); 1117 if ( ! empty( $tiers ) ) { 1118 $entry['tier_prices'] = $tiers; 1119 } 1120 return $entry; 1121 } 1122 1123 /** 1124 * Quantity tiers from the `emporiqa_price_tiers` filter, as the cart 1125 * charges them: ascending, one per quantity, min_quantity 2 or more. 1126 * 1127 * @param \WC_Product $source The product or variation. 1128 * @param callable $shown ( string $unit_price, int $qty ): float, the unit price with tax as shown. 1129 * @return array<int, array{min_quantity: int, price: float}> 1130 */ 1131 private function price_tiers( \WC_Product $source, callable $shown ): array { 1132 /** 1133 * Filter the quantity price tiers of a product or variation. 1134 * 1135 * WooCommerce has no quantity pricing of its own; a plugin that adds it 1136 * returns its tiers here. Each tier is array( 'min_quantity' => int, 1137 * 'price' => unit price ), the price entered as the product's own price 1138 * is (with or without tax, per the shop's setting). Called while the 1139 * price is read as the shopper (a guest for the sync), so a role-aware 1140 * tier plugin answers for that shopper. 1141 * 1142 * @since 1.2.0 1143 * 1144 * @param array $tiers Tiers, empty by default. 1145 * @param \WC_Product $product The product or variation. 1146 */ 1147 $raw = apply_filters( 'emporiqa_price_tiers', array(), $source ); 1148 1149 $tiers = array(); 1150 foreach ( is_array( $raw ) ? $raw : array() as $tier ) { 1151 if ( ! is_array( $tier ) || ! isset( $tier['min_quantity'], $tier['price'] ) || ! is_numeric( $tier['min_quantity'] ) || ! is_numeric( $tier['price'] ) ) { 1152 continue; 1153 } 1154 $qty = (int) $tier['min_quantity']; 1155 $price = (float) $tier['price']; 1156 if ( $qty < 2 || $price <= 0 ) { 1157 continue; 1158 } 1159 $tiers[ $qty ] = array( 1160 'min_quantity' => $qty, 1161 'price' => $shown( (string) $price, $qty ), 1162 ); 1163 } 1164 ksort( $tiers ); 1165 return array_values( $tiers ); 1166 } 1167 1168 /** 1169 * The `emporiqa_price_tiers` answer for the Tiered Price Table plugin 1170 * (tier-pricing-table): each of its quantity rules as the unit price its 1171 * cart charges, unrounded and with tax as the shop enters prices, for the 1172 * shopper being read. The 1173 * pricing type is passed so its per-request rule cache, which keys by 1174 * product only, is not read: a rule cached for another shopper earlier in 1175 * the request (its role-based rules) must not be sent as this one's. 1176 * 1177 * @since 1.2.0 1178 * 1179 * @param array $tiers Tiers so far. 1180 * @param \WC_Product $product The product or variation. 1181 * @return array 1182 */ 1183 public static function tiered_price_table_tiers( $tiers, $product ): array { 1184 $tiers = is_array( $tiers ) ? $tiers : array(); 1185 $manager = '\\TierPricingTable\\PriceManager'; 1186 if ( ! $product instanceof \WC_Product || ! is_callable( array( $manager, 'getPricingRule' ) ) || ! is_callable( array( $manager, 'getPricingType' ) ) ) { 1187 return $tiers; 1188 } 1189 $id = $product->get_id(); 1190 $rule = call_user_func( array( $manager, 'getPricingRule' ), $id, call_user_func( array( $manager, 'getPricingType' ), $id ) ); 1191 if ( ! is_object( $rule ) || ! is_callable( array( $rule, 'getRules' ) ) || ! is_callable( array( $rule, 'getTierPrice' ) ) ) { 1192 return $tiers; 1193 } 1194 foreach ( array_keys( (array) $rule->getRules() ) as $qty ) { 1195 $price = $rule->getTierPrice( (int) $qty, false, 'shop', false ); 1196 if ( is_numeric( $price ) ) { 1197 $tiers[] = array( 1198 'min_quantity' => (int) $qty, 1199 'price' => (float) $price, 1200 ); 1201 } 1202 } 1203 return $tiers; 980 1204 } 981 1205 … … 1244 1468 1245 1469 /** 1246 * Get the translated variation description, falling back to parent description.1247 *1248 * @since 1.0.01249 *1250 * @param \WC_Product_Variation $variation The variation.1251 * @param \WC_Product $parent_product The parent product.1252 * @param string $language The target language code.1253 * @return string1254 */1255 private function get_translated_variation_description( \WC_Product_Variation $variation, \WC_Product $parent_product, string $language ): string {1256 $translated_parent_id = $this->get_translation_id( $parent_product->get_id(), $language );1257 1258 if ( $translated_parent_id && $translated_parent_id !== $parent_product->get_id() ) {1259 $translated_parent = wc_get_product( $translated_parent_id );1260 if ( $translated_parent && $translated_parent->is_type( 'variable' ) ) {1261 // Find the matching translated variation by SKU or attribute combination.1262 foreach ( $translated_parent->get_children() as $translated_var_id ) {1263 $translated_var = wc_get_product( $translated_var_id );1264 if ( ! $translated_var ) {1265 continue;1266 }1267 if ( $translated_var->get_sku() === $variation->get_sku() && '' !== $variation->get_sku() ) {1268 $desc = $translated_var->get_description();1269 if ( ! empty( $desc ) ) {1270 return $desc;1271 }1272 break;1273 }1274 }1275 // Fall back to translated parent description.1276 return $translated_parent->get_description();1277 }1278 }1279 1280 $var_desc = $variation->get_description();1281 return ! empty( $var_desc ) ? $var_desc : $parent_product->get_description();1282 }1283 1284 /**1285 1470 * Get the translated product title. 1286 1471 * -
emporiqa/tags/1.2.0/includes/class-emporiqa-order-tracking.php
r3506790 r3730094 6 6 * order data. Requests are authenticated via HMAC-SHA256 using the same 7 7 * webhook secret configured in Settings > Emporiqa. 8 * 9 * Replaced by the Order status ready-made rule (class-emporiqa-actions.php) 10 * since 1.2.0, and kept working unchanged for stores that use it: same 11 * request, same answers. It stays on unless the merchant switches it off 12 * (option `order_tracking`, default on). 8 13 * 9 14 * @package Emporiqa … … 65 70 66 71 /** 72 * Whether the merchant left order tracking on (the default). 73 * 74 * @since 1.2.0 75 * 76 * @return bool 77 */ 78 public static function is_enabled(): bool { 79 return (bool) Emporiqa_Admin::get_option( 'order_tracking', true ); 80 } 81 82 /** 67 83 * Verify HMAC signature and timestamp for the incoming request. 68 84 * … … 143 159 */ 144 160 public function handle_request( WP_REST_Request $request ): WP_REST_Response { 161 if ( ! self::is_enabled() ) { 162 // Untranslated on purpose: Emporiqa reads this exact text as 163 // "switched off", not as an unknown order. 164 return new WP_REST_Response( array( 'error' => 'Order tracking is disabled.' ), 404 ); 165 } 166 167 try { 168 return $this->answer( $request ); 169 } catch ( \Throwable $e ) { 170 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { 171 error_log( '[Emporiqa] order tracking failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log 172 } 173 return new WP_REST_Response( array( 'error' => 'Internal error.' ), 500 ); 174 } 175 } 176 177 /** 178 * The answer to a signature-verified request. 179 * 180 * @param WP_REST_Request $request The REST request. 181 * @return WP_REST_Response 182 */ 183 private function answer( WP_REST_Request $request ): WP_REST_Response { 145 184 // H8: Rate limiting per IP. 146 185 $rate_check = $this->check_rate_limit(); … … 161 200 } 162 201 163 $order_identifier = sanitize_text_field( $payload['order_identifier'] ); 202 $order_identifier = is_scalar( $payload['order_identifier'] ) ? ltrim( sanitize_text_field( (string) $payload['order_identifier'] ), '#' ) : ''; 203 if ( ! preg_match( '/^[0-9A-Za-z][0-9A-Za-z\-]{0,63}$/D', $order_identifier ) ) { 204 return new WP_REST_Response( 205 array( 'error' => __( 'Order not found.', 'emporiqa' ) ), 206 404 207 ); 208 } 164 209 $raw_verification = $payload['verification_fields'] ?? array(); 165 210 $verification_fields = is_array( $raw_verification ) ? array_map( 'sanitize_text_field', $raw_verification ) : array(); … … 234 279 */ 235 280 private function lookup_order( string $order_identifier, array $verification_fields ): WP_REST_Response { 236 $order = wc_get_order( $order_identifier ); 281 // The typed value is an order number, never an internal id: "123" 282 // must not resolve to whichever order has id 123. 283 $order = Emporiqa_Order_Status::find_by_number( $order_identifier ); 237 284 238 285 // Also try searching by order number (custom order number plugins). … … 245 292 ); 246 293 $order = $orders[0] ?? null; 247 } 248 249 if ( ! $order ) { 294 // WooCommerce ignores a query var it does not know, and then 295 // returns the newest order: only an exact number match counts. 296 if ( $order && 0 !== strcasecmp( (string) $order->get_order_number(), $order_identifier ) ) { 297 $order = null; 298 } 299 } 300 301 if ( ! $order instanceof WC_Order ) { 250 302 return new WP_REST_Response( 251 303 array( 'error' => __( 'Order not found.', 'emporiqa' ) ), -
emporiqa/tags/1.2.0/includes/class-emporiqa-order-webhook.php
r3549190 r3730094 256 256 $url, 257 257 array( 258 'headers' => array( 259 'Content-Type' => 'application/json', 260 'X-Webhook-Signature' => hash_hmac( 'sha256', $payload, $secret ), 261 ), 258 'headers' => Emporiqa_Webhook::signed_headers( $payload ), 262 259 'body' => $payload, 263 260 'timeout' => 5, … … 340 337 $this->log( "order.completed webhook failed for order #{$order_id}. Retry #{$attempt} in {$delay}s." ); 341 338 } else { 339 // Not marked as sent, so the order's next paid status 340 // (processing to completed, say) tries once more. 341 $order->delete_meta_data( self::TRACKED_META_KEY ); 342 $order->save(); 342 343 $this->log( "order.completed webhook permanently failed for order #{$order_id} after {$attempt} attempt(s)." ); 343 344 } -
emporiqa/tags/1.2.0/includes/class-emporiqa-sync.php
r3601468 r3730094 150 150 * AJAX handler: process a single batch of items. 151 151 * 152 * Queries N items for the given page, formats them, and sends them 153 * via the webhook. 152 * Queries the next N items after `after_id` (keyset, by id), formats 153 * them, and sends them via the webhook. The answer's `last_id` is the 154 * next call's `after_id`, and `done` says there is nothing after it. 155 * Paging by id instead of OFFSET means an item disabled or added 156 * mid-sync cannot shift the pages and make a live item skipped, which 157 * the session's completion would then delete from Emporiqa. 154 158 * 155 159 * @since 1.0.0 … … 164 168 $entity = sanitize_text_field( wp_unslash( $_POST['entity'] ?? '' ) ); 165 169 $session_id = sanitize_text_field( wp_unslash( $_POST['session_id'] ?? '' ) ); 166 $ page = absint( $_POST['page'] ?? 1);170 $after_id = absint( $_POST['after_id'] ?? 0 ); 167 171 $items_per_batch = absint( $_POST['items_per_batch'] ?? self::ITEMS_PER_BATCH ); 168 172 … … 175 179 'events' => 0, 176 180 'success' => true, 181 'last_id' => $after_id, 182 'done' => true, 177 183 ); 178 184 179 185 if ( 'products' === $entity ) { 180 $result = $this->process_product_batch( $session_id, $ page, $items_per_batch );186 $result = $this->process_product_batch( $session_id, $after_id, $items_per_batch ); 181 187 } elseif ( 'pages' === $entity ) { 182 $result = $this->process_page_batch( $session_id, $ page, $items_per_batch );188 $result = $this->process_page_batch( $session_id, $after_id, $items_per_batch ); 183 189 } 184 190 185 191 // A failed batch reclassifies all of its items as errors — the 186 // per-session counters drive the completion guard below. 187 $this->record_batch_result( $session_id, $result['processed'], $result['success'] ); 192 // per-session counters drive the completion guard below. A batch 193 // Emporiqa asked to wait for is held as pending instead: the browser 194 // sends the same batch again after the wait. 195 $this->record_batch_result( $session_id, $result['processed'], $result['success'], $after_id, isset( $result['retry_after'] ) ); 188 196 189 197 wp_send_json_success( $result ); … … 235 243 } 236 244 245 if ( ! empty( $state['pending'] ) ) { 246 wp_send_json_error( 247 array( 248 'message' => sprintf( 249 /* translators: %s: entity type (products or pages). */ 250 __( 'The %s sync session was not finalized because Emporiqa asked to wait and a batch was not sent again: items missing from this sync were NOT removed from Emporiqa. Re-run the sync.', 'emporiqa' ), 251 $entity 252 ), 253 'skipped' => true, 254 ) 255 ); 256 } 257 237 258 if ( $state['synced'] < 1 ) { 238 259 wp_send_json_error( … … 283 304 * Mirrors the Drupal reference: a failed batch send reclassifies the 284 305 * whole batch's items as errors (no partial accounting inside a batch). 306 * A batch Emporiqa asked to wait for (429) is held as pending under the 307 * id it starts after, and released when that batch is sent; completion 308 * is refused while any is pending. 285 309 * 286 310 * @since 1.1.9 … … 289 313 * @param int $processed Items processed in this batch. 290 314 * @param bool $success Whether the batch send succeeded. 291 */ 292 private function record_batch_result( string $session_id, int $processed, bool $success ): void { 315 * @param int $after_id Id the batch starts after. 316 * @param bool $wait Whether Emporiqa asked to wait and resend. 317 */ 318 private function record_batch_result( string $session_id, int $processed, bool $success, int $after_id = 0, bool $wait = false ): void { 293 319 $state = $this->get_session_state( $session_id ); 294 320 if ( ! is_array( $state ) ) { … … 296 322 } 297 323 324 $pending = isset( $state['pending'] ) && is_array( $state['pending'] ) ? $state['pending'] : array(); 325 unset( $pending[ (string) $after_id ] ); 326 if ( $wait ) { 327 $pending[ (string) $after_id ] = true; 328 } 329 $state['pending'] = $pending; 330 298 331 if ( $success ) { 299 332 $state['synced'] += $processed; 300 } else {333 } elseif ( ! $wait ) { 301 334 $state['errors'] += max( 1, $processed ); 302 335 } … … 422 455 * 423 456 * @param string $session_id Sync session ID. 424 * @param int $ page Page number (1-based).425 * @param int $items_per_batch Items per page.426 * @return array{processed: int, events: int, success: bool }427 */ 428 private function process_product_batch( string $session_id, int $ page, int $items_per_batch ): array {457 * @param int $after_id Only items with a larger id. 458 * @param int $items_per_batch Items per batch. 459 * @return array{processed: int, events: int, success: bool, last_id: int, done: bool, error?: string, retry_after?: int} 460 */ 461 private function process_product_batch( string $session_id, int $after_id, int $items_per_batch ): array { 429 462 $formatter = emporiqa()->get_formatter(); 430 463 $webhook = emporiqa()->get_webhook(); 431 464 432 465 $query_args = array( 433 'status' => 'publish', 434 'limit' => $items_per_batch, 435 'page' => $page, 436 'type' => Emporiqa_Admin::get_product_types(), 466 'status' => 'publish', 467 'limit' => $items_per_batch, 468 'orderby' => 'ID', 469 'order' => 'ASC', 470 'type' => Emporiqa_Admin::get_product_types(), 437 471 ); 438 472 … … 450 484 } 451 485 452 $products = wc_get_products( $query_args ); 486 $query_args['emporiqa_after_id'] = $after_id; 487 488 $products = self::query_after_id( 489 function () use ( $query_args ) { 490 return wc_get_products( $query_args ); 491 } 492 ); 453 493 $events = array(); 454 494 $processed = count( $products ); 495 $last_id = $after_id; 455 496 456 497 foreach ( $products as $product ) { 498 $last_id = max( $last_id, (int) $product->get_id() ); 457 499 $formatted = $formatter->format_product_consolidated( $product, $session_id ); 458 500 foreach ( $formatted as $item ) { … … 464 506 } 465 507 466 $success = true; 467 $error = null; 508 $success = true; 509 $error = null; 510 $retry_after = null; 468 511 if ( ! empty( $events ) ) { 469 512 $success = $webhook->send_batch_events( $events ); 470 513 if ( ! $success ) { 471 $error = $webhook->get_last_error(); 514 $error = $webhook->get_last_error(); 515 $retry_after = $webhook->get_last_retry_after(); 472 516 } 473 517 } … … 477 521 'events' => count( $events ), 478 522 'success' => $success, 523 'last_id' => $last_id, 524 'done' => $processed < $items_per_batch, 479 525 ); 480 526 if ( ! $success && $error ) { 481 527 $out['error'] = $error; 482 528 } 529 if ( null !== $retry_after ) { 530 $out['retry_after'] = $retry_after; 531 } 483 532 484 533 return $out; … … 491 540 * 492 541 * @param string $session_id Sync session ID. 493 * @param int $ page Page number (1-based).494 * @param int $items_per_batch Items per page.495 * @return array{processed: int, events: int, success: bool }496 */ 497 private function process_page_batch( string $session_id, int $ page, int $items_per_batch ): array {542 * @param int $after_id Only items with a larger id. 543 * @param int $items_per_batch Items per batch. 544 * @return array{processed: int, events: int, success: bool, last_id: int, done: bool, error?: string, retry_after?: int} 545 */ 546 private function process_page_batch( string $session_id, int $after_id, int $items_per_batch ): array { 498 547 $post_types = Emporiqa_Admin::get_option( 'page_post_types', array( 'page' ) ); 499 548 … … 504 553 'events' => 0, 505 554 'success' => true, 555 'last_id' => $after_id, 556 'done' => true, 506 557 ); 507 558 } … … 514 565 'post_status' => 'publish', 515 566 'posts_per_page' => $items_per_batch, 516 'paged' => $page, 567 'orderby' => 'ID', 568 'order' => 'ASC', 569 'no_found_rows' => true, 517 570 'suppress_filters' => false, 518 571 ); … … 525 578 } 526 579 527 $pages = get_posts( $query_args ); 580 $query_args['emporiqa_after_id'] = $after_id; 581 582 $pages = self::query_after_id( 583 function () use ( $query_args ) { 584 return get_posts( $query_args ); 585 } 586 ); 528 587 $events = array(); 529 588 $processed = count( $pages ); 589 $last_id = $after_id; 530 590 531 591 foreach ( $pages as $page_post ) { 592 $last_id = max( $last_id, (int) $page_post->ID ); 532 593 $formatted = $formatter->format_page_consolidated( $page_post, $session_id ); 533 594 if ( ! empty( $formatted ) ) { … … 539 600 } 540 601 541 $success = true; 542 $error = null; 602 $success = true; 603 $error = null; 604 $retry_after = null; 543 605 if ( ! empty( $events ) ) { 544 606 $success = $webhook->send_batch_events( $events ); 545 607 if ( ! $success ) { 546 $error = $webhook->get_last_error(); 608 $error = $webhook->get_last_error(); 609 $retry_after = $webhook->get_last_retry_after(); 547 610 } 548 611 } … … 552 615 'events' => count( $events ), 553 616 'success' => $success, 617 'last_id' => $last_id, 618 'done' => $processed < $items_per_batch, 554 619 ); 555 620 if ( ! $success && $error ) { 556 621 $out['error'] = $error; 557 622 } 623 if ( null !== $retry_after ) { 624 $out['retry_after'] = $retry_after; 625 } 558 626 559 627 return $out; 560 628 } 629 630 /** 631 * Run $query with `ID > after_id` added to the posts queries that carry 632 * the `emporiqa_after_id` query var (keyset paging). wc_get_products() 633 * and get_posts() have no "id after" argument, but both pass unknown 634 * query vars through to WP_Query, so it goes in through posts_where. 635 * 636 * @since 1.2.0 637 * 638 * @param callable $query Runs one posts query whose args include `emporiqa_after_id`. 639 * @return array 640 */ 641 public static function query_after_id( callable $query ): array { 642 $filter = function ( $where, $wp_query ) { 643 $after_id = $wp_query instanceof \WP_Query ? $wp_query->get( 'emporiqa_after_id', null ) : null; 644 if ( null === $after_id ) { 645 return $where; 646 } 647 global $wpdb; 648 return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", absint( $after_id ) ); 649 }; 650 add_filter( 'posts_where', $filter, 10, 2 ); 651 try { 652 $result = $query(); 653 } finally { 654 remove_filter( 'posts_where', $filter, 10 ); 655 } 656 return is_array( $result ) ? $result : array(); 657 } 561 658 } -
emporiqa/tags/1.2.0/includes/class-emporiqa-webhook.php
r3560454 r3730094 98 98 99 99 /** 100 * Seconds Emporiqa asked us to wait (429, or 503 with Retry-After) on the 101 * most recent failed send_batch_events call, or null. Emporiqa sheds sync 102 * batches this way when its queue is full; the batch is meant to be sent 103 * again after the wait, not counted as failed. 104 * 105 * @var int|null 106 */ 107 private ?int $last_retry_after = null; 108 109 /** 110 * Longest wait honoured from Retry-After (seconds). 111 * 112 * @since 1.2.0 113 */ 114 public const MAX_RETRY_AFTER = 300; 115 116 /** 117 * Wait used when a 429 names none (seconds). 118 * 119 * @since 1.2.0 120 */ 121 public const DEFAULT_RETRY_AFTER = 30; 122 123 /** 124 * How many times a WP-CLI sync waits and resends one batch before it 125 * counts the batch as failed. 126 * 127 * @since 1.2.0 128 */ 129 private const CLI_MAX_WAITS = 10; 130 131 /** 100 132 * Connect-phase budget (ms) to install on the in-flight cURL handle 101 133 * via http_api_curl. Set just before wp_remote_post in do_request(), … … 657 689 // (cURL) or by apply_fallback_request_timeout (Streams). 658 690 'timeout' => 0.01, 659 'headers' => array( 660 'Content-Type' => 'application/json', 661 'X-Webhook-Signature' => $this->generate_signature( $json ), 662 ), 691 'headers' => self::signed_headers( $json ), 663 692 'body' => $json, 664 693 ) … … 835 864 $result = $this->do_request( $payload, false, $timeout ); 836 865 866 // WP-CLI has no request time limit, so it waits as Emporiqa asks and 867 // sends the same batch again. The admin sync waits in the browser. 868 $waits = 0; 869 while ( ! $result['success'] && null !== ( $result['retry_after'] ?? null ) && $debug && defined( 'WP_CLI' ) && WP_CLI && $waits < self::CLI_MAX_WAITS ) { 870 ++$waits; 871 \WP_CLI::log( sprintf( 'Emporiqa asked to wait %d s before the next batch; waiting.', $result['retry_after'] ) ); 872 sleep( $result['retry_after'] ); 873 $result = $this->do_request( $payload, false, $timeout ); 874 } 875 876 $this->last_retry_after = $result['success'] ? null : ( $result['retry_after'] ?? null ); 877 837 878 if ( ! $result['success'] ) { 838 879 $this->last_error = $this->build_friendly_error( $result ); … … 864 905 public function get_last_error(): ?string { 865 906 return $this->last_error; 907 } 908 909 /** 910 * Seconds Emporiqa asked to wait before the batch that just failed is 911 * sent again; null when the failure was not a request to wait. 912 * 913 * @since 1.2.0 914 * 915 * @return int|null 916 */ 917 public function get_last_retry_after(): ?int { 918 return $this->last_retry_after; 919 } 920 921 /** 922 * The wait a failed response asks for: a 429 always (Retry-After, else 923 * DEFAULT_RETRY_AFTER), a 503 only when it carries Retry-After. Seconds 924 * between 1 and MAX_RETRY_AFTER; an HTTP-date Retry-After counts as the 925 * default. Null for every other failure. 926 * 927 * @since 1.2.0 928 * 929 * @param int $status HTTP status. 930 * @param string $retry_after Retry-After header value, '' when absent. 931 * @return int|null 932 */ 933 public static function retry_after( int $status, string $retry_after ): ?int { 934 $retry_after = trim( $retry_after ); 935 if ( 429 !== $status && ! ( 503 === $status && '' !== $retry_after ) ) { 936 return null; 937 } 938 $seconds = ctype_digit( $retry_after ) ? (int) $retry_after : self::DEFAULT_RETRY_AFTER; 939 return max( 1, min( self::MAX_RETRY_AFTER, $seconds ) ); 866 940 } 867 941 … … 1040 1114 $result = $this->do_request( array( 'events' => $events ), true ); 1041 1115 1116 // Warned from 2 minutes, well inside the 5 minutes Emporiqa allows, so 1117 // the merchant hears of a drifting clock before every sync fails. 1118 $clock_warning = ''; 1119 $skew = $result['clock_skew'] ?? null; 1120 if ( null !== $skew && abs( $skew ) > 120 ) { 1121 $clock_warning = ' ' . sprintf( 1122 /* translators: %d: number of minutes */ 1123 __( "This server's clock is %d minutes off. Ask your host to turn on time synchronisation (NTP): Emporiqa refuses data sent with a clock more than 5 minutes off.", 'emporiqa' ), 1124 (int) round( abs( $skew ) / 60 ) 1125 ); 1126 } 1127 1042 1128 if ( $result['success'] ) { 1043 1129 $dry_run_data = $result['response'] ?? array(); 1130 if ( is_array( $dry_run_data ) ) { 1131 // A connected site learns here, without reconnecting, whether 1132 // Emporiqa offers ready-made rules and which are live. 1133 Emporiqa_Admin::store_rules_status( $dry_run_data ); 1134 } 1044 1135 1045 1136 $has_warnings = false; … … 1055 1146 return array( 1056 1147 'success' => true, 1057 'message' => $has_warnings1148 'message' => ( $has_warnings 1058 1149 ? __( 'Connection successful, but there are warnings.', 'emporiqa' ) 1059 : __( 'Connection successful! All fields validated.', 'emporiqa' ) ,1150 : __( 'Connection successful! All fields validated.', 'emporiqa' ) ) . $clock_warning, 1060 1151 'dry_run' => $dry_run_data, 1061 1152 ); … … 1065 1156 'success' => false, 1066 1157 /* translators: %s: human-readable error message from the Emporiqa server */ 1067 'message' => sprintf( __( 'Connection failed: %s', 'emporiqa' ), $this->build_friendly_error( $result ) ) ,1158 'message' => sprintf( __( 'Connection failed: %s', 'emporiqa' ), $this->build_friendly_error( $result ) ) . $clock_warning, 1068 1159 'dry_run' => $result['response'], 1069 1160 ); … … 1135 1226 1136 1227 /** 1137 * Generate an HMAC-SHA256 signature for a payload.1138 *1139 * @since 1.0.01140 *1141 * @param string $payload The JSON payload.1142 * @return string The hex-encoded signature.1143 */1144 private function generate_signature( string $payload ): string {1145 $secret = Emporiqa_Admin::get_option( 'webhook_secret', '' );1146 if ( '' === $secret ) {1147 return '';1148 }1149 return hash_hmac( 'sha256', $payload, $secret );1150 }1151 1152 /**1153 1228 * Send an HTTP POST request to the webhook endpoint. 1154 1229 * … … 1199 1274 } 1200 1275 1201 $headers = array( 1202 'Content-Type' => 'application/json', 1203 'X-Webhook-Signature' => $this->generate_signature( $json_payload ), 1204 ); 1276 $headers = self::signed_headers( $json_payload ); 1205 1277 1206 1278 // Hook-driven (AS worker) sends get the tight handshake cap; admin- … … 1237 1309 $status_code = wp_remote_retrieve_response_code( $response ); 1238 1310 $body = json_decode( wp_remote_retrieve_body( $response ), true ); 1311 $clock_skew = self::clock_skew( (string) wp_remote_retrieve_header( $response, 'date' ), time() ); 1239 1312 1240 1313 if ( in_array( $status_code, array( 200, 201, 202 ), true ) ) { 1241 1314 return array( 1242 'success' => true, 1243 'response' => $body, 1244 'error' => null, 1315 'success' => true, 1316 'response' => $body, 1317 'error' => null, 1318 'clock_skew' => $clock_skew, 1245 1319 ); 1246 1320 } … … 1251 1325 1252 1326 return array( 1253 'success' => false, 1254 'response' => $body, 1255 'error' => $error_msg, 1327 'success' => false, 1328 'response' => $body, 1329 'error' => $error_msg, 1330 'clock_skew' => $clock_skew, 1331 'retry_after' => self::retry_after( (int) $status_code, (string) wp_remote_retrieve_header( $response, 'retry-after' ) ), 1256 1332 ); 1333 } 1334 1335 /** 1336 * Headers for a sync webhook body: the scheme-2 signature beside the 1337 * legacy one (a new plugin may still meet a platform that only knows the 1338 * old header), and the plugin version. Built per send, so every send and 1339 * every retry carries a fresh timestamp. 1340 * 1341 * @since 1.2.0 1342 * 1343 * @param string $json Exact request body. 1344 * @return array<string, string> 1345 */ 1346 public static function signed_headers( string $json ): array { 1347 $secret = (string) Emporiqa_Admin::get_option( 'webhook_secret', '' ); 1348 $headers = array( 1349 'Content-Type' => 'application/json', 1350 'X-Webhook-Signature' => '' === $secret ? '' : hash_hmac( 'sha256', $json, $secret ), 1351 'X-Emporiqa-Plugin-Version' => 'woocommerce/' . EMPORIQA_VERSION, 1352 ); 1353 $scheme2 = Emporiqa_Signature::webhook_header( $json, $secret, (string) Emporiqa_Admin::get_option( 'store_id', '' ) ); 1354 if ( '' !== $scheme2 ) { 1355 $headers['X-Emporiqa-Webhook-Signature'] = $scheme2; 1356 } 1357 return $headers; 1358 } 1359 1360 /** 1361 * Seconds this server's clock is ahead of Emporiqa's (negative: behind), 1362 * from the response Date header; null when there is none. Tells a skewed 1363 * clock apart from a wrong secret when signatures are refused. 1364 * 1365 * @since 1.2.0 1366 * 1367 * @param string $date_header HTTP Date header value. 1368 * @param int $now Local time. 1369 * @return int|null 1370 */ 1371 public static function clock_skew( string $date_header, int $now ): ?int { 1372 $remote = '' === $date_header ? false : strtotime( $date_header ); 1373 return false === $remote ? null : $now - $remote; 1257 1374 } 1258 1375 -
emporiqa/tags/1.2.0/includes/class-emporiqa-widget.php
r3549215 r3730094 13 13 * - currency: ISO 4217 currency code (e.g. EUR, USD) 14 14 * - channel: Sales channel identifier (always included, defaults to 'default') 15 * - user_id: Signed token for logged-in customer identification 15 * 16 * A signed-in customer's token is never in the page or the URL (full-page 17 * caches would serve it to another shopper, and URLs end up in server logs). 18 * assets/js/emporiqa-customer-token.js asks the uncached admin-ajax action 19 * `emporiqa_customer_token` for it when the chat opens, and hands it to 20 * embed.js on a MessageChannel port. 16 21 * 17 22 * @package Emporiqa … … 31 36 32 37 /** 38 * The admin-ajax action that returns the customer token. 39 */ 40 public const TOKEN_ACTION = 'emporiqa_customer_token'; 41 42 /** 43 * The request header the token script sends. A custom header makes a 44 * cross-site request need a CORS preflight, which admin-ajax never 45 * grants, so a page on another site cannot have the browser send it. 46 */ 47 public const TOKEN_HEADER = 'X-Emporiqa-Token'; 48 49 /** 33 50 * Register hooks. 34 51 * … … 38 55 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_widget' ) ); 39 56 add_filter( 'script_loader_tag', array( $this, 'modify_widget_script_tag' ), 10, 2 ); 57 // Signed-in visitors only: a guest has no token, and its script never asks. 58 add_action( 'wp_ajax_' . self::TOKEN_ACTION, array( $this, 'ajax_customer_token' ) ); 40 59 } 41 60 … … 67 86 } 68 87 88 $language = $this->get_current_language(); 89 if ( $this->is_multilingual() && ! self::is_language_enabled( $language, (array) Emporiqa_Admin::get_option( 'enabled_languages', array() ) ) ) { 90 return; 91 } 92 69 93 $query_params = array( 70 94 'store_id' => $store_id, 71 'language' => $ this->get_current_language(),95 'language' => $language, 72 96 ); 73 97 … … 92 116 } 93 117 94 if ( is_user_logged_in() ) {95 $webhook_secret = Emporiqa_Admin::get_option( 'webhook_secret' );96 if ( ! empty( $webhook_secret ) ) {97 $user_token = $this->generate_user_token(98 (string) get_current_user_id(),99 $webhook_secret100 );101 if ( ! empty( $user_token ) ) {102 $query_params['user_id'] = $user_token;103 }104 }105 }106 107 118 /** 108 119 * Filter the widget embed query parameters. … … 110 121 * @since 1.0.0 111 122 * 112 * @param array $query_params The query parameters (store_id, language, currency, channel , user_id).123 * @param array $query_params The query parameters (store_id, language, currency, channel). 113 124 */ 114 125 $query_params = apply_filters( 'emporiqa_widget_params', $query_params ); … … 121 132 wp_enqueue_script( 'emporiqa-widget', $widget_url, array(), EMPORIQA_VERSION, true ); 122 133 wp_script_add_data( 'emporiqa-widget', 'strategy', 'async' ); 134 135 // The same for every visitor, so cached pages may carry it: it holds 136 // only the admin-ajax URL, and the token is fetched on demand. 137 if ( ! empty( Emporiqa_Admin::get_option( 'webhook_secret' ) ) ) { 138 wp_enqueue_script( 139 'emporiqa-customer-token', 140 EMPORIQA_PLUGIN_URL . 'assets/js/emporiqa-customer-token.js', 141 array(), 142 EMPORIQA_VERSION, 143 array( 144 'in_footer' => true, 145 'strategy' => 'defer', 146 ) 147 ); 148 wp_add_inline_script( 149 'emporiqa-customer-token', 150 'window.emporiqaTokenConfig = ' . wp_json_encode( 151 array( 152 'url' => admin_url( 'admin-ajax.php' ), 153 'action' => self::TOKEN_ACTION, 154 'header' => self::TOKEN_HEADER, 155 ) 156 ) . ';', 157 'before' 158 ); 159 } 160 } 161 162 /** 163 * Admin-ajax: the signed-in customer's token. 164 * 165 * Read-only: it returns only the current session's own identity token 166 * and changes nothing. It cannot carry a nonce, because the page that 167 * would hold one is served from full-page caches to other visitors. 168 * Cross-site use is blocked instead: POST only, the TOKEN_HEADER header 169 * is required (forcing a CORS preflight), and a request whose 170 * Sec-Fetch-Site or Origin says it came from another site is refused. 171 * Never cached (no-store, private, Vary: Cookie). 172 * 173 * @since 1.2.0 174 */ 175 public function ajax_customer_token(): void { 176 // Through the filter, not header(): wp_send_json() ends in the 177 // admin-ajax die handler, which sends nocache_headers() again, and 178 // before WordPress 6.3 those lack no-store and private. 179 add_filter( 'nocache_headers', array( __CLASS__, 'no_store_headers' ) ); 180 nocache_headers(); 181 send_nosniff_header(); 182 header( 'Vary: Cookie' ); 183 184 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; 185 if ( 'POST' !== $method ) { 186 wp_send_json( array( 'token' => '' ), 405 ); 187 } 188 if ( ! self::is_same_site_request( $_SERVER, home_url() ) ) { 189 wp_send_json( array( 'token' => '' ), 403 ); 190 } 191 192 $token = ''; 193 if ( is_user_logged_in() ) { 194 $token = Emporiqa_Signature::customer_token( 195 (string) get_current_user_id(), 196 (string) Emporiqa_Admin::get_option( 'webhook_secret', '' ), 197 (string) Emporiqa_Admin::get_option( 'store_id', '' ) 198 ); 199 } 200 wp_send_json( array( 'token' => $token ) ); 201 } 202 203 /** 204 * The nocache headers with no-store and private, so no shared or browser 205 * cache keeps a customer's token, on every supported WordPress version. 206 * 207 * @since 1.2.0 208 * 209 * @param array $headers Header names and values. 210 * @return array 211 */ 212 public static function no_store_headers( array $headers ): array { 213 $headers['Cache-Control'] = 'no-cache, must-revalidate, max-age=0, no-store, private'; 214 return $headers; 215 } 216 217 /** 218 * Whether a token request came from this site's own script: it carries 219 * TOKEN_HEADER, Sec-Fetch-Site (when the browser sends it) is 220 * same-origin, and Origin (when sent) is the site's own host. 221 * 222 * @since 1.2.0 223 * 224 * @param array $server $_SERVER. 225 * @param string $home home_url(). 226 * @return bool 227 */ 228 public static function is_same_site_request( array $server, string $home ): bool { 229 $header = 'HTTP_' . strtoupper( str_replace( '-', '_', self::TOKEN_HEADER ) ); 230 if ( '1' !== ( $server[ $header ] ?? '' ) ) { 231 return false; 232 } 233 $fetch_site = isset( $server['HTTP_SEC_FETCH_SITE'] ) ? strtolower( (string) $server['HTTP_SEC_FETCH_SITE'] ) : ''; 234 if ( '' !== $fetch_site && 'same-origin' !== $fetch_site ) { 235 return false; 236 } 237 $origin = isset( $server['HTTP_ORIGIN'] ) ? (string) $server['HTTP_ORIGIN'] : ''; 238 if ( '' === $origin ) { 239 return true; 240 } 241 $host = self::host_of( $origin ); 242 return '' !== $host && self::host_of( $home ) === $host; 243 } 244 245 /** 246 * The host[:port] of a URL, lowercase; the scheme is left out, since a site 247 * behind a TLS proxy may know itself as http. 248 * 249 * @since 1.2.0 250 * 251 * @param string $url URL. 252 * @return string '' when it has no host. 253 */ 254 public static function host_of( string $url ): string { 255 $parts = wp_parse_url( $url ); 256 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { 257 return ''; 258 } 259 return strtolower( $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) ); 123 260 } 124 261 … … 152 289 153 290 /** 154 * Generate a signed user identity token for the chat widget. 155 * 156 * @since 1.0.0 157 * 158 * @param string $user_id The customer's user ID. 159 * @param string $webhook_secret The store's webhook secret. 160 * @return string Signed token in format "base64url_payload.hmac_hex". 161 */ 162 private function generate_user_token( string $user_id, string $webhook_secret ): string { 163 $payload = wp_json_encode( 164 array( 165 'uid' => $user_id, 166 'ts' => time(), 167 ) 168 ); 169 170 if ( false === $payload ) { 171 return ''; 172 } 173 174 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for signed token encoding. 175 $encoded_payload = rtrim( strtr( base64_encode( $payload ), '+/', '-_' ), '=' ); 176 $signature = hash_hmac( 'sha256', $encoded_payload, $webhook_secret ); 177 178 return $encoded_payload . '.' . $signature; 291 * Whether the page's language is ticked under Languages. A language that 292 * is not ticked is neither synced nor offered: its pages show no chat. 293 * 294 * @since 1.2.0 295 * 296 * @param string $current The page's language. 297 * @param string[] $enabled Ticked languages. 298 * @return bool 299 */ 300 public static function is_language_enabled( string $current, array $enabled ): bool { 301 return empty( $enabled ) || in_array( $current, $enabled, true ); 302 } 303 304 /** 305 * Whether Polylang or WPML gives each page its own language. Without one 306 * the site has a single language, and the chat always shows. 307 * 308 * @return bool 309 */ 310 private function is_multilingual(): bool { 311 return function_exists( 'pll_current_language' ) || ( defined( 'ICL_LANGUAGE_CODE' ) && ICL_LANGUAGE_CODE ); 179 312 } 180 313 -
emporiqa/tags/1.2.0/languages/emporiqa.pot
r3678094 r3730094 3 3 msgid "" 4 4 msgstr "" 5 "Project-Id-Version: Emporiqa 1. 1.10\n"5 "Project-Id-Version: Emporiqa 1.2.0\n" 6 6 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/emporiqa\n" 7 7 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" … … 10 10 "Content-Type: text/plain; charset=UTF-8\n" 11 11 "Content-Transfer-Encoding: 8bit\n" 12 "POT-Creation-Date: 2026- 09-02T13:34:37+00:00\n"12 "POT-Creation-Date: 2026-10-06T05:10:28+00:00\n" 13 13 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 14 14 "X-Generator: WP-CLI 2.12.0\n" … … 18 18 #. Author of the plugin 19 19 #: emporiqa.php 20 #: emporiqa.php:10 6121 #: emporiqa.php:11 1022 #: includes/class-emporiqa-admin.php: 5920 #: emporiqa.php:1079 21 #: emporiqa.php:1128 22 #: includes/class-emporiqa-admin.php:64 23 23 msgid "Emporiqa" 24 24 msgstr "" … … 34 34 msgstr "" 35 35 36 #: emporiqa.php:10 6236 #: emporiqa.php:1080 37 37 msgid "requires WooCommerce to be installed and active." 38 38 msgstr "" 39 39 40 #: emporiqa.php:11 1140 #: emporiqa.php:1129 41 41 msgid "requires WooCommerce 8.0 or higher. Some features may not work correctly with your current version." 42 42 msgstr "" 43 43 44 #: emporiqa.php:11 2844 #: emporiqa.php:1146 45 45 msgid "Emporiqa activated!" 46 46 msgstr "" 47 47 48 #: emporiqa.php:11 2948 #: emporiqa.php:1147 49 49 msgid "Connect your store to start syncing products and pages." 50 50 msgstr "" 51 51 52 #: emporiqa.php:11 3152 #: emporiqa.php:1149 53 53 msgid "Go to Settings →" 54 54 msgstr "" 55 55 56 #: includes/class-emporiqa-admin.php: 5856 #: includes/class-emporiqa-admin.php:63 57 57 msgid "Emporiqa Settings" 58 58 msgstr "" 59 59 60 #: includes/class-emporiqa-admin.php: 7861 #: includes/class-emporiqa-admin.php:2 2560 #: includes/class-emporiqa-admin.php:83 61 #: includes/class-emporiqa-admin.php:239 62 62 msgid "Settings" 63 63 msgstr "" 64 64 65 65 #. translators: %s: link to Sync tab 66 #: includes/class-emporiqa-admin.php:10 166 #: includes/class-emporiqa-admin.php:106 67 67 #, php-format 68 68 msgid "To sync your products and pages with Emporiqa, go to the <a href=\"%s\">Sync tab</a>." 69 69 msgstr "" 70 70 71 #: includes/class-emporiqa-admin.php:1 6871 #: includes/class-emporiqa-admin.php:182 72 72 msgid "Connection Secret is required. Webhooks and user identification will not work without it." 73 73 msgstr "" 74 74 75 75 #. translators: %s: link to Emporiqa dashboard integration overview 76 #: includes/class-emporiqa-admin.php: 19776 #: includes/class-emporiqa-admin.php:211 77 77 #, php-format 78 78 msgid "Before your first sync, make sure your Store ID and Connection Secret are configured. Find both in your <a href=\"%s\" target=\"_blank\">Emporiqa dashboard → Settings → Integration → Integration overview</a>." 79 79 msgstr "" 80 80 81 #: includes/class-emporiqa-admin.php:2 2881 #: includes/class-emporiqa-admin.php:242 82 82 msgid "Sync" 83 83 msgstr "" 84 84 85 #: includes/class-emporiqa-admin.php:3 0185 #: includes/class-emporiqa-admin.php:316 86 86 msgid "Connection Settings" 87 87 msgstr "" 88 88 89 #: includes/class-emporiqa-admin.php:3 1189 #: includes/class-emporiqa-admin.php:326 90 90 msgid "Connected." 91 91 msgstr "" 92 92 93 #: includes/class-emporiqa-admin.php:3 1293 #: includes/class-emporiqa-admin.php:327 94 94 msgid "Your store is now linked to Emporiqa. Use the Sync tab to send your catalog." 95 95 msgstr "" 96 96 97 #: includes/class-emporiqa-admin.php:355 97 #: includes/class-emporiqa-admin.php:380 98 msgid "Ready-made rules" 99 msgstr "" 100 101 #: includes/class-emporiqa-admin.php:383 102 msgid "Rules tell the chat what to do when a shopper asks for something specific. The rule below reads your WooCommerce orders through this plugin. Click Open in Emporiqa to test it and switch it on there. Rules that do not need your shop, such as return requests, are added in Emporiqa under Settings > Rules." 103 msgstr "" 104 105 #: includes/class-emporiqa-admin.php:387 106 msgid "The Order status rule is on, so the old order tracking under Advanced is no longer needed. First remove its address in your Emporiqa dashboard (Settings > Integration > For your developer > Order tracking API URL), then untick Order tracking under Advanced and click Save Changes." 107 msgstr "" 108 109 #: includes/class-emporiqa-admin.php:392 110 msgid "Order status address" 111 msgstr "" 112 113 #: includes/class-emporiqa-admin.php:398 114 #: includes/class-emporiqa-admin.php:453 115 #: includes/class-emporiqa-admin.php:709 116 msgid "Copy" 117 msgstr "" 118 119 #: includes/class-emporiqa-admin.php:401 120 msgid "Emporiqa fills this in for you when you add the Order status rule. If it ever asks for the address, use this one." 121 msgstr "" 122 123 #: includes/class-emporiqa-admin.php:403 124 msgid "Your site is not served over https, so Emporiqa cannot call it. Switch the site to https (Settings > General), then come back here." 125 msgstr "" 126 127 #: includes/class-emporiqa-admin.php:408 128 msgid "Order status" 129 msgstr "" 130 131 #: includes/class-emporiqa-admin.php:411 132 msgid "On" 133 msgstr "" 134 135 #: includes/class-emporiqa-admin.php:413 136 msgid "Not added" 137 msgstr "" 138 139 #: includes/class-emporiqa-admin.php:416 140 msgid "Open in Emporiqa" 141 msgstr "" 142 143 #: includes/class-emporiqa-admin.php:420 144 msgid "Answers \"Where is my order?\" from your WooCommerce orders. A shopper signed in to your shop only gives the order number; a guest also gives the email used for the order." 145 msgstr "" 146 147 #: includes/class-emporiqa-admin.php:423 148 msgid "On and Not added show what Emporiqa said at your last connect or Test connection (Sync tab). Run Test connection after changing a rule to refresh them." 149 msgstr "" 150 151 #: includes/class-emporiqa-admin.php:442 152 msgid "Order tracking (old)" 153 msgstr "" 154 155 #: includes/class-emporiqa-admin.php:448 156 msgid "Answer order lookups at the old order tracking address." 157 msgstr "" 158 159 #: includes/class-emporiqa-admin.php:457 160 msgid "Replaced by the Order status rule above. Leave it on while your Emporiqa dashboard still has this address. To switch it off, first remove the address in your Emporiqa dashboard (Settings > Integration > For your developer > Order tracking API URL), then untick this and click Save Changes." 161 msgstr "" 162 163 #: includes/class-emporiqa-admin.php:527 98 164 msgid "Connect to Emporiqa in one click" 99 165 msgstr "" 100 166 101 #: includes/class-emporiqa-admin.php: 358167 #: includes/class-emporiqa-admin.php:530 102 168 msgid "We'll sign you in, link this store, and send back a fresh connection secret. No copy-pasting." 103 169 msgstr "" 104 170 105 #: includes/class-emporiqa-admin.php: 371171 #: includes/class-emporiqa-admin.php:543 106 172 msgid "Connect to Emporiqa" 107 173 msgstr "" 108 174 109 #: includes/class-emporiqa-admin.php: 376110 #: includes/class-emporiqa-connect.php:1 33175 #: includes/class-emporiqa-admin.php:548 176 #: includes/class-emporiqa-connect.php:148 111 177 msgid "Your site must be served over HTTPS to use one-click connect." 112 178 msgstr "" 113 179 114 #: includes/class-emporiqa-admin.php: 383180 #: includes/class-emporiqa-admin.php:555 115 181 msgid "I'd rather paste credentials manually" 116 182 msgstr "" 117 183 118 #: includes/class-emporiqa-admin.php: 400184 #: includes/class-emporiqa-admin.php:572 119 185 msgid "Connection in progress." 120 186 msgstr "" 121 187 122 #: includes/class-emporiqa-admin.php: 401188 #: includes/class-emporiqa-admin.php:573 123 189 msgid "Finish the steps on emporiqa.com, then come back to this tab." 124 190 msgstr "" 125 191 126 192 #. translators: %s: link text for "start over" 127 #: includes/class-emporiqa-admin.php: 407193 #: includes/class-emporiqa-admin.php:579 128 194 #, php-format 129 195 msgid "Tab got stuck? %s." 130 196 msgstr "" 131 197 132 #: includes/class-emporiqa-admin.php: 411198 #: includes/class-emporiqa-admin.php:583 133 199 msgid "Start over" 134 200 msgstr "" 135 201 136 #: includes/class-emporiqa-admin.php: 432202 #: includes/class-emporiqa-admin.php:604 137 203 msgid "Connected to Emporiqa." 138 204 msgstr "" 139 205 140 #: includes/class-emporiqa-admin.php: 436141 #: includes/class-emporiqa-admin.php: 480206 #: includes/class-emporiqa-admin.php:608 207 #: includes/class-emporiqa-admin.php:652 142 208 msgid "Store ID" 143 209 msgstr "" 144 210 145 #: includes/class-emporiqa-admin.php: 442211 #: includes/class-emporiqa-admin.php:614 146 212 msgid "Connection secret" 147 213 msgstr "" 148 214 149 #: includes/class-emporiqa-admin.php: 444215 #: includes/class-emporiqa-admin.php:616 150 216 msgid "Stored. Reconnect to rotate." 151 217 msgstr "" 152 218 153 #: includes/class-emporiqa-admin.php: 450219 #: includes/class-emporiqa-admin.php:622 154 220 msgid "Reconnect" 155 221 msgstr "" 156 222 157 #: includes/class-emporiqa-admin.php: 453223 #: includes/class-emporiqa-admin.php:625 158 224 msgid "Open Emporiqa dashboard" 159 225 msgstr "" 160 226 161 #: includes/class-emporiqa-admin.php: 461227 #: includes/class-emporiqa-admin.php:633 162 228 msgid "Edit credentials manually" 163 229 msgstr "" 164 230 165 231 #. translators: %s: link to Emporiqa dashboard integration overview 166 #: includes/class-emporiqa-admin.php: 490232 #: includes/class-emporiqa-admin.php:662 167 233 #, php-format 168 234 msgid "Find it in your <a href=\"%s\" target=\"_blank\">Emporiqa dashboard → Settings → Integration → Integration overview</a>" 169 235 msgstr "" 170 236 171 #: includes/class-emporiqa-admin.php: 499237 #: includes/class-emporiqa-admin.php:671 172 238 msgid "Connection Secret" 173 239 msgstr "" 174 240 175 #: includes/class-emporiqa-admin.php: 505241 #: includes/class-emporiqa-admin.php:677 176 242 msgid "Value is set (leave empty to keep)" 177 243 msgstr "" 178 244 179 #: includes/class-emporiqa-admin.php: 511245 #: includes/class-emporiqa-admin.php:683 180 246 msgid "Not configured" 181 247 msgstr "" 182 248 183 #: includes/class-emporiqa-admin.php: 516249 #: includes/class-emporiqa-admin.php:688 184 250 msgid "Required. Secret key for HMAC signature. Leave empty to keep the current value." 185 251 msgstr "" 186 252 187 #: includes/class-emporiqa-admin.php: 532253 #: includes/class-emporiqa-admin.php:704 188 254 msgid "Order Tracking API URL" 189 255 msgstr "" 190 256 191 #: includes/class-emporiqa-admin.php:537192 msgid "Copy"193 msgstr ""194 195 257 #. translators: %s: link to Emporiqa dashboard order tracking settings 196 #: includes/class-emporiqa-admin.php: 545258 #: includes/class-emporiqa-admin.php:717 197 259 #, php-format 198 260 msgid "Optional. To enable order tracking in the chat, paste this URL into your <a href=\"%s\" target=\"_blank\">Emporiqa dashboard → Settings → Integration → Order Tracking API URL</a>. Requests are authenticated with your webhook secret; the customer email is always required and must match the order billing email." 199 261 msgstr "" 200 262 201 #: includes/class-emporiqa-admin.php: 569263 #: includes/class-emporiqa-admin.php:741 202 264 msgid "Advanced" 203 265 msgstr "" 204 266 205 #: includes/class-emporiqa-admin.php: 575267 #: includes/class-emporiqa-admin.php:747 206 268 msgid "The defaults work for most stores. Open this only if you want to change what's sent to Emporiqa." 207 269 msgstr "" 208 270 209 #: includes/class-emporiqa-admin.php: 579271 #: includes/class-emporiqa-admin.php:751 210 272 msgid "Sync products" 211 273 msgstr "" 212 274 213 #: includes/class-emporiqa-admin.php: 584275 #: includes/class-emporiqa-admin.php:756 214 276 msgid "Send new and updated products to Emporiqa so the chat can recommend them." 215 277 msgstr "" 216 278 217 #: includes/class-emporiqa-admin.php: 589279 #: includes/class-emporiqa-admin.php:761 218 280 msgid "Sync pages" 219 281 msgstr "" 220 282 221 #: includes/class-emporiqa-admin.php: 594283 #: includes/class-emporiqa-admin.php:766 222 284 msgid "Send pages and blog content so the chat can answer questions about your shipping, returns, FAQs, and similar." 223 285 msgstr "" 224 286 225 #: includes/class-emporiqa-admin.php: 599287 #: includes/class-emporiqa-admin.php:771 226 288 msgid "Languages" 227 289 msgstr "" 228 290 229 #: includes/class-emporiqa-admin.php: 616291 #: includes/class-emporiqa-admin.php:788 230 292 msgid "Pick the languages of your products and pages to send. The chat replies in each shopper's language for any of these." 231 293 msgstr "" 232 294 233 #: includes/class-emporiqa-admin.php: 621295 #: includes/class-emporiqa-admin.php:793 234 296 msgid "Pages and posts to include" 235 297 msgstr "" 236 298 237 #: includes/class-emporiqa-admin.php: 642299 #: includes/class-emporiqa-admin.php:814 238 300 msgid "Which kinds of content count as \"pages\" for the chat. Most stores leave this as Pages only. Add Posts if you want your blog articles included." 239 301 msgstr "" 240 302 241 #: includes/class-emporiqa-admin.php: 647303 #: includes/class-emporiqa-admin.php:824 242 304 msgid "Emporiqa server URL" 243 305 msgstr "" 244 306 245 #: includes/class-emporiqa-admin.php: 652307 #: includes/class-emporiqa-admin.php:829 246 308 msgid "Where this site sends product and page updates. Keep the default (https://emporiqa.com/webhooks/sync/) unless Emporiqa support asked you to change it." 247 309 msgstr "" 248 310 249 #: includes/class-emporiqa-admin.php: 657311 #: includes/class-emporiqa-admin.php:834 250 312 msgid "Items per batch" 251 313 msgstr "" 252 314 253 #: includes/class-emporiqa-admin.php: 662315 #: includes/class-emporiqa-admin.php:839 254 316 msgid "How many products to send at a time during the first sync. The default works for almost everyone. If a sync stops partway through on a big catalog, try a smaller number (10 or 15)." 255 317 msgstr "" 256 318 257 #: includes/class-emporiqa-admin.php: 811319 #: includes/class-emporiqa-admin.php:988 258 320 msgid "Connected to Emporiqa. One more step." 259 321 msgstr "" 260 322 261 #: includes/class-emporiqa-admin.php: 815323 #: includes/class-emporiqa-admin.php:992 262 324 msgid "Send your products and pages to Emporiqa so the chat has something to recommend. We'll send everything in batches in the background." 263 325 msgstr "" 264 326 265 #: includes/class-emporiqa-admin.php: 828327 #: includes/class-emporiqa-admin.php:1005 266 328 msgid "Send my catalog" 267 329 msgstr "" 268 330 269 331 #. translators: 1: total item count, 2: typical time 270 #: includes/class-emporiqa-admin.php: 834332 #: includes/class-emporiqa-admin.php:1011 271 333 #, php-format 272 334 msgid "%1$d items to send. Typically a few minutes." 273 335 msgstr "" 274 336 275 #: includes/class-emporiqa-admin.php: 842337 #: includes/class-emporiqa-admin.php:1019 276 338 msgid "The chat goes live on your storefront once the first full sync finishes. You don't need to keep this tab open. Sync runs from your store." 277 339 msgstr "" 278 340 279 #: includes/class-emporiqa-admin.php: 847341 #: includes/class-emporiqa-admin.php:1024 280 342 msgid "Bulk Sync" 281 343 msgstr "" 282 344 283 #: includes/class-emporiqa-admin.php: 849345 #: includes/class-emporiqa-admin.php:1026 284 346 msgid "Use \"Sync All\" for your initial sync. After that, product and page changes are sent automatically in real-time via webhooks." 285 347 msgstr "" 286 348 287 #: includes/class-emporiqa-admin.php: 855349 #: includes/class-emporiqa-admin.php:1032 288 350 msgid "products" 289 351 msgstr "" 290 352 291 353 #. translators: %s: language codes 292 #: includes/class-emporiqa-admin.php: 859354 #: includes/class-emporiqa-admin.php:1036 293 355 #, php-format 294 356 msgid "(all languages synced per product: %s)" 295 357 msgstr "" 296 358 297 #: includes/class-emporiqa-admin.php: 866359 #: includes/class-emporiqa-admin.php:1043 298 360 msgid "No published products found. Make sure you have published products before syncing." 299 361 msgstr "" 300 362 301 363 #. translators: %s: link to settings tab 302 #: includes/class-emporiqa-admin.php: 875364 #: includes/class-emporiqa-admin.php:1052 303 365 #, php-format 304 366 msgid "Product sync is disabled — <a href=\"%s\">enable it in the Settings tab</a>" 305 367 msgstr "" 306 368 307 #: includes/class-emporiqa-admin.php: 885369 #: includes/class-emporiqa-admin.php:1062 308 370 msgid "pages" 309 371 msgstr "" 310 372 311 373 #. translators: %s: language codes 312 #: includes/class-emporiqa-admin.php: 889374 #: includes/class-emporiqa-admin.php:1066 313 375 #, php-format 314 376 msgid "(all languages synced per page: %s)" 315 377 msgstr "" 316 378 317 #: includes/class-emporiqa-admin.php: 896379 #: includes/class-emporiqa-admin.php:1073 318 380 msgid "No published pages found. Make sure you have published pages before syncing." 319 381 msgstr "" 320 382 321 383 #. translators: %s: link to settings tab 322 #: includes/class-emporiqa-admin.php: 905384 #: includes/class-emporiqa-admin.php:1082 323 385 #, php-format 324 386 msgid "Page sync is disabled — <a href=\"%s\">enable it in the Settings tab</a>" 325 387 msgstr "" 326 388 327 #: includes/class-emporiqa-admin.php: 917389 #: includes/class-emporiqa-admin.php:1094 328 390 msgid "Please configure your Store ID and Connection Secret in the Settings tab before syncing." 329 391 msgstr "" 330 392 331 #: includes/class-emporiqa-admin.php: 929393 #: includes/class-emporiqa-admin.php:1106 332 394 msgid "Sync Products" 333 395 msgstr "" 334 396 335 #: includes/class-emporiqa-admin.php: 931397 #: includes/class-emporiqa-admin.php:1108 336 398 msgid "Send all products to Emporiqa." 337 399 msgstr "" 338 400 339 #: includes/class-emporiqa-admin.php: 936340 #: includes/class-emporiqa-admin.php: 957341 #: includes/class-emporiqa-admin.php: 976401 #: includes/class-emporiqa-admin.php:1113 402 #: includes/class-emporiqa-admin.php:1134 403 #: includes/class-emporiqa-admin.php:1153 342 404 msgid "Configure your Store ID and Connection Secret first" 343 405 msgstr "" 344 406 345 #: includes/class-emporiqa-admin.php: 938407 #: includes/class-emporiqa-admin.php:1115 346 408 msgid "Enable product sync in the Settings tab" 347 409 msgstr "" 348 410 349 #: includes/class-emporiqa-admin.php: 950411 #: includes/class-emporiqa-admin.php:1127 350 412 msgid "Sync Pages" 351 413 msgstr "" 352 414 353 #: includes/class-emporiqa-admin.php: 952415 #: includes/class-emporiqa-admin.php:1129 354 416 msgid "Send all pages to Emporiqa." 355 417 msgstr "" 356 418 357 #: includes/class-emporiqa-admin.php: 959419 #: includes/class-emporiqa-admin.php:1136 358 420 msgid "Enable page sync in the Settings tab" 359 421 msgstr "" 360 422 361 #: includes/class-emporiqa-admin.php: 971423 #: includes/class-emporiqa-admin.php:1148 362 424 msgid "Sync All" 363 425 msgstr "" 364 426 365 #: includes/class-emporiqa-admin.php: 973427 #: includes/class-emporiqa-admin.php:1150 366 428 msgid "Send all products and pages at once." 367 429 msgstr "" 368 430 369 #: includes/class-emporiqa-admin.php: 984431 #: includes/class-emporiqa-admin.php:1161 370 432 msgid "Cancel" 371 433 msgstr "" 372 434 373 #: includes/class-emporiqa-admin.php: 995435 #: includes/class-emporiqa-admin.php:1172 374 436 msgid "Sync log" 375 437 msgstr "" 376 438 377 #: includes/class-emporiqa-admin.php: 998439 #: includes/class-emporiqa-admin.php:1175 378 440 msgid "Test Connection" 379 441 msgstr "" 380 442 381 #: includes/class-emporiqa-admin.php:1 000443 #: includes/class-emporiqa-admin.php:1177 382 444 msgid "Sends a real product to Emporiqa in dry run mode. Validates the connection, signature, and payload without storing anything." 383 445 msgstr "" 384 446 385 #: includes/class-emporiqa-admin.php:1 004447 #: includes/class-emporiqa-admin.php:1181 386 448 msgid "Test Connection (Dry Run)" 387 449 msgstr "" 388 450 389 #: includes/class-emporiqa-admin.php:1 011451 #: includes/class-emporiqa-admin.php:1188 390 452 msgid "WP-CLI Commands (for developers)" 391 453 msgstr "" 392 454 393 #: includes/class-emporiqa-admin.php:1 059455 #: includes/class-emporiqa-admin.php:1236 394 456 msgid "Testing..." 395 457 msgstr "" 396 458 397 #: includes/class-emporiqa-admin.php:1 060459 #: includes/class-emporiqa-admin.php:1237 398 460 msgid "Copied!" 399 461 msgstr "" 400 462 401 #: includes/class-emporiqa-admin.php:1 062463 #: includes/class-emporiqa-admin.php:1239 402 464 msgid "Initializing sync..." 403 465 msgstr "" 404 466 405 467 #. translators: %s: entity type (products/pages) 406 #: includes/class-emporiqa-admin.php:1 064468 #: includes/class-emporiqa-admin.php:1241 407 469 #, php-format 408 470 msgid "Started %s sync" … … 410 472 411 473 #. translators: 1: number of items, 2: number of events, 3: entity type 412 #: includes/class-emporiqa-admin.php:1 066474 #: includes/class-emporiqa-admin.php:1243 413 475 #, php-format 414 476 msgid "Processed %1$d items (%2$d events) for %3$s" … … 416 478 417 479 #. translators: %s: entity type 418 #: includes/class-emporiqa-admin.php:1 068480 #: includes/class-emporiqa-admin.php:1245 419 481 #, php-format 420 482 msgid "Batch failed for %s" 421 483 msgstr "" 422 484 485 #. translators: 1: entity type (products/pages), 2: seconds 486 #: includes/class-emporiqa-admin.php:1247 487 #, php-format 488 msgid "Emporiqa is busy and asked to wait. Sending this %1$s batch again in %2$d seconds." 489 msgstr "" 490 423 491 #. translators: %s: entity type (products/pages) 424 #: includes/class-emporiqa-admin.php:1 070492 #: includes/class-emporiqa-admin.php:1249 425 493 #, php-format 426 494 msgid "Completed %s sync session" … … 428 496 429 497 #. translators: %s: entity type (products/pages) 430 #: includes/class-emporiqa-admin.php:1 072498 #: includes/class-emporiqa-admin.php:1251 431 499 #, php-format 432 500 msgid "Failed to complete %s session" … … 434 502 435 503 #. translators: 1: entity type (products/pages), 2: total count 436 #: includes/class-emporiqa-admin.php:1 074504 #: includes/class-emporiqa-admin.php:1253 437 505 #, php-format 438 506 msgid "Total %1$s synced: %2$d items" 439 507 msgstr "" 440 508 441 #: includes/class-emporiqa-admin.php:1 075509 #: includes/class-emporiqa-admin.php:1254 442 510 msgid "Sync completed successfully!" 443 511 msgstr "" 444 512 445 513 #. translators: %s: entity type (products/pages) 446 #: includes/class-emporiqa-admin.php:1 077514 #: includes/class-emporiqa-admin.php:1256 447 515 #, php-format 448 516 msgid "The %s sync session was not finalized because some batches failed: items missing from this sync were NOT removed from Emporiqa. Re-run the sync after resolving the errors." … … 450 518 451 519 #. translators: %s: entity type (products/pages) 452 #: includes/class-emporiqa-admin.php:1 079520 #: includes/class-emporiqa-admin.php:1258 453 521 #, php-format 454 522 msgid "No %s were synced, so the session was not finalized." 455 523 msgstr "" 456 524 457 #: includes/class-emporiqa-admin.php:1 080525 #: includes/class-emporiqa-admin.php:1259 458 526 msgid "Sync finished with errors — see the log above. Re-run the sync after resolving them." 459 527 msgstr "" 460 528 461 529 #. translators: 1: Products link placeholder, 2: Pages link placeholder 462 #: includes/class-emporiqa-admin.php:1 082530 #: includes/class-emporiqa-admin.php:1261 463 531 #, php-format 464 532 msgid "Your data is now being processed by Emporiqa. This may take a few minutes before changes are fully reflected in the chat assistant. You can check the status on the %1$s or %2$s list in your Emporiqa dashboard." … … 466 534 467 535 #. translators: used as link text in sync log 468 #: includes/class-emporiqa-admin.php:1 086536 #: includes/class-emporiqa-admin.php:1265 469 537 msgid "Products" 470 538 msgstr "" 471 539 472 540 #. translators: used as link text in sync log 473 #: includes/class-emporiqa-admin.php:1 088541 #: includes/class-emporiqa-admin.php:1267 474 542 msgid "Pages" 475 543 msgstr "" 476 544 477 #: includes/class-emporiqa-admin.php:1 089545 #: includes/class-emporiqa-admin.php:1268 478 546 msgid "Sync cancelled." 479 547 msgstr "" 480 548 481 549 #. translators: %s: error message 482 #: includes/class-emporiqa-admin.php:1 091550 #: includes/class-emporiqa-admin.php:1270 483 551 #, php-format 484 552 msgid "Sync failed: %s" 485 553 msgstr "" 486 554 487 #: includes/class-emporiqa-admin.php:1 092555 #: includes/class-emporiqa-admin.php:1271 488 556 msgid "Failed to initialize sync." 489 557 msgstr "" 490 558 491 #: includes/class-emporiqa-admin.php:1 093559 #: includes/class-emporiqa-admin.php:1272 492 560 msgid "Sample product payload" 493 561 msgstr "" 494 562 495 #: includes/class-emporiqa-admin.php:1 094563 #: includes/class-emporiqa-admin.php:1273 496 564 msgid "Sample page payload" 497 565 msgstr "" 498 566 499 #: includes/class-emporiqa-admin.php:1 111567 #: includes/class-emporiqa-admin.php:1290 500 568 #: includes/class-emporiqa-sync.php:65 501 #: includes/class-emporiqa-sync.php:16 1502 #: includes/class-emporiqa-sync.php:20 1569 #: includes/class-emporiqa-sync.php:165 570 #: includes/class-emporiqa-sync.php:209 503 571 msgid "Permission denied." 504 572 msgstr "" 505 573 506 #: includes/class-emporiqa-cart-ajax.php:8 1507 #: includes/class-emporiqa-cart-ajax.php:19 0508 #: includes/class-emporiqa-cart-ajax.php:2 29574 #: includes/class-emporiqa-cart-ajax.php:89 575 #: includes/class-emporiqa-cart-ajax.php:198 576 #: includes/class-emporiqa-cart-ajax.php:237 509 577 msgid "No product ID provided." 510 578 msgstr "" 511 579 512 #: includes/class-emporiqa-cart-ajax.php:9 0580 #: includes/class-emporiqa-cart-ajax.php:98 513 581 msgid "Product not found." 514 582 msgstr "" 515 583 516 #: includes/class-emporiqa-cart-ajax.php: 95584 #: includes/class-emporiqa-cart-ajax.php:103 517 585 msgid "This product cannot be purchased." 518 586 msgstr "" 519 587 520 #: includes/class-emporiqa-cart-ajax.php: 98588 #: includes/class-emporiqa-cart-ajax.php:106 521 589 msgid "This product is currently out of stock." 522 590 msgstr "" 523 591 524 #: includes/class-emporiqa-cart-ajax.php:1 09592 #: includes/class-emporiqa-cart-ajax.php:117 525 593 msgid "Parent product not found." 526 594 msgstr "" 527 595 528 #: includes/class-emporiqa-cart-ajax.php:1 19596 #: includes/class-emporiqa-cart-ajax.php:127 529 597 msgid "Please select a product variation." 530 598 msgstr "" 531 599 532 #: includes/class-emporiqa-cart-ajax.php:1 28600 #: includes/class-emporiqa-cart-ajax.php:136 533 601 msgid "Variation not found." 534 602 msgstr "" 535 603 536 #: includes/class-emporiqa-cart-ajax.php:13 1604 #: includes/class-emporiqa-cart-ajax.php:139 537 605 msgid "This variation cannot be purchased." 538 606 msgstr "" 539 607 540 #: includes/class-emporiqa-cart-ajax.php:1 34608 #: includes/class-emporiqa-cart-ajax.php:142 541 609 msgid "This variation is currently out of stock." 542 610 msgstr "" 543 611 544 #: includes/class-emporiqa-cart-ajax.php:1 44612 #: includes/class-emporiqa-cart-ajax.php:152 545 613 msgid "Failed to add item to cart." 546 614 msgstr "" 547 615 548 #: includes/class-emporiqa-cart-ajax.php: 194616 #: includes/class-emporiqa-cart-ajax.php:202 549 617 msgid "Quantity must be greater than zero." 550 618 msgstr "" 551 619 552 #: includes/class-emporiqa-cart-ajax.php: 199553 #: includes/class-emporiqa-cart-ajax.php:2 34620 #: includes/class-emporiqa-cart-ajax.php:207 621 #: includes/class-emporiqa-cart-ajax.php:242 554 622 msgid "Item not found in cart." 555 623 msgstr "" 556 624 557 #: includes/class-emporiqa-cart-ajax.php:2 06625 #: includes/class-emporiqa-cart-ajax.php:214 558 626 msgid "Failed to update cart." 559 627 msgstr "" 560 628 561 #: includes/class-emporiqa-cart-ajax.php:24 1629 #: includes/class-emporiqa-cart-ajax.php:249 562 630 msgid "Failed to remove item." 563 631 msgstr "" 564 632 565 #: includes/class-emporiqa-cart-ajax.php:3 16633 #: includes/class-emporiqa-cart-ajax.php:326 566 634 msgid "Security check failed. Please refresh the page and try again." 567 635 msgstr "" 568 636 569 #: includes/class-emporiqa-connect.php:1 45637 #: includes/class-emporiqa-connect.php:160 570 638 msgid "Couldn't save the connection state. Please try again." 571 639 msgstr "" 572 640 573 #: includes/class-emporiqa-connect.php: 194641 #: includes/class-emporiqa-connect.php:209 574 642 msgid "Unexpected redirect source." 575 643 msgstr "" 576 644 577 #: includes/class-emporiqa-connect.php: 199645 #: includes/class-emporiqa-connect.php:214 578 646 msgid "Callback missing required parameters." 579 647 msgstr "" 580 648 581 #: includes/class-emporiqa-connect.php:2 07649 #: includes/class-emporiqa-connect.php:222 582 650 msgid "Connection link expired or already used. Click Connect to try again." 583 651 msgstr "" 584 652 585 653 #. translators: %s: WP HTTP API error message 586 #: includes/class-emporiqa-connect.php:2 37654 #: includes/class-emporiqa-connect.php:255 587 655 #, php-format 588 656 msgid "Couldn't reach Emporiqa: %s" … … 590 658 591 659 #. translators: %s: error code from the exchange endpoint 592 #: includes/class-emporiqa-connect.php:2 53660 #: includes/class-emporiqa-connect.php:278 593 661 #, php-format 594 662 msgid "Emporiqa refused the connection: %s" 595 663 msgstr "" 596 664 597 #: includes/class-emporiqa-formatter.php: 941598 #: includes/class-emporiqa-formatter.php: 952665 #: includes/class-emporiqa-formatter.php:869 666 #: includes/class-emporiqa-formatter.php:880 599 667 msgid "General" 600 668 msgstr "" 601 669 602 #: includes/class-emporiqa-formatter.php:1 021670 #: includes/class-emporiqa-formatter.php:1245 603 671 msgid "Generic" 604 672 msgstr "" 605 673 606 #: includes/class-emporiqa-order-tracking.php: 80674 #: includes/class-emporiqa-order-tracking.php:96 607 675 msgid "Missing signature." 608 676 msgstr "" 609 677 610 #: includes/class-emporiqa-order-tracking.php: 89611 #: includes/class-emporiqa-order-tracking.php: 99678 #: includes/class-emporiqa-order-tracking.php:105 679 #: includes/class-emporiqa-order-tracking.php:115 612 680 msgid "Invalid signature." 613 681 msgstr "" 614 682 615 #: includes/class-emporiqa-order-tracking.php:1 08683 #: includes/class-emporiqa-order-tracking.php:124 616 684 msgid "Invalid JSON in request body." 617 685 msgstr "" 618 686 619 #: includes/class-emporiqa-order-tracking.php:1 17687 #: includes/class-emporiqa-order-tracking.php:133 620 688 msgid "Request expired." 621 689 msgstr "" 622 690 623 #: includes/class-emporiqa-order-tracking.php:1 58691 #: includes/class-emporiqa-order-tracking.php:197 624 692 msgid "Invalid payload: order_identifier is required." 625 693 msgstr "" 626 694 627 #: includes/class-emporiqa-order-tracking.php:185 695 #: includes/class-emporiqa-order-tracking.php:205 696 #: includes/class-emporiqa-order-tracking.php:303 697 #: includes/class-emporiqa-order-tracking.php:312 698 msgid "Order not found." 699 msgstr "" 700 701 #: includes/class-emporiqa-order-tracking.php:230 628 702 msgid "Too many requests. Please try again later." 629 msgstr ""630 631 #: includes/class-emporiqa-order-tracking.php:251632 #: includes/class-emporiqa-order-tracking.php:260633 msgid "Order not found."634 703 msgstr "" 635 704 … … 642 711 msgstr "" 643 712 644 #: includes/class-emporiqa-sync.php:2 17713 #: includes/class-emporiqa-sync.php:225 645 714 msgid "Unknown or expired sync session — it was not finalized. Re-run the sync." 646 715 msgstr "" 647 716 648 717 #. translators: 1: number of items that failed, 2: entity type (products or pages). 649 #: includes/class-emporiqa-sync.php:2 28718 #: includes/class-emporiqa-sync.php:236 650 719 #, php-format 651 720 msgid "The %2$s sync session was not finalized because %1$d item(s) failed to sync: items missing from this sync were NOT removed from Emporiqa. Re-run the sync after resolving the errors." 652 721 msgstr "" 653 722 654 #: includes/class-emporiqa-sync.php:240 723 #. translators: %s: entity type (products or pages). 724 #: includes/class-emporiqa-sync.php:250 725 #, php-format 726 msgid "The %s sync session was not finalized because Emporiqa asked to wait and a batch was not sent again: items missing from this sync were NOT removed from Emporiqa. Re-run the sync." 727 msgstr "" 728 729 #: includes/class-emporiqa-sync.php:261 655 730 msgid "No items were synced in this session, so it was not finalized (finalizing an empty session would delete the entire remote catalog)." 656 731 msgstr "" 657 732 658 #: includes/class-emporiqa-sync.php:2 49733 #: includes/class-emporiqa-sync.php:270 659 734 msgid "Session completed." 660 735 msgstr "" 661 736 662 #: includes/class-emporiqa-sync.php:2 52737 #: includes/class-emporiqa-sync.php:273 663 738 msgid "Failed to complete sync session." 664 739 msgstr "" 665 740 666 #: includes/class-emporiqa-webhook.php:9 15741 #: includes/class-emporiqa-webhook.php:989 667 742 msgid "Unknown error" 668 743 msgstr "" 669 744 670 #: includes/class-emporiqa-webhook.php:10 00745 #: includes/class-emporiqa-webhook.php:1074 671 746 msgid "Store ID is not configured." 672 747 msgstr "" 673 748 674 #: includes/class-emporiqa-webhook.php:10 09749 #: includes/class-emporiqa-webhook.php:1083 675 750 msgid "Connection Secret is not configured." 676 751 msgstr "" 677 752 678 #: includes/class-emporiqa-webhook.php:10 18753 #: includes/class-emporiqa-webhook.php:1092 679 754 msgid "No published products found. Add a product before testing." 680 755 msgstr "" 681 756 682 #: includes/class-emporiqa-webhook.php:1 027757 #: includes/class-emporiqa-webhook.php:1101 683 758 msgid "Failed to format product for dry run." 684 759 msgstr "" 685 760 686 #: includes/class-emporiqa-webhook.php:1058 761 #. translators: %d: number of minutes 762 #: includes/class-emporiqa-webhook.php:1123 763 #, php-format 764 msgid "This server's clock is %d minutes off. Ask your host to turn on time synchronisation (NTP): Emporiqa refuses data sent with a clock more than 5 minutes off." 765 msgstr "" 766 767 #: includes/class-emporiqa-webhook.php:1149 687 768 msgid "Connection successful, but there are warnings." 688 769 msgstr "" 689 770 690 #: includes/class-emporiqa-webhook.php:1 059771 #: includes/class-emporiqa-webhook.php:1150 691 772 msgid "Connection successful! All fields validated." 692 773 msgstr "" 693 774 694 775 #. translators: %s: human-readable error message from the Emporiqa server 695 #: includes/class-emporiqa-webhook.php:1 067776 #: includes/class-emporiqa-webhook.php:1158 696 777 #, php-format 697 778 msgid "Connection failed: %s" 698 779 msgstr "" 699 780 700 #: includes/class-emporiqa-webhook.php:1 174781 #: includes/class-emporiqa-webhook.php:1249 701 782 msgid "Webhook not configured (missing Store ID)." 702 783 msgstr "" 703 784 704 #: includes/class-emporiqa-webhook.php:1 183785 #: includes/class-emporiqa-webhook.php:1258 705 786 msgid "Connection Secret is not configured. Go to Settings > Emporiqa to set it." 706 787 msgstr "" 707 788 708 #: includes/class-emporiqa-webhook.php:1 196789 #: includes/class-emporiqa-webhook.php:1271 709 790 msgid "Failed to encode payload as JSON." 710 791 msgstr "" 711 792 712 793 #. translators: %d: HTTP status code 713 #: includes/class-emporiqa-webhook.php:1 249794 #: includes/class-emporiqa-webhook.php:1323 714 795 #, php-format 715 796 msgid "Unexpected status code: %d" -
emporiqa/tags/1.2.0/readme.txt
r3725261 r3730094 2 2 Contributors: rosenhristov1 3 3 Tags: ai chatbot, live chat, woocommerce chatbot, ecommerce chatbot, ai customer support 4 Requires at least: 6. 04 Requires at least: 6.2 5 5 Tested up to: 7.1 6 6 Requires Plugins: woocommerce 7 7 Requires PHP: 7.4 8 8 WC requires at least: 8.0 9 WC tested up to: 1 0.910 Stable tag: 1. 1.109 WC tested up to: 11.1 10 Stable tag: 1.2.0 11 11 License: GPLv2 or later 12 12 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 54 54 Price, stock and status changes reach Emporiqa as you make them; a few edits need one Sync All afterwards, which the [documentation](https://emporiqa.com/docs/woocommerce/) covers. The same chatbot also runs on Drupal Commerce, Magento, PrestaShop, Shopware and Sylius. 55 55 56 = Privacy and security notes = 57 58 The signed-in shopper's token is fetched from `admin-ajax.php?action=emporiqa_customer_token`. It is read-only and returns only the current session's own identity token. It cannot be nonce-protected, because the page that would carry the nonce is full-page cached; cross-site use is blocked instead by a required custom header (which forces a CORS preflight) and a Sec-Fetch-Site / Origin check, and the answer is never cached. 59 56 60 = Third-Party Service = 57 61 58 This plugin requires an [Emporiqa](https://emporiqa.com) account and sends data there. Your catalog and synced pages go to `https://emporiqa.com/webhooks/sync/`, signed with HMAC-SHA256; the widget script loads from `https://emporiqa.com/chat/embed/`; connecting uses a PKCE handshake via `https://emporiqa.com/connect/start`. What a shopper types, and any photo uploaded for visual search, is processed by Emporiqa's AI provider and never trains AI models. If you switch on voice mode, a shopper's spoken message goes to that provider to be turned into text and the reply is read aloud by an AI voice; Emporiqa does not keep the recording. Emporiqa may call your REST API for an order status. [Terms of Service](https://emporiqa.com/terms-of-service/), [Privacy Policy](https://emporiqa.com/privacy-policy/).62 This plugin requires an [Emporiqa](https://emporiqa.com) account and sends data there. Your catalog and synced pages go to `https://emporiqa.com/webhooks/sync/`, signed with HMAC-SHA256; the widget script loads from `https://emporiqa.com/chat/embed/`; connecting uses a PKCE handshake via `https://emporiqa.com/connect/start`. What a shopper types, and any photo uploaded for visual search, is processed by Emporiqa's AI provider and never trains AI models. If you switch on voice mode, a shopper's spoken message goes to that provider to be turned into text and the reply is read aloud by an AI voice; Emporiqa does not keep the recording. Sync requests carry the plugin version (`X-Emporiqa-Plugin-Version`). If you add the Order status rule, Emporiqa calls this site's `/wp-json/emporiqa/v1/actions/order-status` with a signed request to look up the order a shopper asks about; the older order tracking address `/wp-json/emporiqa/api/order/tracking` keeps working while you use it. When a shopper is signed in to your store, the chat sends Emporiqa their WordPress user ID (signed, never their name or email), so Order status can find their own orders without an email and `/wp-json/emporiqa/v1/actions/customer-prices` can answer with the prices your shop gives that customer. Emporiqa also calls `/wp-json/emporiqa/v1/actions/verify` to check the address is yours. Each of these answers only a request signed with your store's connection secret (HMAC-SHA256, within 5 minutes), checked by the route's permission callback; the one unsigned call is the connect check, answered only during a connection you started in the last 2 minutes. [Terms of Service](https://emporiqa.com/terms-of-service/), [Privacy Policy](https://emporiqa.com/privacy-policy/). 59 63 60 64 == Installation == … … 120 124 7. Order tracking: the chat asks for an order number and matching email 121 125 8. Dashboard (demo store data): chat status, spend, revenue from chat 122 9. Billing: pay-as-you-go , $0.00 a month, $0.25 a conversation, $59 cap126 9. Billing: pay-as-you-go at $0.25 a conversation, capped at $59 a month, with the next billing date 123 127 10. Voice mode, if you switch it on: the answer is read aloud in an AI voice while the product cards stay on screen 124 128 125 129 == Changelog == 130 131 = 1.2.0 = 132 * New: Order status, a ready-made rule. Shoppers ask "where is my order?" and the chat answers from your WooCommerce orders: status, order date and tracking numbers (WooCommerce Shipment Tracking and Advanced Shipment Tracking are read when installed). A signed-in shopper only gives the order number; a guest also gives the order's email. Settings > Emporiqa shows the Order status address to copy into the rule in Emporiqa, and whether the rule is on. 133 * Changed: a signed-in shopper's identity is no longer written into the page or the chat script's URL, where a full-page cache could hand it to another shopper and server logs could keep it. The chat asks for it when it opens, from an uncached request. 134 * Changed: the old order tracking keeps working as before and stays on. Where ready-made rules are offered it moves under Advanced, with how to switch it off once the Order status rule replaces it. 135 * Changed: sync requests are also signed with Emporiqa's newer per-purpose keys and carry the plugin version; connecting proves the request came from your site; a connection Emporiqa holds for an owner's approval shows Emporiqa's own explanation. 136 * Changed: prices are sent as your shop shows them. A shop that enters prices without tax but shows them with tax (or the reverse) used to send the entered amount; prices with and without tax are now both sent when taxes are on. 137 * Fix: prices are always synced as a signed-out visitor sees them, with tax at your shop's address. A role, B2B or wholesale pricing plugin could otherwise send the price of whoever saved the product or ran the sync as the public price. The chat's cart now shows each item's price with or without tax as your cart page does. 138 * Fix: Sync All pages through products and pages by id, so a product changed during a sync can no longer be skipped and then removed from Emporiqa. 139 * Fix: an order conversion that Emporiqa could not receive after every retry is sent again at the order's next paid status. 140 * Changed: a language you did not tick under Languages (Polylang or WPML) is neither synced nor offered: its pages show no chat. 141 * New: customer prices. Emporiqa can ask this site for a signed-in shopper's own prices (`actions/customer-prices`), read by WooCommerce as that shopper with tax for their address. WooCommerce has one price per product, so this is your public price unless a role, B2B or wholesale pricing plugin gives that customer another; only prices are returned, and products the shopper cannot see are left out. 142 * New: quantity price tiers. The Tiered Price Table plugin's tiers are sent when it is active, and the `emporiqa_price_tiers` filter lets any other quantity-pricing plugin send its own (WooCommerce has none of its own); each tier is the unit price the cart charges, with tax as your shop shows prices. 143 * Changed: the signed-in shopper's token request requires a custom header and refuses cross-site requests; guests no longer reach it at all. 144 * Fix: the old order tracking reads a typed number only as an order number, never as another order's internal id. 145 * Test connection warns when this server's clock is more than 2 minutes off. 146 * Changed: a variation is sent without the description, categories and brand it shares with its product, and Emporiqa reads them from the product, so syncs send less. 147 * Fix: moving a variable product to the Trash now removes its variations from Emporiqa straight away, not only at the next Sync All. 148 * Fix: on a page served from a full-page cache long enough for its security token to expire, the chat's first add to cart no longer fails with "Security check failed"; it is retried once with a fresh token. 149 * Fix: when Emporiqa is busy and asks a sync to slow down, Sync All and the WP-CLI sync wait as asked and send the same batch again, instead of failing the sync and blocking the next one for an hour. 150 * Fix: the chat's cart actions now refuse requests sent from another website, and a guest's cart security token is tied to that guest's own cart. 151 * Fix: the Mini-Cart block of block themes (such as Twenty Twenty-Five) now updates when the chat adds or removes items; it used to show the old count until the page was reloaded. 152 * Changed: requires WordPress 6.2, the oldest version WooCommerce 8.0 runs on. The signed-in shopper's token answer is marked no-store and private on every supported WordPress version. 126 153 127 154 = 1.1.10 = … … 130 157 * Text only. No functional changes. 131 158 132 = 1.1.9 =133 * Fix: a full sync now finalizes only when every batch succeeds, so a failed batch can no longer drop items still present in your store from the chat's index. Nothing in your WooCommerce store was ever modified.134 * Fix: variable products with parent-managed stock no longer over-report quantity, and decimal quantities are no longer truncated in the cart.135 136 159 Older entries are in changelog.txt, shipped with the plugin. 137 160 138 161 == Upgrade Notice == 139 162 163 = 1.2.0 = 164 Adds the Order status rule, and keeps a signed-in shopper's identity out of cached pages. Old order tracking keeps working; nothing to do after updating. 165 140 166 = 1.1.10 = 141 167 Text-only update: the plugin's setup instructions now match the renamed Integration section of the Emporiqa dashboard. 142 143 = 1.1.9 =144 Important fix: a bulk sync that hit errors could remove valid products or pages from your Emporiqa catalog. Syncs now finalize only when every batch succeeds. Also fixes over-reported stock and truncated decimal quantities. -
emporiqa/tags/1.2.0/uninstall.php
r3546004 r3730094 16 16 delete_option( 'emporiqa_pending_async_events' ); 17 17 delete_option( 'emporiqa_instance_id' ); 18 delete_option( 'emporiqa_rules_status' ); 18 19 19 20 // Clean up retry transients + one-click connect handshake transients. … … 28 29 } 29 30 30 // Clean up dispatch payload transients (1.1.3+). 31 $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_emporiqa_event_%' OR option_name LIKE '_transient_timeout_emporiqa_event_%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 31 // Clean up dispatch payload transients (1.1.3+) and the Order status 32 // rate-limit and request-replay transients (1.2.0+). 33 $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_emporiqa_event_%' OR option_name LIKE '_transient_timeout_emporiqa_event_%' OR option_name LIKE '_transient_emporiqa_arl_%' OR option_name LIKE '_transient_timeout_emporiqa_arl_%' OR option_name LIKE '_transient_emporiqa_areq_%' OR option_name LIKE '_transient_timeout_emporiqa_areq_%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 32 34 33 35 // Clean up order meta. -
emporiqa/trunk/admin/css/emporiqa-admin.css
r3506790 r3730094 371 371 color: #1d2327; 372 372 } 373 374 /* Ready-made rules: On / Not added */ 375 .emporiqa-rule-state { 376 display: inline-block; 377 padding: 2px 8px; 378 border-radius: 3px; 379 background: #f0f0f1; 380 color: #50575e; 381 font-weight: 600; 382 vertical-align: middle; 383 } 384 385 .emporiqa-rule-state.emporiqa-rule-on { 386 background: #edfaef; 387 color: #00701a; 388 } -
emporiqa/trunk/admin/js/emporiqa-admin.js
r3601468 r3730094 12 12 var syncRunning = false; 13 13 var currentXhr = null; 14 15 // Consecutive waits for one batch before it counts as failed. 16 var MAX_BATCH_WAITS = 30; 14 17 15 18 /** … … 335 338 var totalItems = productCount + pageCount; 336 339 337 // Build work queue: for each session, calculate pages. 340 // Build work queue: one entry per session. Each entry is 341 // paged by id (after_id) until the server says it is done, 342 // so items changing mid-sync never shift the pages. 338 343 var workQueue = []; 339 344 var sessionCount = sessions.length; 340 var s , itemCount, totalPages, p;345 var s; 341 346 342 347 for ( s = 0; s < sessionCount; s++ ) { 343 itemCount = 0;344 if ( 'products' === sessions[ s ].entity ) {345 itemCount = productCount;346 } else if ( 'pages' === sessions[ s ].entity ) {347 itemCount = pageCount;348 }349 350 totalPages = Math.ceil( itemCount / itemsPerBatch );351 348 addLogEntry( 352 349 sprintf( … … 357 354 ); 358 355 359 for ( p = 1; p <= totalPages; p++ ) { 360 workQueue.push( 361 { 362 entity: sessions[ s ].entity, 363 language: sessions[ s ].language, 364 session_id: sessions[ s ].session_id, 365 page: p, 366 items_per_batch: itemsPerBatch 367 } 368 ); 369 } 356 workQueue.push( 357 { 358 entity: sessions[ s ].entity, 359 language: sessions[ s ].language, 360 session_id: sessions[ s ].session_id, 361 after_id: 0, 362 items_per_batch: itemsPerBatch, 363 waits: 0 364 } 365 ); 370 366 } 371 367 … … 401 397 402 398 var work = workQueue[ batchIndex ]; 403 batchIndex++;404 399 405 400 currentXhr = $.post( … … 411 406 language: work.language, 412 407 session_id: work.session_id, 413 page: work.page,408 after_id: work.after_id, 414 409 items_per_batch: work.items_per_batch 415 410 } 416 411 ).done( 417 412 function ( batchResponse ) { 413 var waitSeconds = batchResponse.success && batchResponse.data ? parseInt( batchResponse.data.retry_after, 10 ) || 0 : 0; 414 if ( waitSeconds > 0 && work.waits < MAX_BATCH_WAITS ) { 415 // Emporiqa asked to wait (its queue is full): send the 416 // same batch again; the server holds it as pending. 417 work.waits++; 418 addLogEntry( 419 sprintf( 420 i18n.batchWait || 'Emporiqa is busy and asked to wait. Sending this %1$s batch again in %2$d seconds.', 421 work.entity, 422 waitSeconds 423 ), 424 'warning' 425 ); 426 setTimeout( processBatch, waitSeconds * 1000 ); 427 return; 428 } 429 work.waits = 0; 430 418 431 if ( batchResponse.success && batchResponse.data ) { 419 432 var batchProcessed = batchResponse.data.processed || 0; … … 456 469 457 470 if ( totalItems > 0 ) { 458 updateProgress( ( processed / totalItems ) * 100 ); 471 updateProgress( Math.min( 99, ( processed / totalItems ) * 100 ) ); 472 } 473 474 var batchData = ( batchResponse.success && batchResponse.data ) || {}; 475 var lastId = parseInt( batchData.last_id, 10 ) || 0; 476 if ( batchData.done || lastId <= work.after_id ) { 477 batchIndex++; 478 } else { 479 work.after_id = lastId; 459 480 } 460 481 … … 475 496 ); 476 497 } 498 // Where the next batch starts is unknown, so the rest 499 // of this session is skipped; the failure above keeps 500 // the session from being finalized. 501 batchIndex++; 477 502 processBatch(); 478 503 } -
emporiqa/trunk/assets/js/emporiqa-cart.js
r3491860 r3730094 35 35 * Automatically updates the nonce from each response to keep 36 36 * long-lived browser sessions working beyond the default 24h expiry. 37 * 38 * @param {string} action The WordPress AJAX action name. 39 * @param {Object|null} data Additional key-value pairs for the request body. 37 * A nonce refusal is retried once with the fresh nonce it carries: a page 38 * served from a full-page cache can hold an expired nonce, or a guest's 39 * nonce for a signed-in shopper, and the refused request changed nothing. 40 * 41 * @param {string} action The WordPress AJAX action name. 42 * @param {Object|null} data Additional key-value pairs for the request body. 43 * @param {boolean} retried True on the retry after a nonce refusal. 40 44 * @return {Promise<Object>} Parsed JSON response matching the EmporiqaCartHandler spec. 41 45 */ 42 function emporiqaAjax( action, data ) {46 function emporiqaAjax( action, data, retried ) { 43 47 var formData = new FormData(); 44 48 formData.append( 'action', action ); … … 62 66 config.nonce = result.nonce; 63 67 } 64 return result; 65 } ); 66 } 67 68 /** 69 * Trigger WooCommerce mini-cart fragment refresh after cart changes. 70 */ 71 function refreshCartFragments() { 68 if ( ! retried && result && result.success === false && result.code === 'invalid_nonce' && result.nonce ) { 69 return emporiqaAjax( action, data, true ); 70 } 71 return result; 72 } ); 73 } 74 75 /** 76 * Tell the page's mini-cart that the cart changed. 77 * 78 * Classic themes refresh their cart fragments on wc_fragment_refresh. 79 * The Mini-Cart block of block themes ignores that and refetches the 80 * cart on wc-blocks_added_to_cart / wc-blocks_removed_from_cart. 81 * 82 * @param {boolean} removed True when items were removed (remove, clear). 83 */ 84 function refreshCartFragments( removed ) { 72 85 if ( typeof jQuery !== 'undefined' ) { 73 86 jQuery( document.body ).trigger( 'wc_fragment_refresh' ); 87 } 88 if ( typeof document !== 'undefined' && document.body && typeof window.Event === 'function' ) { 89 document.body.dispatchEvent( 90 new window.Event( removed ? 'wc-blocks_removed_from_cart' : 'wc-blocks_added_to_cart', { bubbles: true } ) 91 ); 74 92 } 75 93 } … … 153 171 return emporiqaAjax( 'emporiqa_remove_from_cart', data ).then( function( result ) { 154 172 if ( result.success ) { 155 refreshCartFragments( );173 refreshCartFragments( true ); 156 174 } 157 175 return result; … … 167 185 return emporiqaAjax( 'emporiqa_clear_cart' ).then( function( result ) { 168 186 if ( result.success ) { 169 refreshCartFragments( );187 refreshCartFragments( true ); 170 188 } 171 189 return result; -
emporiqa/trunk/changelog.txt
r3678094 r3730094 2 2 3 3 Older Emporiqa releases. The two most recent versions are in readme.txt. 4 5 = 1.1.9 = 6 * Fix: a full sync now finalizes only when every batch succeeds, so a failed batch can no longer drop items still present in your store from the chat's index. Nothing in your WooCommerce store was ever modified. 7 * Fix: variable products with parent-managed stock no longer over-report quantity, and decimal quantities are no longer truncated in the cart. 4 8 5 9 = 1.1.8 = -
emporiqa/trunk/cli/class-emporiqa-cli.php
r3601468 r3730094 89 89 $synced = 0; 90 90 $errors = 0; 91 $ page = 1;91 $after_id = 0; 92 92 $per_page = 100; 93 93 94 // Keyset by id, not OFFSET: a product disabled mid-sync must not 95 // shift the pages and get a live one skipped (and then deleted from 96 // Emporiqa when the session completes). 94 97 while ( true ) { 95 98 $query_args = array( 96 'status' => 'publish', 97 'limit' => $per_page, 98 'page' => $page, 99 'type' => Emporiqa_Admin::get_product_types(), 99 'status' => 'publish', 100 'limit' => $per_page, 101 'orderby' => 'ID', 102 'order' => 'ASC', 103 'type' => Emporiqa_Admin::get_product_types(), 104 'emporiqa_after_id' => $after_id, 100 105 ); 101 106 … … 113 118 } 114 119 115 $products = wc_get_products( $query_args ); 120 $products = Emporiqa_Sync::query_after_id( 121 function () use ( $query_args ) { 122 return wc_get_products( $query_args ); 123 } 124 ); 116 125 if ( empty( $products ) ) { 117 126 break; … … 119 128 120 129 foreach ( $products as $product ) { 130 $after_id = max( $after_id, (int) $product->get_id() ); 121 131 $formatted = $formatter->format_product_consolidated( $product, $session_id ); 122 132 foreach ( $formatted as $item ) { … … 142 152 143 153 wp_cache_flush(); 144 ++$page;145 154 } 146 155 … … 233 242 $synced = 0; 234 243 $errors = 0; 235 $ paged = 1;244 $after_id = 0; 236 245 $per_page = 100; 237 246 238 247 while ( true ) { 239 248 $query_args = array( 240 'post_type' => $post_types, 241 'post_status' => 'publish', 242 'posts_per_page' => $per_page, 243 'paged' => $paged, 244 'suppress_filters' => false, 249 'post_type' => $post_types, 250 'post_status' => 'publish', 251 'posts_per_page' => $per_page, 252 'orderby' => 'ID', 253 'order' => 'ASC', 254 'no_found_rows' => true, 255 'suppress_filters' => false, 256 'emporiqa_after_id' => $after_id, 245 257 ); 246 258 … … 252 264 } 253 265 254 $pages = get_posts( $query_args ); 266 $pages = Emporiqa_Sync::query_after_id( 267 function () use ( $query_args ) { 268 return get_posts( $query_args ); 269 } 270 ); 255 271 if ( empty( $pages ) ) { 256 272 break; … … 258 274 259 275 foreach ( $pages as $page ) { 276 $after_id = max( $after_id, (int) $page->ID ); 260 277 $formatted = $formatter->format_page_consolidated( $page, $session_id ); 261 278 if ( ! empty( $formatted ) ) { … … 281 298 282 299 wp_cache_flush(); 283 ++$paged;284 300 } 285 301 -
emporiqa/trunk/emporiqa.php
r3678232 r3730094 3 3 * Plugin Name: Emporiqa 4 4 * Description: Integrates WooCommerce with Emporiqa chat assistant. 5 * Version: 1. 1.105 * Version: 1.2.0 6 6 * Author: Emporiqa 7 7 * Author URI: https://emporiqa.com … … 10 10 * Text Domain: emporiqa 11 11 * Domain Path: /languages 12 * Requires at least: 6. 012 * Requires at least: 6.2 13 13 * Tested up to: 7.1 14 14 * Requires PHP: 7.4 15 15 * Requires Plugins: woocommerce 16 16 * WC requires at least: 8.0 17 * WC tested up to: 1 0.917 * WC tested up to: 11.1 18 18 * 19 19 * @package Emporiqa … … 25 25 } 26 26 27 define( 'EMPORIQA_VERSION', '1. 1.10' );27 define( 'EMPORIQA_VERSION', '1.2.0' ); 28 28 define( 'EMPORIQA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); 29 29 define( 'EMPORIQA_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); … … 36 36 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-widget.php'; 37 37 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-order-tracking.php'; 38 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-signature.php'; 39 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-order-status.php'; 40 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-customer-prices.php'; 41 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-actions.php'; 38 42 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-cart-ajax.php'; 39 43 require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-order-webhook.php'; … … 94 98 */ 95 99 private Emporiqa_Order_Tracking $order_tracking; 100 101 /** 102 * Ready-made rules endpoints (Order status, customer prices, verify). 103 * 104 * @var Emporiqa_Actions 105 */ 106 private Emporiqa_Actions $actions; 96 107 97 108 /** … … 206 217 $this->widget = new Emporiqa_Widget(); 207 218 $this->order_tracking = new Emporiqa_Order_Tracking(); 219 $this->actions = new Emporiqa_Actions(); 208 220 $this->cart_ajax = new Emporiqa_Cart_Ajax(); 209 221 $this->order_webhook = new Emporiqa_Order_Webhook(); … … 229 241 private function init_hooks(): void { 230 242 add_action( 'woocommerce_init', array( $this, 'init_woocommerce_hooks' ) ); 243 244 // Quantity tiers of the Tiered Price Table plugin, when it is active. 245 add_filter( 'emporiqa_price_tiers', array( 'Emporiqa_Formatter', 'tiered_price_table_tiers' ), 5, 2 ); 231 246 232 247 // Page-save entry point. Inside the handler we skip when Polylang is … … 269 284 add_action( 'woocommerce_new_product', array( $this, 'handle_product_created' ) ); 270 285 add_action( 'woocommerce_update_product', array( $this, 'handle_product_updated' ) ); 271 add_action( 'wp_trash_post', array( $this, 'handle_product_trashed' ) ); 286 // Before WooCommerce's own wp_trash_post (priority 10) trashes the 287 // variations, so the variation deletes can still be found and sent. 288 add_action( 'wp_trash_post', array( $this, 'handle_product_trashed' ), 5 ); 272 289 add_action( 'untrashed_post', array( $this, 'handle_product_untrashed' ) ); 273 290 add_action( 'woocommerce_new_product_variation', array( $this, 'handle_variation_created' ) ); … … 449 466 * wp_trash_post fires when a product is moved to trash. WooCommerce's 450 467 * woocommerce_update_product does NOT fire on trash, so this is needed. 451 * Cannot go through format_product_consolidated because WooCommerce 452 * trashes child variations before this hook fires at priority 10. 468 * Runs at priority 5, before WooCommerce trashes the child variations at 469 * priority 10, so queue_product_delete() still sees them and deletes 470 * them too; afterwards get_children() no longer returns them. 453 471 * 454 472 * @since 1.0.0 -
emporiqa/trunk/includes/class-emporiqa-admin.php
r3678094 r3730094 34 34 */ 35 35 private string $option_name = 'emporiqa_options'; 36 37 /** 38 * Option holding what Emporiqa last said about ready-made rules. 39 */ 40 public const RULES_STATUS_OPTION = 'emporiqa_rules_status'; 36 41 37 42 /** … … 156 161 } 157 162 163 // The checkbox is on the page only where ready-made rules are offered; 164 // elsewhere a save keeps the stored value (on by default). 165 if ( isset( $input['order_tracking_shown'] ) ) { 166 $sanitized['order_tracking'] = ! empty( $input['order_tracking'] ); 167 } else { 168 $existing = get_option( $this->option_name, array() ); 169 $sanitized['order_tracking'] = ! is_array( $existing ) || ! isset( $existing['order_tracking'] ) || (bool) $existing['order_tracking']; 170 } 171 158 172 $batch_size = absint( $input['batch_size'] ?? 25 ); 159 173 if ( $batch_size < 1 || $batch_size > 100 ) { … … 242 256 243 257 <?php $this->render_connection_section( $options ); ?> 258 <?php $this->render_rules_section( $options ); ?> 244 259 <?php $this->render_advanced_section( $options ); ?> 245 260 … … 338 353 } 339 354 340 $this->render_order_tracking_row(); 355 // Where ready-made rules are offered, order tracking moves under 356 // Advanced as the old way; elsewhere it stays here as before. 357 if ( ! self::rules_status()['available'] ) { 358 $this->render_order_tracking_row(); 359 } 341 360 $this->render_section_close(); 361 } 362 363 /** 364 * Render the Ready-made rules section: shown once Emporiqa has said, at 365 * connect or on Test connection, that this store has them. 366 * 367 * @since 1.2.0 368 * 369 * @param array $options Current options. 370 */ 371 private function render_rules_section( array $options ): void { 372 $status = self::rules_status(); 373 if ( ! $status['available'] ) { 374 return; 375 } 376 $live = in_array( 'order_status', $status['live'], true ); 377 $address = Emporiqa_Actions::base_url(); 378 $platform_base = rtrim( (string) apply_filters( 'emporiqa_connect_base_url', 'https://emporiqa.com' ), '/' ); 379 380 $this->render_section_open( 'emporiqa-section-rules', __( 'Ready-made rules', 'emporiqa' ), true ); 381 ?> 382 <p style="margin:0 0 12px 0;color:#50575e;"> 383 <?php esc_html_e( 'Rules tell the chat what to do when a shopper asks for something specific. The rule below reads your WooCommerce orders through this plugin. Click Open in Emporiqa to test it and switch it on there. Rules that do not need your shop, such as return requests, are added in Emporiqa under Settings > Rules.', 'emporiqa' ); ?> 384 </p> 385 <?php if ( $live && ! empty( $options['order_tracking'] ) ) : ?> 386 <div class="notice notice-info inline" style="margin:0 0 12px 0;"> 387 <p><?php esc_html_e( 'The Order status rule is on, so the old order tracking under Advanced is no longer needed. First remove its address in your Emporiqa dashboard (Settings > Integration > For your developer > Order tracking API URL), then untick Order tracking under Advanced and click Save Changes.', 'emporiqa' ); ?></p> 388 </div> 389 <?php endif; ?> 390 <table class="form-table" role="presentation"> 391 <tr> 392 <th scope="row"><label for="emporiqa_order_status_address"><?php esc_html_e( 'Order status address', 'emporiqa' ); ?></label></th> 393 <td> 394 <?php if ( '' !== $address ) : ?> 395 <div class="emporiqa-readonly-field"> 396 <input type="text" id="emporiqa_order_status_address" value="<?php echo esc_attr( $address ); ?>" class="regular-text" readonly /> 397 <button type="button" class="button emporiqa-copy-btn" data-copy-target="emporiqa_order_status_address"> 398 <?php esc_html_e( 'Copy', 'emporiqa' ); ?> 399 </button> 400 </div> 401 <p class="description"><?php esc_html_e( 'Emporiqa fills this in for you when you add the Order status rule. If it ever asks for the address, use this one.', 'emporiqa' ); ?></p> 402 <?php else : ?> 403 <p class="description"><?php esc_html_e( 'Your site is not served over https, so Emporiqa cannot call it. Switch the site to https (Settings > General), then come back here.', 'emporiqa' ); ?></p> 404 <?php endif; ?> 405 </td> 406 </tr> 407 <tr> 408 <th scope="row"><?php esc_html_e( 'Order status', 'emporiqa' ); ?></th> 409 <td> 410 <?php if ( $live ) : ?> 411 <span class="emporiqa-rule-state emporiqa-rule-on"><?php esc_html_e( 'On', 'emporiqa' ); ?></span> 412 <?php else : ?> 413 <span class="emporiqa-rule-state"><?php esc_html_e( 'Not added', 'emporiqa' ); ?></span> 414 <?php endif; ?> 415 <a href="<?php echo esc_url( $platform_base . '/platform/rules/?add=order_status' ); ?>" class="button" target="_blank" rel="noopener noreferrer" style="margin-left:8px;"> 416 <?php esc_html_e( 'Open in Emporiqa', 'emporiqa' ); ?> 417 <span class="dashicons dashicons-external" style="vertical-align:middle;font-size:14px;line-height:1;margin-left:2px;"></span> 418 </a> 419 <p class="description"> 420 <?php esc_html_e( 'Answers "Where is my order?" from your WooCommerce orders. A shopper signed in to your shop only gives the order number; a guest also gives the email used for the order.', 'emporiqa' ); ?> 421 </p> 422 <p class="description"> 423 <?php esc_html_e( 'On and Not added show what Emporiqa said at your last connect or Test connection (Sync tab). Run Test connection after changing a rule to refresh them.', 'emporiqa' ); ?> 424 </p> 425 </td> 426 </tr> 427 </table> 428 <?php 429 $this->render_section_close(); 430 } 431 432 /** 433 * The old order tracking under Advanced, where ready-made rules are 434 * offered: its address and an on/off checkbox, labelled as replaced. 435 * 436 * @param array $options Current options. 437 */ 438 private function render_legacy_order_tracking_rows( array $options ): void { 439 $tracking_url = rest_url( 'emporiqa/api/order/tracking' ); 440 ?> 441 <tr> 442 <th scope="row"><?php esc_html_e( 'Order tracking (old)', 'emporiqa' ); ?></th> 443 <td> 444 <input type="hidden" name="<?php echo esc_attr( $this->option_name ); ?>[order_tracking_shown]" value="1" /> 445 <label> 446 <input type="checkbox" name="<?php echo esc_attr( $this->option_name ); ?>[order_tracking]" value="1" 447 <?php checked( ! empty( $options['order_tracking'] ) ); ?> /> 448 <?php esc_html_e( 'Answer order lookups at the old order tracking address.', 'emporiqa' ); ?> 449 </label> 450 <div class="emporiqa-readonly-field" style="margin-top:8px;"> 451 <input type="text" id="order_tracking_url" value="<?php echo esc_attr( $tracking_url ); ?>" class="regular-text" readonly /> 452 <button type="button" class="button emporiqa-copy-btn" data-copy-target="order_tracking_url"> 453 <?php esc_html_e( 'Copy', 'emporiqa' ); ?> 454 </button> 455 </div> 456 <p class="description"> 457 <?php esc_html_e( 'Replaced by the Order status rule above. Leave it on while your Emporiqa dashboard still has this address. To switch it off, first remove the address in your Emporiqa dashboard (Settings > Integration > For your developer > Order tracking API URL), then untick this and click Save Changes.', 'emporiqa' ); ?> 458 </p> 459 </td> 460 </tr> 461 <?php 462 } 463 464 /** 465 * What Emporiqa last said about ready-made rules. 466 * 467 * @since 1.2.0 468 * 469 * @return array{available: bool, live: string[]} 470 */ 471 public static function rules_status(): array { 472 $stored = get_option( self::RULES_STATUS_OPTION, array() ); 473 $live = is_array( $stored ) && isset( $stored['live'] ) && is_array( $stored['live'] ) ? $stored['live'] : array(); 474 return array( 475 'available' => is_array( $stored ) && ! empty( $stored['available'] ), 476 'live' => array_values( array_filter( $live, 'is_string' ) ), 477 ); 478 } 479 480 /** 481 * Keep what Emporiqa said about ready-made rules: whether this store is 482 * offered them, and which are live. Both /connect/exchange and the Test 483 * connection dry run carry it; an answer without the key (an older 484 * platform) changes nothing. 485 * 486 * Kept in its own option, not in emporiqa_options, so saving the 487 * settings form never drops it. 488 * 489 * @since 1.2.0 490 * 491 * @param array $answer Decoded Emporiqa answer. 492 */ 493 public static function store_rules_status( array $answer ): void { 494 if ( ! array_key_exists( 'rules_available', $answer ) ) { 495 return; 496 } 497 $live = array(); 498 if ( isset( $answer['live_rules'] ) && is_array( $answer['live_rules'] ) ) { 499 foreach ( $answer['live_rules'] as $key ) { 500 if ( is_string( $key ) && preg_match( '/^[a-z_]{1,40}$/D', $key ) ) { 501 $live[] = $key; 502 } 503 } 504 } 505 $available = (bool) $answer['rules_available']; 506 update_option( 507 self::RULES_STATUS_OPTION, 508 array( 509 'available' => $available, 510 'live' => $available ? $live : array(), 511 ), 512 false 513 ); 342 514 } 343 515 … … 644 816 </td> 645 817 </tr> 818 <?php 819 if ( self::rules_status()['available'] ) { 820 $this->render_legacy_order_tracking_rows( $options ); 821 } 822 ?> 646 823 <tr> 647 824 <th scope="row"><label for="webhook_url"><?php esc_html_e( 'Emporiqa server URL', 'emporiqa' ); ?></label></th> … … 1067 1244 /* translators: %s: entity type */ 1068 1245 'batchFailed' => __( 'Batch failed for %s', 'emporiqa' ), 1246 /* translators: 1: entity type (products/pages), 2: seconds */ 1247 'batchWait' => __( 'Emporiqa is busy and asked to wait. Sending this %1$s batch again in %2$d seconds.', 'emporiqa' ), 1069 1248 /* translators: %s: entity type (products/pages) */ 1070 1249 'sessionComplete' => __( 'Completed %s sync session', 'emporiqa' ), … … 1184 1363 'page_post_types' => array( 'page' ), 1185 1364 'batch_size' => 25, 1365 'order_tracking' => true, 1186 1366 ); 1187 1367 } -
emporiqa/trunk/includes/class-emporiqa-cart-ajax.php
r3601468 r3730094 26 26 * Nonce action used for all cart AJAX requests. 27 27 * 28 * Starts with "woocommerce" because WooCommerce binds a guest's nonce to 29 * their cart session only for such actions; any other guest nonce is the 30 * same for every signed-out visitor. 31 * 28 32 * @since 1.0.0 29 33 * @var string 30 34 */ 31 private const NONCE_ACTION = 'emporiqa_cart_nonce'; 35 public const NONCE_ACTION = 'woocommerce-emporiqa-cart'; 36 37 /** 38 * Error code sent when a cart change comes from another site. 39 * 40 * @since 1.2.0 41 * @var string 42 */ 43 public const CROSS_SITE_CODE = 'cross_site'; 44 45 /** 46 * Error code sent when the nonce check fails. 47 * 48 * @since 1.2.0 49 * @var string 50 */ 51 public const INVALID_NONCE_CODE = 'invalid_nonce'; 32 52 33 53 /** … … 65 85 */ 66 86 public function add_to_cart(): void { 87 $this->refuse_cross_site(); 67 88 $this->verify_nonce(); 68 89 $this->ensure_cart_loaded(); … … 174 195 */ 175 196 public function update_cart(): void { 197 $this->refuse_cross_site(); 176 198 $this->verify_nonce(); 177 199 $this->ensure_cart_loaded(); … … 215 237 */ 216 238 public function remove_from_cart(): void { 239 $this->refuse_cross_site(); 217 240 $this->verify_nonce(); 218 241 $this->ensure_cart_loaded(); … … 248 271 */ 249 272 public function clear_cart(): void { 273 $this->refuse_cross_site(); 250 274 $this->verify_nonce(); 251 275 $this->ensure_cart_loaded(); … … 314 338 private function verify_nonce(): void { 315 339 if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) { 316 $this->send_error( __( 'Security check failed. Please refresh the page and try again.', 'emporiqa' ) ); 317 } 340 // The code lets emporiqa-cart.js retry once with the fresh nonce: 341 // a page served from a full-page cache can carry an expired one. 342 $this->send_error( __( 'Security check failed. Please refresh the page and try again.', 'emporiqa' ), self::INVALID_NONCE_CODE ); 343 } 344 } 345 346 /** 347 * Refuse a cart change that another site's page sent. 348 * 349 * @since 1.2.0 350 */ 351 private function refuse_cross_site(): void { 352 if ( self::is_cross_site( $_SERVER, array( home_url(), site_url() ) ) ) { 353 $this->send_error( __( 'Security check failed. Please refresh the page and try again.', 'emporiqa' ), self::CROSS_SITE_CODE ); 354 } 355 } 356 357 /** 358 * Whether a request says it came from another site: Sec-Fetch-Site (when 359 * the browser sends it) is neither same-origin nor none, or Origin (when 360 * sent) is not one of the site's own hosts. 361 * 362 * @since 1.2.0 363 * 364 * @param array $server $_SERVER. 365 * @param string[] $own_urls The site's own URLs (home_url(), site_url()). 366 * @return bool 367 */ 368 public static function is_cross_site( array $server, array $own_urls ): bool { 369 $fetch_site = isset( $server['HTTP_SEC_FETCH_SITE'] ) ? strtolower( (string) $server['HTTP_SEC_FETCH_SITE'] ) : ''; 370 if ( '' !== $fetch_site && 'same-origin' !== $fetch_site && 'none' !== $fetch_site ) { 371 return true; 372 } 373 $origin = isset( $server['HTTP_ORIGIN'] ) ? (string) $server['HTTP_ORIGIN'] : ''; 374 if ( '' === $origin ) { 375 return false; 376 } 377 $host = Emporiqa_Widget::host_of( $origin ); 378 return '' === $host || ! in_array( $host, array_map( array( 'Emporiqa_Widget', 'host_of' ), $own_urls ), true ); 318 379 } 319 380 … … 352 413 'name' => $product->get_name(), 353 414 'quantity' => self::parse_quantity( $item['quantity'] ), 354 'unit_price' => (float) $product->get_price(),415 'unit_price' => $this->displayed_unit_price( $product ), 355 416 'image_url' => $image_url ? $image_url : null, 356 417 'product_url' => get_permalink( $item['product_id'] ), … … 364 425 'currency' => emporiqa_get_active_currency(), 365 426 ); 427 } 428 429 /** 430 * A cart line's unit price as the cart page shows it. 431 * 432 * Same rule as WC_Cart::get_product_price(): including or excluding tax 433 * per the shop's "Display prices during cart and checkout" setting. Read 434 * as the shopper whose cart it is, at their tax address, because this is 435 * the price they will pay, unlike the synced catalogue price, which is a 436 * guest's. 437 * 438 * @since 1.2.0 439 * 440 * @param \WC_Product $product The cart line's product. 441 * @return float 442 */ 443 private function displayed_unit_price( \WC_Product $product ): float { 444 $price = WC()->cart->display_prices_including_tax() 445 ? wc_get_price_including_tax( $product ) 446 : wc_get_price_excluding_tax( $product ); 447 return round( (float) $price, wc_get_price_decimals() ); 366 448 } 367 449 … … 400 482 * 401 483 * @param string $message The error message. 402 */ 403 private function send_error( string $message ): void { 484 * @param string $code Machine-readable reason, '' when there is none. 485 */ 486 private function send_error( string $message, string $code = '' ): void { 404 487 wp_send_json( 405 488 array( 406 489 'success' => false, 407 490 'error' => $message, 491 'code' => $code, 408 492 'checkoutUrl' => $this->get_checkout_url(), 409 493 'unit_price' => 0, -
emporiqa/trunk/includes/class-emporiqa-connect.php
r3601468 r3730094 66 66 67 67 /** 68 * How long a claimed state stays "exchanging". Emporiqa proves the shop 69 * origin during /connect/exchange by asking actions/verify for 70 * HMAC(verifier, nonce), so the verifier must outlive the claim until the 71 * exchange returns; the TTL only bounds a request that never returned. 72 */ 73 private const EXCHANGING_TTL = 120; 74 75 /** 76 * /connect/exchange waits on the origin proof (at most 4 s on Emporiqa's 77 * side) and on the secret rotation; past this timeout the plugin would 78 * keep its old secret while Emporiqa already rotated to the new one. 79 */ 80 private const EXCHANGE_TIMEOUT = 15; 81 82 /** 68 83 * Constructor — wires the admin_init handler that routes 69 84 * ?emporiqa_action= requests. … … 214 229 $this->base_url() . '/connect/exchange', 215 230 array( 216 'timeout' => 10,231 'timeout' => self::EXCHANGE_TIMEOUT, 217 232 'headers' => array( 218 233 'Content-Type' => 'application/json', … … 222 237 'body' => wp_json_encode( 223 238 array( 224 'code' => $code, 225 'code_verifier' => $verifier, 226 'shop_origin' => $shop_origin, 239 'code' => $code, 240 'code_verifier' => $verifier, 241 'shop_origin' => $shop_origin, 242 'actions_base_url' => Emporiqa_Actions::base_url(), 227 243 ) 228 244 ), 229 245 ) 230 246 ); 247 // The exchange has returned: the origin proof can no longer be asked for. 248 $this->finish_nonce( $state ); 231 249 232 250 if ( is_wp_error( $resp ) ) { … … 244 262 $status = (int) wp_remote_retrieve_response_code( $resp ); 245 263 $body = json_decode( wp_remote_retrieve_body( $resp ), true ); 264 265 // A 409 carries a sentence for the merchant (a move or a reconnect 266 // waiting for an owner's email confirmation); show it as it is. 267 if ( 409 === $status && is_array( $body ) && ! empty( $body['error'] ) && is_string( $body['error'] ) ) { 268 $this->set_error( 'exchange_409', sanitize_text_field( $body['error'] ) ); 269 $this->redirect_to_settings(); 270 } 246 271 247 272 if ( 200 !== $status || ! is_array( $body ) || empty( $body['webhook_secret'] ) || empty( $body['store_id'] ) ) { … … 257 282 $this->redirect_to_settings(); 258 283 } 284 285 // Whether Emporiqa offers ready-made rules to this store, and which are live. 286 Emporiqa_Admin::store_rules_status( $body ); 259 287 260 288 $this->persist_credentials( … … 414 442 415 443 /** 416 * Atomically read + delete the state→verifier transient. Returns the 417 * verifier on hit, null on miss/expired/already-used. 444 * Claim a pending state for the exchange and return its verifier; null on 445 * miss, expiry or a state already claimed (a double callback cannot 446 * replay). The row is kept, marked exchanging, until finish_nonce(), so 447 * the origin proof can be answered while the exchange is in flight. 418 448 * 419 449 * @param string $state Public state from the callback. … … 422 452 $key = self::NONCE_PREFIX . hash( 'sha256', $state ); 423 453 $data = get_transient( $key ); 424 if ( ! is_array( $data ) || empty( $data['verifier'] ) ) {454 if ( ! is_array( $data ) || empty( $data['verifier'] ) || ! empty( $data['exchanging_at'] ) ) { 425 455 return null; 426 456 } 427 // Delete first so a double-callback can't replay. 428 delete_transient( $key ); 457 $data['exchanging_at'] = time(); 458 if ( ! set_transient( $key, $data, self::EXCHANGING_TTL ) ) { 459 return null; 460 } 429 461 return (string) $data['verifier']; 462 } 463 464 /** 465 * The verifier of an exchange in flight for $state, else null. Pending 466 * states never answer: only an exchange this site itself started can. 467 * 468 * @since 1.2.0 469 * 470 * @param string $state Public state sent by Emporiqa's origin proof. 471 * @return string|null 472 */ 473 public static function exchanging_verifier( string $state ): ?string { 474 $data = get_transient( self::NONCE_PREFIX . hash( 'sha256', $state ) ); 475 if ( ! is_array( $data ) || empty( $data['verifier'] ) || empty( $data['exchanging_at'] ) ) { 476 return null; 477 } 478 if ( time() - (int) $data['exchanging_at'] > self::EXCHANGING_TTL ) { 479 return null; 480 } 481 return (string) $data['verifier']; 482 } 483 484 /** 485 * Drop the state once the exchange has returned. 486 * 487 * @param string $state Public state from the callback. 488 */ 489 private function finish_nonce( string $state ): void { 490 delete_transient( self::NONCE_PREFIX . hash( 'sha256', $state ) ); 430 491 } 431 492 -
emporiqa/trunk/includes/class-emporiqa-formatter.php
r3601468 r3730094 39 39 */ 40 40 private array $excluded_post_ids = array(); 41 42 /** 43 * How many as_shopper() calls are running; above 0, prices are already read as a shopper. 44 * 45 * @var int 46 */ 47 private static int $shopper_depth = 0; 41 48 42 49 /** … … 416 423 $brand_data[ $channel ] = $this->get_product_brand( $product, $languages[0] ); 417 424 418 if ( $is_variable && ! empty( $published_variations ) ) { 419 // Representative price for the parent. Emporiqa derives the 420 // real "from–to" range from the variation events at read time, 421 // so this single value only needs the right currency and a 422 // sensible "from" price. Pick the cheapest variation and take 423 // BOTH its current and regular price from that same variation — 424 // mixing the min current price with another variation's regular 425 // price would fabricate a discount the customer can't actually 426 // get. 427 $representative = $published_variations[0]; 428 $min_price = null; 429 foreach ( $published_variations as $v ) { 430 $vp = $v->get_price(); 431 if ( '' === $vp ) { 432 continue; 433 } 434 $vp = (float) $vp; 435 if ( null === $min_price || $vp < $min_price ) { 436 $min_price = $vp; 437 $representative = $v; 438 } 439 } 440 $price = $min_price ?? 0.0; 441 $regular_price = '' !== $representative->get_regular_price() ? (float) $representative->get_regular_price() : $price; 442 } elseif ( $is_grouped ) { 443 // C2: Grouped products — the cheapest child's price. Derived 444 // from ALL children, not only in-stock ones, so an 445 // all-out-of-stock grouped product still reports its real 446 // lowest price instead of 0. current_price and regular_price 447 // are both taken from the SAME (cheapest) child so we never 448 // pair a low current price with a different child's higher 449 // regular price and fabricate a discount. Unlike variable 450 // products, grouped children sync as separate products, so 451 // Emporiqa does not range-derive the parent — this value is 452 // shown directly. 453 $price = null; 454 $regular_price = null; 455 foreach ( $grouped_children as $child ) { 456 $cp = $child->get_price(); 457 $child_price = '' !== $cp ? (float) $cp : null; 458 if ( null === $child_price ) { 459 continue; 460 } 461 if ( null === $price || $child_price < $price ) { 462 $price = $child_price; 463 $cr = $child->get_regular_price(); 464 $regular_price = '' !== $cr ? (float) $cr : $child_price; 465 } 466 } 467 $price = $price ?? 0.0; 468 $regular_price = $regular_price ?? $price; 469 } else { 470 $price = '' !== $product->get_price() ? (float) $product->get_price() : 0.0; 471 $regular_price = '' !== $product->get_regular_price() ? (float) $product->get_regular_price() : $price; 472 } 473 474 $price_entry = array( 475 'currency' => $currency, 476 'current_price' => $price, 477 'regular_price' => $regular_price, 425 // Prices are read as a guest at the shop's base location; see as_shopper(). 426 $price_entry = $this->as_shopper( 427 0, 428 fn() => $this->headline_entry( $product, $published_variations, $grouped_children, $currency ) 478 429 ); 479 430 … … 673 624 $var_images = array_values( array_unique( $var_images ) ); 674 625 675 $price = '' !== $variation->get_price() ? (float) $variation->get_price() : 0.0; 676 $regular_price = '' !== $variation->get_regular_price() ? (float) $variation->get_regular_price() : $price; 626 $price_entry = $this->price_entry( $variation, $currency ); 677 627 678 628 $names = array(); 679 $descriptions = array();680 629 $links = array(); 681 630 $attributes = array(); 682 $cat_data = array();683 $brand_data = array();684 631 $price_data = array(); 685 632 $avail_data = array(); … … 689 636 $max_qty_data = array(); 690 637 638 // Descriptions, categories and brand are not built here: Emporiqa stores a 639 // variation as a lean row and reads them from the parent. 691 640 foreach ( $channels as $channel ) { 692 $names[ $channel ] = array(); 693 $descriptions[ $channel ] = array(); 694 $links[ $channel ] = array(); 695 $attributes[ $channel ] = array(); 696 $cat_data[ $channel ] = array(); 641 $names[ $channel ] = array(); 642 $links[ $channel ] = array(); 643 $attributes[ $channel ] = array(); 697 644 698 645 foreach ( $languages as $lang ) { … … 705 652 $names[ $channel ][ $lang ] = $title; 706 653 707 $descriptions[ $channel ][ $lang ] = $this->get_translated_variation_description( $variation, $parent_product, $lang );708 709 654 $links[ $channel ][ $lang ] = $this->get_translated_link( $parent_product, $lang ); 710 655 711 656 $attributes[ $channel ][ $lang ] = empty( $clean_attributes ) ? new \stdClass() : $clean_attributes; 712 713 $cat_data[ $channel ][ $lang ] = $this->get_product_categories( $parent_product, $lang ); 714 } 715 716 $this->apply_translation_fallbacks( $cat_data[ $channel ], $languages ); 657 } 658 717 659 $this->apply_translation_fallbacks( $names[ $channel ], $languages ); 718 $this->apply_translation_fallbacks( $descriptions[ $channel ], $languages );719 660 $this->apply_translation_fallbacks( $links[ $channel ], $languages ); 720 661 721 $brand_data[ $channel ] = $this->get_product_brand( $parent_product, $languages[0] ); 722 723 $price_data[ $channel ] = array( 724 array( 725 'currency' => $currency, 726 'current_price' => $price, 727 'regular_price' => $regular_price, 728 ), 729 ); 662 $price_data[ $channel ] = array( $price_entry ); 730 663 731 664 $avail_data[ $channel ] = $this->get_availability_status( $variation ); … … 744 677 'channels' => $channels, 745 678 'names' => $names, 746 'descriptions' => $descriptions,747 679 'links' => $links, 748 680 'attributes' => $attributes, 749 'categories' => $cat_data,750 'brands' => $brand_data,751 681 'prices' => $price_data, 752 682 'availability_statuses' => $avail_data, … … 759 689 'is_virtual' => $variation->is_virtual(), 760 690 'parent_sku' => $parent_sku, 761 'is_parent' => false,762 'variation_attributes' => new \stdClass(),763 691 ); 764 692 … … 791 719 * @return array List of channel identifiers. 792 720 */ 793 p rivatefunction get_product_channels( \WC_Product $product ): array {721 public function get_product_channels( \WC_Product $product ): array { 794 722 /** 795 723 * Filter the product sales channels. … … 978 906 979 907 return implode( ' > ', $names ); 908 } 909 910 /** 911 * The `prices` entry for a product as a whole: its own price, or for a 912 * variable product its cheapest priced variation's and for a grouped 913 * product its cheapest priced child's (all children, so an all-out-of-stock 914 * group still shows its real lowest price). Current and regular price both 915 * come from that one item, so a low price is never paired with another 916 * item's higher regular price, which would fabricate a discount. Quantity 917 * tiers are kept only for the product's own price: a variation's or a 918 * child's tiers are not the parent's. Emporiqa shows a variable product's 919 * full range from its variations. 920 * 921 * @since 1.2.0 922 * 923 * @param \WC_Product $product The product. 924 * @param \WC_Product[] $variations Published variations (variable products). 925 * @param \WC_Product[] $children Children (grouped products). 926 * @param string $currency Currency code. 927 * @return array 928 */ 929 public function headline_entry( \WC_Product $product, array $variations, array $children, string $currency ): array { 930 if ( $product->is_type( 'variable' ) && ! empty( $variations ) ) { 931 $pool = $variations; 932 } elseif ( $product->is_type( 'grouped' ) ) { 933 $pool = $children; 934 } else { 935 return $this->price_entry( $product, $currency ); 936 } 937 938 $cheapest = null; 939 $lowest = null; 940 foreach ( $pool as $item ) { 941 $price = $item->get_price(); 942 if ( '' !== $price && ( null === $lowest || (float) $price < $lowest ) ) { 943 $lowest = (float) $price; 944 $cheapest = $item; 945 } 946 } 947 $entry = $this->price_entry( $cheapest, $currency ); 948 unset( $entry['tier_prices'] ); 949 return $entry; 950 } 951 952 /** 953 * Run price reads as one shopper: a signed-out visitor ($user_id 0) or a 954 * signed-in customer. 955 * 956 * A sync runs as whoever triggered it: the shop manager saving a product, 957 * a signed-in user whose request ran the queue, the WP-CLI user. Role, 958 * B2B, wholesale and dynamic pricing plugins filter the price getters by 959 * the current user, session or cart, and WooCommerce works out tax from 960 * the current customer's address, so without this a wholesale or staff 961 * price, or another country's tax, could be synced as the public price. 962 * 963 * For the duration of $read the current user is $user_id, WC()->customer 964 * is that customer (none for a guest, a copy read from the database, never 965 * a session, for a customer), WC()->session and WC()->cart are unset, and 966 * the tax location is fixed: $country when given, else the customer's own 967 * taxable address, else the shop's base address. Everything is put back 968 * afterwards, also when $read throws. Nested calls run $read as-is. 969 * 970 * @since 1.2.0 971 * 972 * @param int $user_id 0 for a guest, else a WordPress user id. 973 * @param callable $read Reads and returns the prices. 974 * @param string $country ISO country for tax, '' for the default. 975 * @return mixed What $read returns. 976 */ 977 public function as_shopper( int $user_id, callable $read, string $country = '' ) { 978 if ( self::$shopper_depth > 0 ) { 979 return $read(); 980 } 981 982 global $current_user; 983 $previous_user = $current_user; 984 $previous_user_id = get_current_user_id(); 985 $swap_user = $previous_user_id !== $user_id; 986 $wc = function_exists( 'WC' ) ? WC() : null; 987 $previous = $wc ? array( $wc->customer, $wc->session, $wc->cart ) : array(); 988 $location = null; 989 990 ++self::$shopper_depth; 991 try { 992 if ( $swap_user ) { 993 wp_set_current_user( $user_id ); 994 } 995 if ( $wc ) { 996 $wc->session = null; 997 $wc->cart = null; 998 $wc->customer = $user_id > 0 ? new \WC_Customer( $user_id ) : null; 999 1000 $fixed = $this->tax_location( $wc, $country ); 1001 if ( null !== $fixed ) { 1002 $location = static function () use ( $fixed ) { 1003 return $fixed; 1004 }; 1005 add_filter( 'woocommerce_get_tax_location', $location, PHP_INT_MAX ); 1006 } 1007 } 1008 return $read(); 1009 } finally { 1010 if ( null !== $location ) { 1011 remove_filter( 'woocommerce_get_tax_location', $location, PHP_INT_MAX ); 1012 } 1013 if ( $wc ) { 1014 list( $wc->customer, $wc->session, $wc->cart ) = $previous; 1015 } 1016 if ( $swap_user ) { 1017 wp_set_current_user( $previous_user_id ); 1018 $current_user = $previous_user; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Puts back the exact user object the caller had. 1019 } 1020 --self::$shopper_depth; 1021 } 1022 } 1023 1024 /** 1025 * The tax location as_shopper() fixes, null when WooCommerce knows no countries. 1026 * 1027 * @param object $wc WC(). 1028 * @param string $country ISO country, or ''. 1029 * @return array|null array( country, state, postcode, city ). 1030 */ 1031 private function tax_location( $wc, string $country ): ?array { 1032 if ( '' !== $country ) { 1033 return array( $country, '', '', '' ); 1034 } 1035 if ( $wc->customer ) { 1036 $own = $wc->customer->get_taxable_address(); 1037 if ( is_array( $own ) && ! empty( $own[0] ) ) { 1038 return $own; 1039 } 1040 } 1041 $countries = $wc->countries; 1042 if ( ! $countries ) { 1043 return null; 1044 } 1045 return array( $countries->get_base_country(), $countries->get_base_state(), $countries->get_base_postcode(), $countries->get_base_city() ); 1046 } 1047 1048 /** 1049 * One `prices` entry for a product or variation, as the shop shows it. 1050 * 1051 * Its current_price and regular_price follow the shop's "Display prices in 1052 * the shop" tax setting, so the chat quotes what the product page shows 1053 * even when prices are entered the other way (entered excluding tax, 1054 * shown including it, as many EU shops do). With taxes on and a taxable 1055 * product, price_incl_tax and price_excl_tax carry both, for the tax 1056 * location as_shopper() fixed. A missing price is 0, as before. Outside 1057 * as_shopper() it is read as a guest. 1058 * 1059 * Quantity tiers come from the `emporiqa_price_tiers` filter (WooCommerce 1060 * has none of its own): each tier is the unit price the cart charges at 1061 * that quantity, with tax as current_price, at full precision. 1062 * 1063 * @since 1.2.0 1064 * 1065 * @param \WC_Product|null $source The product or variation whose price is sent; null when none has one. 1066 * @param string $currency Currency code. 1067 * @return array 1068 */ 1069 public function price_entry( ?\WC_Product $source, string $currency ): array { 1070 if ( 0 === self::$shopper_depth ) { 1071 return $this->as_shopper( 0, fn() => $this->price_entry( $source, $currency ) ); 1072 } 1073 1074 $raw = $source ? (string) $source->get_price() : ''; 1075 $regular_raw = $source ? (string) $source->get_regular_price() : ''; 1076 if ( '' === $regular_raw ) { 1077 $regular_raw = $raw; 1078 } 1079 1080 $entry = array( 1081 'currency' => $currency, 1082 'current_price' => '' !== $raw ? (float) $raw : 0.0, 1083 'regular_price' => '' !== $regular_raw ? (float) $regular_raw : 0.0, 1084 ); 1085 if ( '' === $raw ) { 1086 return $entry; 1087 } 1088 1089 $taxed = function_exists( 'wc_tax_enabled' ) && wc_tax_enabled() && $source->is_taxable(); 1090 $display_incl = $taxed && 'incl' === get_option( 'woocommerce_tax_display_shop', 'excl' ); 1091 $shown = static function ( string $amount, int $qty ) use ( $source, $taxed, $display_incl ): float { 1092 if ( ! $taxed ) { 1093 return (float) $amount; 1094 } 1095 $args = array( 1096 'qty' => $qty, 1097 'price' => $amount, 1098 ); 1099 $line = $display_incl ? wc_get_price_including_tax( $source, $args ) : wc_get_price_excluding_tax( $source, $args ); 1100 return (float) $line / $qty; 1101 }; 1102 1103 if ( $taxed ) { 1104 $decimals = function_exists( 'wc_get_price_decimals' ) ? (int) wc_get_price_decimals() : 2; 1105 $one = array( 1106 'qty' => 1, 1107 'price' => $raw, 1108 ); 1109 1110 $entry['price_incl_tax'] = round( (float) wc_get_price_including_tax( $source, $one ), $decimals ); 1111 $entry['price_excl_tax'] = round( (float) wc_get_price_excluding_tax( $source, $one ), $decimals ); 1112 $entry['current_price'] = $display_incl ? $entry['price_incl_tax'] : $entry['price_excl_tax']; 1113 $entry['regular_price'] = round( $shown( $regular_raw, 1 ), $decimals ); 1114 } 1115 1116 $tiers = $this->price_tiers( $source, $shown ); 1117 if ( ! empty( $tiers ) ) { 1118 $entry['tier_prices'] = $tiers; 1119 } 1120 return $entry; 1121 } 1122 1123 /** 1124 * Quantity tiers from the `emporiqa_price_tiers` filter, as the cart 1125 * charges them: ascending, one per quantity, min_quantity 2 or more. 1126 * 1127 * @param \WC_Product $source The product or variation. 1128 * @param callable $shown ( string $unit_price, int $qty ): float, the unit price with tax as shown. 1129 * @return array<int, array{min_quantity: int, price: float}> 1130 */ 1131 private function price_tiers( \WC_Product $source, callable $shown ): array { 1132 /** 1133 * Filter the quantity price tiers of a product or variation. 1134 * 1135 * WooCommerce has no quantity pricing of its own; a plugin that adds it 1136 * returns its tiers here. Each tier is array( 'min_quantity' => int, 1137 * 'price' => unit price ), the price entered as the product's own price 1138 * is (with or without tax, per the shop's setting). Called while the 1139 * price is read as the shopper (a guest for the sync), so a role-aware 1140 * tier plugin answers for that shopper. 1141 * 1142 * @since 1.2.0 1143 * 1144 * @param array $tiers Tiers, empty by default. 1145 * @param \WC_Product $product The product or variation. 1146 */ 1147 $raw = apply_filters( 'emporiqa_price_tiers', array(), $source ); 1148 1149 $tiers = array(); 1150 foreach ( is_array( $raw ) ? $raw : array() as $tier ) { 1151 if ( ! is_array( $tier ) || ! isset( $tier['min_quantity'], $tier['price'] ) || ! is_numeric( $tier['min_quantity'] ) || ! is_numeric( $tier['price'] ) ) { 1152 continue; 1153 } 1154 $qty = (int) $tier['min_quantity']; 1155 $price = (float) $tier['price']; 1156 if ( $qty < 2 || $price <= 0 ) { 1157 continue; 1158 } 1159 $tiers[ $qty ] = array( 1160 'min_quantity' => $qty, 1161 'price' => $shown( (string) $price, $qty ), 1162 ); 1163 } 1164 ksort( $tiers ); 1165 return array_values( $tiers ); 1166 } 1167 1168 /** 1169 * The `emporiqa_price_tiers` answer for the Tiered Price Table plugin 1170 * (tier-pricing-table): each of its quantity rules as the unit price its 1171 * cart charges, unrounded and with tax as the shop enters prices, for the 1172 * shopper being read. The 1173 * pricing type is passed so its per-request rule cache, which keys by 1174 * product only, is not read: a rule cached for another shopper earlier in 1175 * the request (its role-based rules) must not be sent as this one's. 1176 * 1177 * @since 1.2.0 1178 * 1179 * @param array $tiers Tiers so far. 1180 * @param \WC_Product $product The product or variation. 1181 * @return array 1182 */ 1183 public static function tiered_price_table_tiers( $tiers, $product ): array { 1184 $tiers = is_array( $tiers ) ? $tiers : array(); 1185 $manager = '\\TierPricingTable\\PriceManager'; 1186 if ( ! $product instanceof \WC_Product || ! is_callable( array( $manager, 'getPricingRule' ) ) || ! is_callable( array( $manager, 'getPricingType' ) ) ) { 1187 return $tiers; 1188 } 1189 $id = $product->get_id(); 1190 $rule = call_user_func( array( $manager, 'getPricingRule' ), $id, call_user_func( array( $manager, 'getPricingType' ), $id ) ); 1191 if ( ! is_object( $rule ) || ! is_callable( array( $rule, 'getRules' ) ) || ! is_callable( array( $rule, 'getTierPrice' ) ) ) { 1192 return $tiers; 1193 } 1194 foreach ( array_keys( (array) $rule->getRules() ) as $qty ) { 1195 $price = $rule->getTierPrice( (int) $qty, false, 'shop', false ); 1196 if ( is_numeric( $price ) ) { 1197 $tiers[] = array( 1198 'min_quantity' => (int) $qty, 1199 'price' => (float) $price, 1200 ); 1201 } 1202 } 1203 return $tiers; 980 1204 } 981 1205 … … 1244 1468 1245 1469 /** 1246 * Get the translated variation description, falling back to parent description.1247 *1248 * @since 1.0.01249 *1250 * @param \WC_Product_Variation $variation The variation.1251 * @param \WC_Product $parent_product The parent product.1252 * @param string $language The target language code.1253 * @return string1254 */1255 private function get_translated_variation_description( \WC_Product_Variation $variation, \WC_Product $parent_product, string $language ): string {1256 $translated_parent_id = $this->get_translation_id( $parent_product->get_id(), $language );1257 1258 if ( $translated_parent_id && $translated_parent_id !== $parent_product->get_id() ) {1259 $translated_parent = wc_get_product( $translated_parent_id );1260 if ( $translated_parent && $translated_parent->is_type( 'variable' ) ) {1261 // Find the matching translated variation by SKU or attribute combination.1262 foreach ( $translated_parent->get_children() as $translated_var_id ) {1263 $translated_var = wc_get_product( $translated_var_id );1264 if ( ! $translated_var ) {1265 continue;1266 }1267 if ( $translated_var->get_sku() === $variation->get_sku() && '' !== $variation->get_sku() ) {1268 $desc = $translated_var->get_description();1269 if ( ! empty( $desc ) ) {1270 return $desc;1271 }1272 break;1273 }1274 }1275 // Fall back to translated parent description.1276 return $translated_parent->get_description();1277 }1278 }1279 1280 $var_desc = $variation->get_description();1281 return ! empty( $var_desc ) ? $var_desc : $parent_product->get_description();1282 }1283 1284 /**1285 1470 * Get the translated product title. 1286 1471 * -
emporiqa/trunk/includes/class-emporiqa-order-tracking.php
r3506790 r3730094 6 6 * order data. Requests are authenticated via HMAC-SHA256 using the same 7 7 * webhook secret configured in Settings > Emporiqa. 8 * 9 * Replaced by the Order status ready-made rule (class-emporiqa-actions.php) 10 * since 1.2.0, and kept working unchanged for stores that use it: same 11 * request, same answers. It stays on unless the merchant switches it off 12 * (option `order_tracking`, default on). 8 13 * 9 14 * @package Emporiqa … … 65 70 66 71 /** 72 * Whether the merchant left order tracking on (the default). 73 * 74 * @since 1.2.0 75 * 76 * @return bool 77 */ 78 public static function is_enabled(): bool { 79 return (bool) Emporiqa_Admin::get_option( 'order_tracking', true ); 80 } 81 82 /** 67 83 * Verify HMAC signature and timestamp for the incoming request. 68 84 * … … 143 159 */ 144 160 public function handle_request( WP_REST_Request $request ): WP_REST_Response { 161 if ( ! self::is_enabled() ) { 162 // Untranslated on purpose: Emporiqa reads this exact text as 163 // "switched off", not as an unknown order. 164 return new WP_REST_Response( array( 'error' => 'Order tracking is disabled.' ), 404 ); 165 } 166 167 try { 168 return $this->answer( $request ); 169 } catch ( \Throwable $e ) { 170 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { 171 error_log( '[Emporiqa] order tracking failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log 172 } 173 return new WP_REST_Response( array( 'error' => 'Internal error.' ), 500 ); 174 } 175 } 176 177 /** 178 * The answer to a signature-verified request. 179 * 180 * @param WP_REST_Request $request The REST request. 181 * @return WP_REST_Response 182 */ 183 private function answer( WP_REST_Request $request ): WP_REST_Response { 145 184 // H8: Rate limiting per IP. 146 185 $rate_check = $this->check_rate_limit(); … … 161 200 } 162 201 163 $order_identifier = sanitize_text_field( $payload['order_identifier'] ); 202 $order_identifier = is_scalar( $payload['order_identifier'] ) ? ltrim( sanitize_text_field( (string) $payload['order_identifier'] ), '#' ) : ''; 203 if ( ! preg_match( '/^[0-9A-Za-z][0-9A-Za-z\-]{0,63}$/D', $order_identifier ) ) { 204 return new WP_REST_Response( 205 array( 'error' => __( 'Order not found.', 'emporiqa' ) ), 206 404 207 ); 208 } 164 209 $raw_verification = $payload['verification_fields'] ?? array(); 165 210 $verification_fields = is_array( $raw_verification ) ? array_map( 'sanitize_text_field', $raw_verification ) : array(); … … 234 279 */ 235 280 private function lookup_order( string $order_identifier, array $verification_fields ): WP_REST_Response { 236 $order = wc_get_order( $order_identifier ); 281 // The typed value is an order number, never an internal id: "123" 282 // must not resolve to whichever order has id 123. 283 $order = Emporiqa_Order_Status::find_by_number( $order_identifier ); 237 284 238 285 // Also try searching by order number (custom order number plugins). … … 245 292 ); 246 293 $order = $orders[0] ?? null; 247 } 248 249 if ( ! $order ) { 294 // WooCommerce ignores a query var it does not know, and then 295 // returns the newest order: only an exact number match counts. 296 if ( $order && 0 !== strcasecmp( (string) $order->get_order_number(), $order_identifier ) ) { 297 $order = null; 298 } 299 } 300 301 if ( ! $order instanceof WC_Order ) { 250 302 return new WP_REST_Response( 251 303 array( 'error' => __( 'Order not found.', 'emporiqa' ) ), -
emporiqa/trunk/includes/class-emporiqa-order-webhook.php
r3549190 r3730094 256 256 $url, 257 257 array( 258 'headers' => array( 259 'Content-Type' => 'application/json', 260 'X-Webhook-Signature' => hash_hmac( 'sha256', $payload, $secret ), 261 ), 258 'headers' => Emporiqa_Webhook::signed_headers( $payload ), 262 259 'body' => $payload, 263 260 'timeout' => 5, … … 340 337 $this->log( "order.completed webhook failed for order #{$order_id}. Retry #{$attempt} in {$delay}s." ); 341 338 } else { 339 // Not marked as sent, so the order's next paid status 340 // (processing to completed, say) tries once more. 341 $order->delete_meta_data( self::TRACKED_META_KEY ); 342 $order->save(); 342 343 $this->log( "order.completed webhook permanently failed for order #{$order_id} after {$attempt} attempt(s)." ); 343 344 } -
emporiqa/trunk/includes/class-emporiqa-sync.php
r3601468 r3730094 150 150 * AJAX handler: process a single batch of items. 151 151 * 152 * Queries N items for the given page, formats them, and sends them 153 * via the webhook. 152 * Queries the next N items after `after_id` (keyset, by id), formats 153 * them, and sends them via the webhook. The answer's `last_id` is the 154 * next call's `after_id`, and `done` says there is nothing after it. 155 * Paging by id instead of OFFSET means an item disabled or added 156 * mid-sync cannot shift the pages and make a live item skipped, which 157 * the session's completion would then delete from Emporiqa. 154 158 * 155 159 * @since 1.0.0 … … 164 168 $entity = sanitize_text_field( wp_unslash( $_POST['entity'] ?? '' ) ); 165 169 $session_id = sanitize_text_field( wp_unslash( $_POST['session_id'] ?? '' ) ); 166 $ page = absint( $_POST['page'] ?? 1);170 $after_id = absint( $_POST['after_id'] ?? 0 ); 167 171 $items_per_batch = absint( $_POST['items_per_batch'] ?? self::ITEMS_PER_BATCH ); 168 172 … … 175 179 'events' => 0, 176 180 'success' => true, 181 'last_id' => $after_id, 182 'done' => true, 177 183 ); 178 184 179 185 if ( 'products' === $entity ) { 180 $result = $this->process_product_batch( $session_id, $ page, $items_per_batch );186 $result = $this->process_product_batch( $session_id, $after_id, $items_per_batch ); 181 187 } elseif ( 'pages' === $entity ) { 182 $result = $this->process_page_batch( $session_id, $ page, $items_per_batch );188 $result = $this->process_page_batch( $session_id, $after_id, $items_per_batch ); 183 189 } 184 190 185 191 // A failed batch reclassifies all of its items as errors — the 186 // per-session counters drive the completion guard below. 187 $this->record_batch_result( $session_id, $result['processed'], $result['success'] ); 192 // per-session counters drive the completion guard below. A batch 193 // Emporiqa asked to wait for is held as pending instead: the browser 194 // sends the same batch again after the wait. 195 $this->record_batch_result( $session_id, $result['processed'], $result['success'], $after_id, isset( $result['retry_after'] ) ); 188 196 189 197 wp_send_json_success( $result ); … … 235 243 } 236 244 245 if ( ! empty( $state['pending'] ) ) { 246 wp_send_json_error( 247 array( 248 'message' => sprintf( 249 /* translators: %s: entity type (products or pages). */ 250 __( 'The %s sync session was not finalized because Emporiqa asked to wait and a batch was not sent again: items missing from this sync were NOT removed from Emporiqa. Re-run the sync.', 'emporiqa' ), 251 $entity 252 ), 253 'skipped' => true, 254 ) 255 ); 256 } 257 237 258 if ( $state['synced'] < 1 ) { 238 259 wp_send_json_error( … … 283 304 * Mirrors the Drupal reference: a failed batch send reclassifies the 284 305 * whole batch's items as errors (no partial accounting inside a batch). 306 * A batch Emporiqa asked to wait for (429) is held as pending under the 307 * id it starts after, and released when that batch is sent; completion 308 * is refused while any is pending. 285 309 * 286 310 * @since 1.1.9 … … 289 313 * @param int $processed Items processed in this batch. 290 314 * @param bool $success Whether the batch send succeeded. 291 */ 292 private function record_batch_result( string $session_id, int $processed, bool $success ): void { 315 * @param int $after_id Id the batch starts after. 316 * @param bool $wait Whether Emporiqa asked to wait and resend. 317 */ 318 private function record_batch_result( string $session_id, int $processed, bool $success, int $after_id = 0, bool $wait = false ): void { 293 319 $state = $this->get_session_state( $session_id ); 294 320 if ( ! is_array( $state ) ) { … … 296 322 } 297 323 324 $pending = isset( $state['pending'] ) && is_array( $state['pending'] ) ? $state['pending'] : array(); 325 unset( $pending[ (string) $after_id ] ); 326 if ( $wait ) { 327 $pending[ (string) $after_id ] = true; 328 } 329 $state['pending'] = $pending; 330 298 331 if ( $success ) { 299 332 $state['synced'] += $processed; 300 } else {333 } elseif ( ! $wait ) { 301 334 $state['errors'] += max( 1, $processed ); 302 335 } … … 422 455 * 423 456 * @param string $session_id Sync session ID. 424 * @param int $ page Page number (1-based).425 * @param int $items_per_batch Items per page.426 * @return array{processed: int, events: int, success: bool }427 */ 428 private function process_product_batch( string $session_id, int $ page, int $items_per_batch ): array {457 * @param int $after_id Only items with a larger id. 458 * @param int $items_per_batch Items per batch. 459 * @return array{processed: int, events: int, success: bool, last_id: int, done: bool, error?: string, retry_after?: int} 460 */ 461 private function process_product_batch( string $session_id, int $after_id, int $items_per_batch ): array { 429 462 $formatter = emporiqa()->get_formatter(); 430 463 $webhook = emporiqa()->get_webhook(); 431 464 432 465 $query_args = array( 433 'status' => 'publish', 434 'limit' => $items_per_batch, 435 'page' => $page, 436 'type' => Emporiqa_Admin::get_product_types(), 466 'status' => 'publish', 467 'limit' => $items_per_batch, 468 'orderby' => 'ID', 469 'order' => 'ASC', 470 'type' => Emporiqa_Admin::get_product_types(), 437 471 ); 438 472 … … 450 484 } 451 485 452 $products = wc_get_products( $query_args ); 486 $query_args['emporiqa_after_id'] = $after_id; 487 488 $products = self::query_after_id( 489 function () use ( $query_args ) { 490 return wc_get_products( $query_args ); 491 } 492 ); 453 493 $events = array(); 454 494 $processed = count( $products ); 495 $last_id = $after_id; 455 496 456 497 foreach ( $products as $product ) { 498 $last_id = max( $last_id, (int) $product->get_id() ); 457 499 $formatted = $formatter->format_product_consolidated( $product, $session_id ); 458 500 foreach ( $formatted as $item ) { … … 464 506 } 465 507 466 $success = true; 467 $error = null; 508 $success = true; 509 $error = null; 510 $retry_after = null; 468 511 if ( ! empty( $events ) ) { 469 512 $success = $webhook->send_batch_events( $events ); 470 513 if ( ! $success ) { 471 $error = $webhook->get_last_error(); 514 $error = $webhook->get_last_error(); 515 $retry_after = $webhook->get_last_retry_after(); 472 516 } 473 517 } … … 477 521 'events' => count( $events ), 478 522 'success' => $success, 523 'last_id' => $last_id, 524 'done' => $processed < $items_per_batch, 479 525 ); 480 526 if ( ! $success && $error ) { 481 527 $out['error'] = $error; 482 528 } 529 if ( null !== $retry_after ) { 530 $out['retry_after'] = $retry_after; 531 } 483 532 484 533 return $out; … … 491 540 * 492 541 * @param string $session_id Sync session ID. 493 * @param int $ page Page number (1-based).494 * @param int $items_per_batch Items per page.495 * @return array{processed: int, events: int, success: bool }496 */ 497 private function process_page_batch( string $session_id, int $ page, int $items_per_batch ): array {542 * @param int $after_id Only items with a larger id. 543 * @param int $items_per_batch Items per batch. 544 * @return array{processed: int, events: int, success: bool, last_id: int, done: bool, error?: string, retry_after?: int} 545 */ 546 private function process_page_batch( string $session_id, int $after_id, int $items_per_batch ): array { 498 547 $post_types = Emporiqa_Admin::get_option( 'page_post_types', array( 'page' ) ); 499 548 … … 504 553 'events' => 0, 505 554 'success' => true, 555 'last_id' => $after_id, 556 'done' => true, 506 557 ); 507 558 } … … 514 565 'post_status' => 'publish', 515 566 'posts_per_page' => $items_per_batch, 516 'paged' => $page, 567 'orderby' => 'ID', 568 'order' => 'ASC', 569 'no_found_rows' => true, 517 570 'suppress_filters' => false, 518 571 ); … … 525 578 } 526 579 527 $pages = get_posts( $query_args ); 580 $query_args['emporiqa_after_id'] = $after_id; 581 582 $pages = self::query_after_id( 583 function () use ( $query_args ) { 584 return get_posts( $query_args ); 585 } 586 ); 528 587 $events = array(); 529 588 $processed = count( $pages ); 589 $last_id = $after_id; 530 590 531 591 foreach ( $pages as $page_post ) { 592 $last_id = max( $last_id, (int) $page_post->ID ); 532 593 $formatted = $formatter->format_page_consolidated( $page_post, $session_id ); 533 594 if ( ! empty( $formatted ) ) { … … 539 600 } 540 601 541 $success = true; 542 $error = null; 602 $success = true; 603 $error = null; 604 $retry_after = null; 543 605 if ( ! empty( $events ) ) { 544 606 $success = $webhook->send_batch_events( $events ); 545 607 if ( ! $success ) { 546 $error = $webhook->get_last_error(); 608 $error = $webhook->get_last_error(); 609 $retry_after = $webhook->get_last_retry_after(); 547 610 } 548 611 } … … 552 615 'events' => count( $events ), 553 616 'success' => $success, 617 'last_id' => $last_id, 618 'done' => $processed < $items_per_batch, 554 619 ); 555 620 if ( ! $success && $error ) { 556 621 $out['error'] = $error; 557 622 } 623 if ( null !== $retry_after ) { 624 $out['retry_after'] = $retry_after; 625 } 558 626 559 627 return $out; 560 628 } 629 630 /** 631 * Run $query with `ID > after_id` added to the posts queries that carry 632 * the `emporiqa_after_id` query var (keyset paging). wc_get_products() 633 * and get_posts() have no "id after" argument, but both pass unknown 634 * query vars through to WP_Query, so it goes in through posts_where. 635 * 636 * @since 1.2.0 637 * 638 * @param callable $query Runs one posts query whose args include `emporiqa_after_id`. 639 * @return array 640 */ 641 public static function query_after_id( callable $query ): array { 642 $filter = function ( $where, $wp_query ) { 643 $after_id = $wp_query instanceof \WP_Query ? $wp_query->get( 'emporiqa_after_id', null ) : null; 644 if ( null === $after_id ) { 645 return $where; 646 } 647 global $wpdb; 648 return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", absint( $after_id ) ); 649 }; 650 add_filter( 'posts_where', $filter, 10, 2 ); 651 try { 652 $result = $query(); 653 } finally { 654 remove_filter( 'posts_where', $filter, 10 ); 655 } 656 return is_array( $result ) ? $result : array(); 657 } 561 658 } -
emporiqa/trunk/includes/class-emporiqa-webhook.php
r3560454 r3730094 98 98 99 99 /** 100 * Seconds Emporiqa asked us to wait (429, or 503 with Retry-After) on the 101 * most recent failed send_batch_events call, or null. Emporiqa sheds sync 102 * batches this way when its queue is full; the batch is meant to be sent 103 * again after the wait, not counted as failed. 104 * 105 * @var int|null 106 */ 107 private ?int $last_retry_after = null; 108 109 /** 110 * Longest wait honoured from Retry-After (seconds). 111 * 112 * @since 1.2.0 113 */ 114 public const MAX_RETRY_AFTER = 300; 115 116 /** 117 * Wait used when a 429 names none (seconds). 118 * 119 * @since 1.2.0 120 */ 121 public const DEFAULT_RETRY_AFTER = 30; 122 123 /** 124 * How many times a WP-CLI sync waits and resends one batch before it 125 * counts the batch as failed. 126 * 127 * @since 1.2.0 128 */ 129 private const CLI_MAX_WAITS = 10; 130 131 /** 100 132 * Connect-phase budget (ms) to install on the in-flight cURL handle 101 133 * via http_api_curl. Set just before wp_remote_post in do_request(), … … 657 689 // (cURL) or by apply_fallback_request_timeout (Streams). 658 690 'timeout' => 0.01, 659 'headers' => array( 660 'Content-Type' => 'application/json', 661 'X-Webhook-Signature' => $this->generate_signature( $json ), 662 ), 691 'headers' => self::signed_headers( $json ), 663 692 'body' => $json, 664 693 ) … … 835 864 $result = $this->do_request( $payload, false, $timeout ); 836 865 866 // WP-CLI has no request time limit, so it waits as Emporiqa asks and 867 // sends the same batch again. The admin sync waits in the browser. 868 $waits = 0; 869 while ( ! $result['success'] && null !== ( $result['retry_after'] ?? null ) && $debug && defined( 'WP_CLI' ) && WP_CLI && $waits < self::CLI_MAX_WAITS ) { 870 ++$waits; 871 \WP_CLI::log( sprintf( 'Emporiqa asked to wait %d s before the next batch; waiting.', $result['retry_after'] ) ); 872 sleep( $result['retry_after'] ); 873 $result = $this->do_request( $payload, false, $timeout ); 874 } 875 876 $this->last_retry_after = $result['success'] ? null : ( $result['retry_after'] ?? null ); 877 837 878 if ( ! $result['success'] ) { 838 879 $this->last_error = $this->build_friendly_error( $result ); … … 864 905 public function get_last_error(): ?string { 865 906 return $this->last_error; 907 } 908 909 /** 910 * Seconds Emporiqa asked to wait before the batch that just failed is 911 * sent again; null when the failure was not a request to wait. 912 * 913 * @since 1.2.0 914 * 915 * @return int|null 916 */ 917 public function get_last_retry_after(): ?int { 918 return $this->last_retry_after; 919 } 920 921 /** 922 * The wait a failed response asks for: a 429 always (Retry-After, else 923 * DEFAULT_RETRY_AFTER), a 503 only when it carries Retry-After. Seconds 924 * between 1 and MAX_RETRY_AFTER; an HTTP-date Retry-After counts as the 925 * default. Null for every other failure. 926 * 927 * @since 1.2.0 928 * 929 * @param int $status HTTP status. 930 * @param string $retry_after Retry-After header value, '' when absent. 931 * @return int|null 932 */ 933 public static function retry_after( int $status, string $retry_after ): ?int { 934 $retry_after = trim( $retry_after ); 935 if ( 429 !== $status && ! ( 503 === $status && '' !== $retry_after ) ) { 936 return null; 937 } 938 $seconds = ctype_digit( $retry_after ) ? (int) $retry_after : self::DEFAULT_RETRY_AFTER; 939 return max( 1, min( self::MAX_RETRY_AFTER, $seconds ) ); 866 940 } 867 941 … … 1040 1114 $result = $this->do_request( array( 'events' => $events ), true ); 1041 1115 1116 // Warned from 2 minutes, well inside the 5 minutes Emporiqa allows, so 1117 // the merchant hears of a drifting clock before every sync fails. 1118 $clock_warning = ''; 1119 $skew = $result['clock_skew'] ?? null; 1120 if ( null !== $skew && abs( $skew ) > 120 ) { 1121 $clock_warning = ' ' . sprintf( 1122 /* translators: %d: number of minutes */ 1123 __( "This server's clock is %d minutes off. Ask your host to turn on time synchronisation (NTP): Emporiqa refuses data sent with a clock more than 5 minutes off.", 'emporiqa' ), 1124 (int) round( abs( $skew ) / 60 ) 1125 ); 1126 } 1127 1042 1128 if ( $result['success'] ) { 1043 1129 $dry_run_data = $result['response'] ?? array(); 1130 if ( is_array( $dry_run_data ) ) { 1131 // A connected site learns here, without reconnecting, whether 1132 // Emporiqa offers ready-made rules and which are live. 1133 Emporiqa_Admin::store_rules_status( $dry_run_data ); 1134 } 1044 1135 1045 1136 $has_warnings = false; … … 1055 1146 return array( 1056 1147 'success' => true, 1057 'message' => $has_warnings1148 'message' => ( $has_warnings 1058 1149 ? __( 'Connection successful, but there are warnings.', 'emporiqa' ) 1059 : __( 'Connection successful! All fields validated.', 'emporiqa' ) ,1150 : __( 'Connection successful! All fields validated.', 'emporiqa' ) ) . $clock_warning, 1060 1151 'dry_run' => $dry_run_data, 1061 1152 ); … … 1065 1156 'success' => false, 1066 1157 /* translators: %s: human-readable error message from the Emporiqa server */ 1067 'message' => sprintf( __( 'Connection failed: %s', 'emporiqa' ), $this->build_friendly_error( $result ) ) ,1158 'message' => sprintf( __( 'Connection failed: %s', 'emporiqa' ), $this->build_friendly_error( $result ) ) . $clock_warning, 1068 1159 'dry_run' => $result['response'], 1069 1160 ); … … 1135 1226 1136 1227 /** 1137 * Generate an HMAC-SHA256 signature for a payload.1138 *1139 * @since 1.0.01140 *1141 * @param string $payload The JSON payload.1142 * @return string The hex-encoded signature.1143 */1144 private function generate_signature( string $payload ): string {1145 $secret = Emporiqa_Admin::get_option( 'webhook_secret', '' );1146 if ( '' === $secret ) {1147 return '';1148 }1149 return hash_hmac( 'sha256', $payload, $secret );1150 }1151 1152 /**1153 1228 * Send an HTTP POST request to the webhook endpoint. 1154 1229 * … … 1199 1274 } 1200 1275 1201 $headers = array( 1202 'Content-Type' => 'application/json', 1203 'X-Webhook-Signature' => $this->generate_signature( $json_payload ), 1204 ); 1276 $headers = self::signed_headers( $json_payload ); 1205 1277 1206 1278 // Hook-driven (AS worker) sends get the tight handshake cap; admin- … … 1237 1309 $status_code = wp_remote_retrieve_response_code( $response ); 1238 1310 $body = json_decode( wp_remote_retrieve_body( $response ), true ); 1311 $clock_skew = self::clock_skew( (string) wp_remote_retrieve_header( $response, 'date' ), time() ); 1239 1312 1240 1313 if ( in_array( $status_code, array( 200, 201, 202 ), true ) ) { 1241 1314 return array( 1242 'success' => true, 1243 'response' => $body, 1244 'error' => null, 1315 'success' => true, 1316 'response' => $body, 1317 'error' => null, 1318 'clock_skew' => $clock_skew, 1245 1319 ); 1246 1320 } … … 1251 1325 1252 1326 return array( 1253 'success' => false, 1254 'response' => $body, 1255 'error' => $error_msg, 1327 'success' => false, 1328 'response' => $body, 1329 'error' => $error_msg, 1330 'clock_skew' => $clock_skew, 1331 'retry_after' => self::retry_after( (int) $status_code, (string) wp_remote_retrieve_header( $response, 'retry-after' ) ), 1256 1332 ); 1333 } 1334 1335 /** 1336 * Headers for a sync webhook body: the scheme-2 signature beside the 1337 * legacy one (a new plugin may still meet a platform that only knows the 1338 * old header), and the plugin version. Built per send, so every send and 1339 * every retry carries a fresh timestamp. 1340 * 1341 * @since 1.2.0 1342 * 1343 * @param string $json Exact request body. 1344 * @return array<string, string> 1345 */ 1346 public static function signed_headers( string $json ): array { 1347 $secret = (string) Emporiqa_Admin::get_option( 'webhook_secret', '' ); 1348 $headers = array( 1349 'Content-Type' => 'application/json', 1350 'X-Webhook-Signature' => '' === $secret ? '' : hash_hmac( 'sha256', $json, $secret ), 1351 'X-Emporiqa-Plugin-Version' => 'woocommerce/' . EMPORIQA_VERSION, 1352 ); 1353 $scheme2 = Emporiqa_Signature::webhook_header( $json, $secret, (string) Emporiqa_Admin::get_option( 'store_id', '' ) ); 1354 if ( '' !== $scheme2 ) { 1355 $headers['X-Emporiqa-Webhook-Signature'] = $scheme2; 1356 } 1357 return $headers; 1358 } 1359 1360 /** 1361 * Seconds this server's clock is ahead of Emporiqa's (negative: behind), 1362 * from the response Date header; null when there is none. Tells a skewed 1363 * clock apart from a wrong secret when signatures are refused. 1364 * 1365 * @since 1.2.0 1366 * 1367 * @param string $date_header HTTP Date header value. 1368 * @param int $now Local time. 1369 * @return int|null 1370 */ 1371 public static function clock_skew( string $date_header, int $now ): ?int { 1372 $remote = '' === $date_header ? false : strtotime( $date_header ); 1373 return false === $remote ? null : $now - $remote; 1257 1374 } 1258 1375 -
emporiqa/trunk/includes/class-emporiqa-widget.php
r3549215 r3730094 13 13 * - currency: ISO 4217 currency code (e.g. EUR, USD) 14 14 * - channel: Sales channel identifier (always included, defaults to 'default') 15 * - user_id: Signed token for logged-in customer identification 15 * 16 * A signed-in customer's token is never in the page or the URL (full-page 17 * caches would serve it to another shopper, and URLs end up in server logs). 18 * assets/js/emporiqa-customer-token.js asks the uncached admin-ajax action 19 * `emporiqa_customer_token` for it when the chat opens, and hands it to 20 * embed.js on a MessageChannel port. 16 21 * 17 22 * @package Emporiqa … … 31 36 32 37 /** 38 * The admin-ajax action that returns the customer token. 39 */ 40 public const TOKEN_ACTION = 'emporiqa_customer_token'; 41 42 /** 43 * The request header the token script sends. A custom header makes a 44 * cross-site request need a CORS preflight, which admin-ajax never 45 * grants, so a page on another site cannot have the browser send it. 46 */ 47 public const TOKEN_HEADER = 'X-Emporiqa-Token'; 48 49 /** 33 50 * Register hooks. 34 51 * … … 38 55 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_widget' ) ); 39 56 add_filter( 'script_loader_tag', array( $this, 'modify_widget_script_tag' ), 10, 2 ); 57 // Signed-in visitors only: a guest has no token, and its script never asks. 58 add_action( 'wp_ajax_' . self::TOKEN_ACTION, array( $this, 'ajax_customer_token' ) ); 40 59 } 41 60 … … 67 86 } 68 87 88 $language = $this->get_current_language(); 89 if ( $this->is_multilingual() && ! self::is_language_enabled( $language, (array) Emporiqa_Admin::get_option( 'enabled_languages', array() ) ) ) { 90 return; 91 } 92 69 93 $query_params = array( 70 94 'store_id' => $store_id, 71 'language' => $ this->get_current_language(),95 'language' => $language, 72 96 ); 73 97 … … 92 116 } 93 117 94 if ( is_user_logged_in() ) {95 $webhook_secret = Emporiqa_Admin::get_option( 'webhook_secret' );96 if ( ! empty( $webhook_secret ) ) {97 $user_token = $this->generate_user_token(98 (string) get_current_user_id(),99 $webhook_secret100 );101 if ( ! empty( $user_token ) ) {102 $query_params['user_id'] = $user_token;103 }104 }105 }106 107 118 /** 108 119 * Filter the widget embed query parameters. … … 110 121 * @since 1.0.0 111 122 * 112 * @param array $query_params The query parameters (store_id, language, currency, channel , user_id).123 * @param array $query_params The query parameters (store_id, language, currency, channel). 113 124 */ 114 125 $query_params = apply_filters( 'emporiqa_widget_params', $query_params ); … … 121 132 wp_enqueue_script( 'emporiqa-widget', $widget_url, array(), EMPORIQA_VERSION, true ); 122 133 wp_script_add_data( 'emporiqa-widget', 'strategy', 'async' ); 134 135 // The same for every visitor, so cached pages may carry it: it holds 136 // only the admin-ajax URL, and the token is fetched on demand. 137 if ( ! empty( Emporiqa_Admin::get_option( 'webhook_secret' ) ) ) { 138 wp_enqueue_script( 139 'emporiqa-customer-token', 140 EMPORIQA_PLUGIN_URL . 'assets/js/emporiqa-customer-token.js', 141 array(), 142 EMPORIQA_VERSION, 143 array( 144 'in_footer' => true, 145 'strategy' => 'defer', 146 ) 147 ); 148 wp_add_inline_script( 149 'emporiqa-customer-token', 150 'window.emporiqaTokenConfig = ' . wp_json_encode( 151 array( 152 'url' => admin_url( 'admin-ajax.php' ), 153 'action' => self::TOKEN_ACTION, 154 'header' => self::TOKEN_HEADER, 155 ) 156 ) . ';', 157 'before' 158 ); 159 } 160 } 161 162 /** 163 * Admin-ajax: the signed-in customer's token. 164 * 165 * Read-only: it returns only the current session's own identity token 166 * and changes nothing. It cannot carry a nonce, because the page that 167 * would hold one is served from full-page caches to other visitors. 168 * Cross-site use is blocked instead: POST only, the TOKEN_HEADER header 169 * is required (forcing a CORS preflight), and a request whose 170 * Sec-Fetch-Site or Origin says it came from another site is refused. 171 * Never cached (no-store, private, Vary: Cookie). 172 * 173 * @since 1.2.0 174 */ 175 public function ajax_customer_token(): void { 176 // Through the filter, not header(): wp_send_json() ends in the 177 // admin-ajax die handler, which sends nocache_headers() again, and 178 // before WordPress 6.3 those lack no-store and private. 179 add_filter( 'nocache_headers', array( __CLASS__, 'no_store_headers' ) ); 180 nocache_headers(); 181 send_nosniff_header(); 182 header( 'Vary: Cookie' ); 183 184 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; 185 if ( 'POST' !== $method ) { 186 wp_send_json( array( 'token' => '' ), 405 ); 187 } 188 if ( ! self::is_same_site_request( $_SERVER, home_url() ) ) { 189 wp_send_json( array( 'token' => '' ), 403 ); 190 } 191 192 $token = ''; 193 if ( is_user_logged_in() ) { 194 $token = Emporiqa_Signature::customer_token( 195 (string) get_current_user_id(), 196 (string) Emporiqa_Admin::get_option( 'webhook_secret', '' ), 197 (string) Emporiqa_Admin::get_option( 'store_id', '' ) 198 ); 199 } 200 wp_send_json( array( 'token' => $token ) ); 201 } 202 203 /** 204 * The nocache headers with no-store and private, so no shared or browser 205 * cache keeps a customer's token, on every supported WordPress version. 206 * 207 * @since 1.2.0 208 * 209 * @param array $headers Header names and values. 210 * @return array 211 */ 212 public static function no_store_headers( array $headers ): array { 213 $headers['Cache-Control'] = 'no-cache, must-revalidate, max-age=0, no-store, private'; 214 return $headers; 215 } 216 217 /** 218 * Whether a token request came from this site's own script: it carries 219 * TOKEN_HEADER, Sec-Fetch-Site (when the browser sends it) is 220 * same-origin, and Origin (when sent) is the site's own host. 221 * 222 * @since 1.2.0 223 * 224 * @param array $server $_SERVER. 225 * @param string $home home_url(). 226 * @return bool 227 */ 228 public static function is_same_site_request( array $server, string $home ): bool { 229 $header = 'HTTP_' . strtoupper( str_replace( '-', '_', self::TOKEN_HEADER ) ); 230 if ( '1' !== ( $server[ $header ] ?? '' ) ) { 231 return false; 232 } 233 $fetch_site = isset( $server['HTTP_SEC_FETCH_SITE'] ) ? strtolower( (string) $server['HTTP_SEC_FETCH_SITE'] ) : ''; 234 if ( '' !== $fetch_site && 'same-origin' !== $fetch_site ) { 235 return false; 236 } 237 $origin = isset( $server['HTTP_ORIGIN'] ) ? (string) $server['HTTP_ORIGIN'] : ''; 238 if ( '' === $origin ) { 239 return true; 240 } 241 $host = self::host_of( $origin ); 242 return '' !== $host && self::host_of( $home ) === $host; 243 } 244 245 /** 246 * The host[:port] of a URL, lowercase; the scheme is left out, since a site 247 * behind a TLS proxy may know itself as http. 248 * 249 * @since 1.2.0 250 * 251 * @param string $url URL. 252 * @return string '' when it has no host. 253 */ 254 public static function host_of( string $url ): string { 255 $parts = wp_parse_url( $url ); 256 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { 257 return ''; 258 } 259 return strtolower( $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) ); 123 260 } 124 261 … … 152 289 153 290 /** 154 * Generate a signed user identity token for the chat widget. 155 * 156 * @since 1.0.0 157 * 158 * @param string $user_id The customer's user ID. 159 * @param string $webhook_secret The store's webhook secret. 160 * @return string Signed token in format "base64url_payload.hmac_hex". 161 */ 162 private function generate_user_token( string $user_id, string $webhook_secret ): string { 163 $payload = wp_json_encode( 164 array( 165 'uid' => $user_id, 166 'ts' => time(), 167 ) 168 ); 169 170 if ( false === $payload ) { 171 return ''; 172 } 173 174 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for signed token encoding. 175 $encoded_payload = rtrim( strtr( base64_encode( $payload ), '+/', '-_' ), '=' ); 176 $signature = hash_hmac( 'sha256', $encoded_payload, $webhook_secret ); 177 178 return $encoded_payload . '.' . $signature; 291 * Whether the page's language is ticked under Languages. A language that 292 * is not ticked is neither synced nor offered: its pages show no chat. 293 * 294 * @since 1.2.0 295 * 296 * @param string $current The page's language. 297 * @param string[] $enabled Ticked languages. 298 * @return bool 299 */ 300 public static function is_language_enabled( string $current, array $enabled ): bool { 301 return empty( $enabled ) || in_array( $current, $enabled, true ); 302 } 303 304 /** 305 * Whether Polylang or WPML gives each page its own language. Without one 306 * the site has a single language, and the chat always shows. 307 * 308 * @return bool 309 */ 310 private function is_multilingual(): bool { 311 return function_exists( 'pll_current_language' ) || ( defined( 'ICL_LANGUAGE_CODE' ) && ICL_LANGUAGE_CODE ); 179 312 } 180 313 -
emporiqa/trunk/languages/emporiqa.pot
r3678094 r3730094 3 3 msgid "" 4 4 msgstr "" 5 "Project-Id-Version: Emporiqa 1. 1.10\n"5 "Project-Id-Version: Emporiqa 1.2.0\n" 6 6 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/emporiqa\n" 7 7 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" … … 10 10 "Content-Type: text/plain; charset=UTF-8\n" 11 11 "Content-Transfer-Encoding: 8bit\n" 12 "POT-Creation-Date: 2026- 09-02T13:34:37+00:00\n"12 "POT-Creation-Date: 2026-10-06T05:10:28+00:00\n" 13 13 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 14 14 "X-Generator: WP-CLI 2.12.0\n" … … 18 18 #. Author of the plugin 19 19 #: emporiqa.php 20 #: emporiqa.php:10 6121 #: emporiqa.php:11 1022 #: includes/class-emporiqa-admin.php: 5920 #: emporiqa.php:1079 21 #: emporiqa.php:1128 22 #: includes/class-emporiqa-admin.php:64 23 23 msgid "Emporiqa" 24 24 msgstr "" … … 34 34 msgstr "" 35 35 36 #: emporiqa.php:10 6236 #: emporiqa.php:1080 37 37 msgid "requires WooCommerce to be installed and active." 38 38 msgstr "" 39 39 40 #: emporiqa.php:11 1140 #: emporiqa.php:1129 41 41 msgid "requires WooCommerce 8.0 or higher. Some features may not work correctly with your current version." 42 42 msgstr "" 43 43 44 #: emporiqa.php:11 2844 #: emporiqa.php:1146 45 45 msgid "Emporiqa activated!" 46 46 msgstr "" 47 47 48 #: emporiqa.php:11 2948 #: emporiqa.php:1147 49 49 msgid "Connect your store to start syncing products and pages." 50 50 msgstr "" 51 51 52 #: emporiqa.php:11 3152 #: emporiqa.php:1149 53 53 msgid "Go to Settings →" 54 54 msgstr "" 55 55 56 #: includes/class-emporiqa-admin.php: 5856 #: includes/class-emporiqa-admin.php:63 57 57 msgid "Emporiqa Settings" 58 58 msgstr "" 59 59 60 #: includes/class-emporiqa-admin.php: 7861 #: includes/class-emporiqa-admin.php:2 2560 #: includes/class-emporiqa-admin.php:83 61 #: includes/class-emporiqa-admin.php:239 62 62 msgid "Settings" 63 63 msgstr "" 64 64 65 65 #. translators: %s: link to Sync tab 66 #: includes/class-emporiqa-admin.php:10 166 #: includes/class-emporiqa-admin.php:106 67 67 #, php-format 68 68 msgid "To sync your products and pages with Emporiqa, go to the <a href=\"%s\">Sync tab</a>." 69 69 msgstr "" 70 70 71 #: includes/class-emporiqa-admin.php:1 6871 #: includes/class-emporiqa-admin.php:182 72 72 msgid "Connection Secret is required. Webhooks and user identification will not work without it." 73 73 msgstr "" 74 74 75 75 #. translators: %s: link to Emporiqa dashboard integration overview 76 #: includes/class-emporiqa-admin.php: 19776 #: includes/class-emporiqa-admin.php:211 77 77 #, php-format 78 78 msgid "Before your first sync, make sure your Store ID and Connection Secret are configured. Find both in your <a href=\"%s\" target=\"_blank\">Emporiqa dashboard → Settings → Integration → Integration overview</a>." 79 79 msgstr "" 80 80 81 #: includes/class-emporiqa-admin.php:2 2881 #: includes/class-emporiqa-admin.php:242 82 82 msgid "Sync" 83 83 msgstr "" 84 84 85 #: includes/class-emporiqa-admin.php:3 0185 #: includes/class-emporiqa-admin.php:316 86 86 msgid "Connection Settings" 87 87 msgstr "" 88 88 89 #: includes/class-emporiqa-admin.php:3 1189 #: includes/class-emporiqa-admin.php:326 90 90 msgid "Connected." 91 91 msgstr "" 92 92 93 #: includes/class-emporiqa-admin.php:3 1293 #: includes/class-emporiqa-admin.php:327 94 94 msgid "Your store is now linked to Emporiqa. Use the Sync tab to send your catalog." 95 95 msgstr "" 96 96 97 #: includes/class-emporiqa-admin.php:355 97 #: includes/class-emporiqa-admin.php:380 98 msgid "Ready-made rules" 99 msgstr "" 100 101 #: includes/class-emporiqa-admin.php:383 102 msgid "Rules tell the chat what to do when a shopper asks for something specific. The rule below reads your WooCommerce orders through this plugin. Click Open in Emporiqa to test it and switch it on there. Rules that do not need your shop, such as return requests, are added in Emporiqa under Settings > Rules." 103 msgstr "" 104 105 #: includes/class-emporiqa-admin.php:387 106 msgid "The Order status rule is on, so the old order tracking under Advanced is no longer needed. First remove its address in your Emporiqa dashboard (Settings > Integration > For your developer > Order tracking API URL), then untick Order tracking under Advanced and click Save Changes." 107 msgstr "" 108 109 #: includes/class-emporiqa-admin.php:392 110 msgid "Order status address" 111 msgstr "" 112 113 #: includes/class-emporiqa-admin.php:398 114 #: includes/class-emporiqa-admin.php:453 115 #: includes/class-emporiqa-admin.php:709 116 msgid "Copy" 117 msgstr "" 118 119 #: includes/class-emporiqa-admin.php:401 120 msgid "Emporiqa fills this in for you when you add the Order status rule. If it ever asks for the address, use this one." 121 msgstr "" 122 123 #: includes/class-emporiqa-admin.php:403 124 msgid "Your site is not served over https, so Emporiqa cannot call it. Switch the site to https (Settings > General), then come back here." 125 msgstr "" 126 127 #: includes/class-emporiqa-admin.php:408 128 msgid "Order status" 129 msgstr "" 130 131 #: includes/class-emporiqa-admin.php:411 132 msgid "On" 133 msgstr "" 134 135 #: includes/class-emporiqa-admin.php:413 136 msgid "Not added" 137 msgstr "" 138 139 #: includes/class-emporiqa-admin.php:416 140 msgid "Open in Emporiqa" 141 msgstr "" 142 143 #: includes/class-emporiqa-admin.php:420 144 msgid "Answers \"Where is my order?\" from your WooCommerce orders. A shopper signed in to your shop only gives the order number; a guest also gives the email used for the order." 145 msgstr "" 146 147 #: includes/class-emporiqa-admin.php:423 148 msgid "On and Not added show what Emporiqa said at your last connect or Test connection (Sync tab). Run Test connection after changing a rule to refresh them." 149 msgstr "" 150 151 #: includes/class-emporiqa-admin.php:442 152 msgid "Order tracking (old)" 153 msgstr "" 154 155 #: includes/class-emporiqa-admin.php:448 156 msgid "Answer order lookups at the old order tracking address." 157 msgstr "" 158 159 #: includes/class-emporiqa-admin.php:457 160 msgid "Replaced by the Order status rule above. Leave it on while your Emporiqa dashboard still has this address. To switch it off, first remove the address in your Emporiqa dashboard (Settings > Integration > For your developer > Order tracking API URL), then untick this and click Save Changes." 161 msgstr "" 162 163 #: includes/class-emporiqa-admin.php:527 98 164 msgid "Connect to Emporiqa in one click" 99 165 msgstr "" 100 166 101 #: includes/class-emporiqa-admin.php: 358167 #: includes/class-emporiqa-admin.php:530 102 168 msgid "We'll sign you in, link this store, and send back a fresh connection secret. No copy-pasting." 103 169 msgstr "" 104 170 105 #: includes/class-emporiqa-admin.php: 371171 #: includes/class-emporiqa-admin.php:543 106 172 msgid "Connect to Emporiqa" 107 173 msgstr "" 108 174 109 #: includes/class-emporiqa-admin.php: 376110 #: includes/class-emporiqa-connect.php:1 33175 #: includes/class-emporiqa-admin.php:548 176 #: includes/class-emporiqa-connect.php:148 111 177 msgid "Your site must be served over HTTPS to use one-click connect." 112 178 msgstr "" 113 179 114 #: includes/class-emporiqa-admin.php: 383180 #: includes/class-emporiqa-admin.php:555 115 181 msgid "I'd rather paste credentials manually" 116 182 msgstr "" 117 183 118 #: includes/class-emporiqa-admin.php: 400184 #: includes/class-emporiqa-admin.php:572 119 185 msgid "Connection in progress." 120 186 msgstr "" 121 187 122 #: includes/class-emporiqa-admin.php: 401188 #: includes/class-emporiqa-admin.php:573 123 189 msgid "Finish the steps on emporiqa.com, then come back to this tab." 124 190 msgstr "" 125 191 126 192 #. translators: %s: link text for "start over" 127 #: includes/class-emporiqa-admin.php: 407193 #: includes/class-emporiqa-admin.php:579 128 194 #, php-format 129 195 msgid "Tab got stuck? %s." 130 196 msgstr "" 131 197 132 #: includes/class-emporiqa-admin.php: 411198 #: includes/class-emporiqa-admin.php:583 133 199 msgid "Start over" 134 200 msgstr "" 135 201 136 #: includes/class-emporiqa-admin.php: 432202 #: includes/class-emporiqa-admin.php:604 137 203 msgid "Connected to Emporiqa." 138 204 msgstr "" 139 205 140 #: includes/class-emporiqa-admin.php: 436141 #: includes/class-emporiqa-admin.php: 480206 #: includes/class-emporiqa-admin.php:608 207 #: includes/class-emporiqa-admin.php:652 142 208 msgid "Store ID" 143 209 msgstr "" 144 210 145 #: includes/class-emporiqa-admin.php: 442211 #: includes/class-emporiqa-admin.php:614 146 212 msgid "Connection secret" 147 213 msgstr "" 148 214 149 #: includes/class-emporiqa-admin.php: 444215 #: includes/class-emporiqa-admin.php:616 150 216 msgid "Stored. Reconnect to rotate." 151 217 msgstr "" 152 218 153 #: includes/class-emporiqa-admin.php: 450219 #: includes/class-emporiqa-admin.php:622 154 220 msgid "Reconnect" 155 221 msgstr "" 156 222 157 #: includes/class-emporiqa-admin.php: 453223 #: includes/class-emporiqa-admin.php:625 158 224 msgid "Open Emporiqa dashboard" 159 225 msgstr "" 160 226 161 #: includes/class-emporiqa-admin.php: 461227 #: includes/class-emporiqa-admin.php:633 162 228 msgid "Edit credentials manually" 163 229 msgstr "" 164 230 165 231 #. translators: %s: link to Emporiqa dashboard integration overview 166 #: includes/class-emporiqa-admin.php: 490232 #: includes/class-emporiqa-admin.php:662 167 233 #, php-format 168 234 msgid "Find it in your <a href=\"%s\" target=\"_blank\">Emporiqa dashboard → Settings → Integration → Integration overview</a>" 169 235 msgstr "" 170 236 171 #: includes/class-emporiqa-admin.php: 499237 #: includes/class-emporiqa-admin.php:671 172 238 msgid "Connection Secret" 173 239 msgstr "" 174 240 175 #: includes/class-emporiqa-admin.php: 505241 #: includes/class-emporiqa-admin.php:677 176 242 msgid "Value is set (leave empty to keep)" 177 243 msgstr "" 178 244 179 #: includes/class-emporiqa-admin.php: 511245 #: includes/class-emporiqa-admin.php:683 180 246 msgid "Not configured" 181 247 msgstr "" 182 248 183 #: includes/class-emporiqa-admin.php: 516249 #: includes/class-emporiqa-admin.php:688 184 250 msgid "Required. Secret key for HMAC signature. Leave empty to keep the current value." 185 251 msgstr "" 186 252 187 #: includes/class-emporiqa-admin.php: 532253 #: includes/class-emporiqa-admin.php:704 188 254 msgid "Order Tracking API URL" 189 255 msgstr "" 190 256 191 #: includes/class-emporiqa-admin.php:537192 msgid "Copy"193 msgstr ""194 195 257 #. translators: %s: link to Emporiqa dashboard order tracking settings 196 #: includes/class-emporiqa-admin.php: 545258 #: includes/class-emporiqa-admin.php:717 197 259 #, php-format 198 260 msgid "Optional. To enable order tracking in the chat, paste this URL into your <a href=\"%s\" target=\"_blank\">Emporiqa dashboard → Settings → Integration → Order Tracking API URL</a>. Requests are authenticated with your webhook secret; the customer email is always required and must match the order billing email." 199 261 msgstr "" 200 262 201 #: includes/class-emporiqa-admin.php: 569263 #: includes/class-emporiqa-admin.php:741 202 264 msgid "Advanced" 203 265 msgstr "" 204 266 205 #: includes/class-emporiqa-admin.php: 575267 #: includes/class-emporiqa-admin.php:747 206 268 msgid "The defaults work for most stores. Open this only if you want to change what's sent to Emporiqa." 207 269 msgstr "" 208 270 209 #: includes/class-emporiqa-admin.php: 579271 #: includes/class-emporiqa-admin.php:751 210 272 msgid "Sync products" 211 273 msgstr "" 212 274 213 #: includes/class-emporiqa-admin.php: 584275 #: includes/class-emporiqa-admin.php:756 214 276 msgid "Send new and updated products to Emporiqa so the chat can recommend them." 215 277 msgstr "" 216 278 217 #: includes/class-emporiqa-admin.php: 589279 #: includes/class-emporiqa-admin.php:761 218 280 msgid "Sync pages" 219 281 msgstr "" 220 282 221 #: includes/class-emporiqa-admin.php: 594283 #: includes/class-emporiqa-admin.php:766 222 284 msgid "Send pages and blog content so the chat can answer questions about your shipping, returns, FAQs, and similar." 223 285 msgstr "" 224 286 225 #: includes/class-emporiqa-admin.php: 599287 #: includes/class-emporiqa-admin.php:771 226 288 msgid "Languages" 227 289 msgstr "" 228 290 229 #: includes/class-emporiqa-admin.php: 616291 #: includes/class-emporiqa-admin.php:788 230 292 msgid "Pick the languages of your products and pages to send. The chat replies in each shopper's language for any of these." 231 293 msgstr "" 232 294 233 #: includes/class-emporiqa-admin.php: 621295 #: includes/class-emporiqa-admin.php:793 234 296 msgid "Pages and posts to include" 235 297 msgstr "" 236 298 237 #: includes/class-emporiqa-admin.php: 642299 #: includes/class-emporiqa-admin.php:814 238 300 msgid "Which kinds of content count as \"pages\" for the chat. Most stores leave this as Pages only. Add Posts if you want your blog articles included." 239 301 msgstr "" 240 302 241 #: includes/class-emporiqa-admin.php: 647303 #: includes/class-emporiqa-admin.php:824 242 304 msgid "Emporiqa server URL" 243 305 msgstr "" 244 306 245 #: includes/class-emporiqa-admin.php: 652307 #: includes/class-emporiqa-admin.php:829 246 308 msgid "Where this site sends product and page updates. Keep the default (https://emporiqa.com/webhooks/sync/) unless Emporiqa support asked you to change it." 247 309 msgstr "" 248 310 249 #: includes/class-emporiqa-admin.php: 657311 #: includes/class-emporiqa-admin.php:834 250 312 msgid "Items per batch" 251 313 msgstr "" 252 314 253 #: includes/class-emporiqa-admin.php: 662315 #: includes/class-emporiqa-admin.php:839 254 316 msgid "How many products to send at a time during the first sync. The default works for almost everyone. If a sync stops partway through on a big catalog, try a smaller number (10 or 15)." 255 317 msgstr "" 256 318 257 #: includes/class-emporiqa-admin.php: 811319 #: includes/class-emporiqa-admin.php:988 258 320 msgid "Connected to Emporiqa. One more step." 259 321 msgstr "" 260 322 261 #: includes/class-emporiqa-admin.php: 815323 #: includes/class-emporiqa-admin.php:992 262 324 msgid "Send your products and pages to Emporiqa so the chat has something to recommend. We'll send everything in batches in the background." 263 325 msgstr "" 264 326 265 #: includes/class-emporiqa-admin.php: 828327 #: includes/class-emporiqa-admin.php:1005 266 328 msgid "Send my catalog" 267 329 msgstr "" 268 330 269 331 #. translators: 1: total item count, 2: typical time 270 #: includes/class-emporiqa-admin.php: 834332 #: includes/class-emporiqa-admin.php:1011 271 333 #, php-format 272 334 msgid "%1$d items to send. Typically a few minutes." 273 335 msgstr "" 274 336 275 #: includes/class-emporiqa-admin.php: 842337 #: includes/class-emporiqa-admin.php:1019 276 338 msgid "The chat goes live on your storefront once the first full sync finishes. You don't need to keep this tab open. Sync runs from your store." 277 339 msgstr "" 278 340 279 #: includes/class-emporiqa-admin.php: 847341 #: includes/class-emporiqa-admin.php:1024 280 342 msgid "Bulk Sync" 281 343 msgstr "" 282 344 283 #: includes/class-emporiqa-admin.php: 849345 #: includes/class-emporiqa-admin.php:1026 284 346 msgid "Use \"Sync All\" for your initial sync. After that, product and page changes are sent automatically in real-time via webhooks." 285 347 msgstr "" 286 348 287 #: includes/class-emporiqa-admin.php: 855349 #: includes/class-emporiqa-admin.php:1032 288 350 msgid "products" 289 351 msgstr "" 290 352 291 353 #. translators: %s: language codes 292 #: includes/class-emporiqa-admin.php: 859354 #: includes/class-emporiqa-admin.php:1036 293 355 #, php-format 294 356 msgid "(all languages synced per product: %s)" 295 357 msgstr "" 296 358 297 #: includes/class-emporiqa-admin.php: 866359 #: includes/class-emporiqa-admin.php:1043 298 360 msgid "No published products found. Make sure you have published products before syncing." 299 361 msgstr "" 300 362 301 363 #. translators: %s: link to settings tab 302 #: includes/class-emporiqa-admin.php: 875364 #: includes/class-emporiqa-admin.php:1052 303 365 #, php-format 304 366 msgid "Product sync is disabled — <a href=\"%s\">enable it in the Settings tab</a>" 305 367 msgstr "" 306 368 307 #: includes/class-emporiqa-admin.php: 885369 #: includes/class-emporiqa-admin.php:1062 308 370 msgid "pages" 309 371 msgstr "" 310 372 311 373 #. translators: %s: language codes 312 #: includes/class-emporiqa-admin.php: 889374 #: includes/class-emporiqa-admin.php:1066 313 375 #, php-format 314 376 msgid "(all languages synced per page: %s)" 315 377 msgstr "" 316 378 317 #: includes/class-emporiqa-admin.php: 896379 #: includes/class-emporiqa-admin.php:1073 318 380 msgid "No published pages found. Make sure you have published pages before syncing." 319 381 msgstr "" 320 382 321 383 #. translators: %s: link to settings tab 322 #: includes/class-emporiqa-admin.php: 905384 #: includes/class-emporiqa-admin.php:1082 323 385 #, php-format 324 386 msgid "Page sync is disabled — <a href=\"%s\">enable it in the Settings tab</a>" 325 387 msgstr "" 326 388 327 #: includes/class-emporiqa-admin.php: 917389 #: includes/class-emporiqa-admin.php:1094 328 390 msgid "Please configure your Store ID and Connection Secret in the Settings tab before syncing." 329 391 msgstr "" 330 392 331 #: includes/class-emporiqa-admin.php: 929393 #: includes/class-emporiqa-admin.php:1106 332 394 msgid "Sync Products" 333 395 msgstr "" 334 396 335 #: includes/class-emporiqa-admin.php: 931397 #: includes/class-emporiqa-admin.php:1108 336 398 msgid "Send all products to Emporiqa." 337 399 msgstr "" 338 400 339 #: includes/class-emporiqa-admin.php: 936340 #: includes/class-emporiqa-admin.php: 957341 #: includes/class-emporiqa-admin.php: 976401 #: includes/class-emporiqa-admin.php:1113 402 #: includes/class-emporiqa-admin.php:1134 403 #: includes/class-emporiqa-admin.php:1153 342 404 msgid "Configure your Store ID and Connection Secret first" 343 405 msgstr "" 344 406 345 #: includes/class-emporiqa-admin.php: 938407 #: includes/class-emporiqa-admin.php:1115 346 408 msgid "Enable product sync in the Settings tab" 347 409 msgstr "" 348 410 349 #: includes/class-emporiqa-admin.php: 950411 #: includes/class-emporiqa-admin.php:1127 350 412 msgid "Sync Pages" 351 413 msgstr "" 352 414 353 #: includes/class-emporiqa-admin.php: 952415 #: includes/class-emporiqa-admin.php:1129 354 416 msgid "Send all pages to Emporiqa." 355 417 msgstr "" 356 418 357 #: includes/class-emporiqa-admin.php: 959419 #: includes/class-emporiqa-admin.php:1136 358 420 msgid "Enable page sync in the Settings tab" 359 421 msgstr "" 360 422 361 #: includes/class-emporiqa-admin.php: 971423 #: includes/class-emporiqa-admin.php:1148 362 424 msgid "Sync All" 363 425 msgstr "" 364 426 365 #: includes/class-emporiqa-admin.php: 973427 #: includes/class-emporiqa-admin.php:1150 366 428 msgid "Send all products and pages at once." 367 429 msgstr "" 368 430 369 #: includes/class-emporiqa-admin.php: 984431 #: includes/class-emporiqa-admin.php:1161 370 432 msgid "Cancel" 371 433 msgstr "" 372 434 373 #: includes/class-emporiqa-admin.php: 995435 #: includes/class-emporiqa-admin.php:1172 374 436 msgid "Sync log" 375 437 msgstr "" 376 438 377 #: includes/class-emporiqa-admin.php: 998439 #: includes/class-emporiqa-admin.php:1175 378 440 msgid "Test Connection" 379 441 msgstr "" 380 442 381 #: includes/class-emporiqa-admin.php:1 000443 #: includes/class-emporiqa-admin.php:1177 382 444 msgid "Sends a real product to Emporiqa in dry run mode. Validates the connection, signature, and payload without storing anything." 383 445 msgstr "" 384 446 385 #: includes/class-emporiqa-admin.php:1 004447 #: includes/class-emporiqa-admin.php:1181 386 448 msgid "Test Connection (Dry Run)" 387 449 msgstr "" 388 450 389 #: includes/class-emporiqa-admin.php:1 011451 #: includes/class-emporiqa-admin.php:1188 390 452 msgid "WP-CLI Commands (for developers)" 391 453 msgstr "" 392 454 393 #: includes/class-emporiqa-admin.php:1 059455 #: includes/class-emporiqa-admin.php:1236 394 456 msgid "Testing..." 395 457 msgstr "" 396 458 397 #: includes/class-emporiqa-admin.php:1 060459 #: includes/class-emporiqa-admin.php:1237 398 460 msgid "Copied!" 399 461 msgstr "" 400 462 401 #: includes/class-emporiqa-admin.php:1 062463 #: includes/class-emporiqa-admin.php:1239 402 464 msgid "Initializing sync..." 403 465 msgstr "" 404 466 405 467 #. translators: %s: entity type (products/pages) 406 #: includes/class-emporiqa-admin.php:1 064468 #: includes/class-emporiqa-admin.php:1241 407 469 #, php-format 408 470 msgid "Started %s sync" … … 410 472 411 473 #. translators: 1: number of items, 2: number of events, 3: entity type 412 #: includes/class-emporiqa-admin.php:1 066474 #: includes/class-emporiqa-admin.php:1243 413 475 #, php-format 414 476 msgid "Processed %1$d items (%2$d events) for %3$s" … … 416 478 417 479 #. translators: %s: entity type 418 #: includes/class-emporiqa-admin.php:1 068480 #: includes/class-emporiqa-admin.php:1245 419 481 #, php-format 420 482 msgid "Batch failed for %s" 421 483 msgstr "" 422 484 485 #. translators: 1: entity type (products/pages), 2: seconds 486 #: includes/class-emporiqa-admin.php:1247 487 #, php-format 488 msgid "Emporiqa is busy and asked to wait. Sending this %1$s batch again in %2$d seconds." 489 msgstr "" 490 423 491 #. translators: %s: entity type (products/pages) 424 #: includes/class-emporiqa-admin.php:1 070492 #: includes/class-emporiqa-admin.php:1249 425 493 #, php-format 426 494 msgid "Completed %s sync session" … … 428 496 429 497 #. translators: %s: entity type (products/pages) 430 #: includes/class-emporiqa-admin.php:1 072498 #: includes/class-emporiqa-admin.php:1251 431 499 #, php-format 432 500 msgid "Failed to complete %s session" … … 434 502 435 503 #. translators: 1: entity type (products/pages), 2: total count 436 #: includes/class-emporiqa-admin.php:1 074504 #: includes/class-emporiqa-admin.php:1253 437 505 #, php-format 438 506 msgid "Total %1$s synced: %2$d items" 439 507 msgstr "" 440 508 441 #: includes/class-emporiqa-admin.php:1 075509 #: includes/class-emporiqa-admin.php:1254 442 510 msgid "Sync completed successfully!" 443 511 msgstr "" 444 512 445 513 #. translators: %s: entity type (products/pages) 446 #: includes/class-emporiqa-admin.php:1 077514 #: includes/class-emporiqa-admin.php:1256 447 515 #, php-format 448 516 msgid "The %s sync session was not finalized because some batches failed: items missing from this sync were NOT removed from Emporiqa. Re-run the sync after resolving the errors." … … 450 518 451 519 #. translators: %s: entity type (products/pages) 452 #: includes/class-emporiqa-admin.php:1 079520 #: includes/class-emporiqa-admin.php:1258 453 521 #, php-format 454 522 msgid "No %s were synced, so the session was not finalized." 455 523 msgstr "" 456 524 457 #: includes/class-emporiqa-admin.php:1 080525 #: includes/class-emporiqa-admin.php:1259 458 526 msgid "Sync finished with errors — see the log above. Re-run the sync after resolving them." 459 527 msgstr "" 460 528 461 529 #. translators: 1: Products link placeholder, 2: Pages link placeholder 462 #: includes/class-emporiqa-admin.php:1 082530 #: includes/class-emporiqa-admin.php:1261 463 531 #, php-format 464 532 msgid "Your data is now being processed by Emporiqa. This may take a few minutes before changes are fully reflected in the chat assistant. You can check the status on the %1$s or %2$s list in your Emporiqa dashboard." … … 466 534 467 535 #. translators: used as link text in sync log 468 #: includes/class-emporiqa-admin.php:1 086536 #: includes/class-emporiqa-admin.php:1265 469 537 msgid "Products" 470 538 msgstr "" 471 539 472 540 #. translators: used as link text in sync log 473 #: includes/class-emporiqa-admin.php:1 088541 #: includes/class-emporiqa-admin.php:1267 474 542 msgid "Pages" 475 543 msgstr "" 476 544 477 #: includes/class-emporiqa-admin.php:1 089545 #: includes/class-emporiqa-admin.php:1268 478 546 msgid "Sync cancelled." 479 547 msgstr "" 480 548 481 549 #. translators: %s: error message 482 #: includes/class-emporiqa-admin.php:1 091550 #: includes/class-emporiqa-admin.php:1270 483 551 #, php-format 484 552 msgid "Sync failed: %s" 485 553 msgstr "" 486 554 487 #: includes/class-emporiqa-admin.php:1 092555 #: includes/class-emporiqa-admin.php:1271 488 556 msgid "Failed to initialize sync." 489 557 msgstr "" 490 558 491 #: includes/class-emporiqa-admin.php:1 093559 #: includes/class-emporiqa-admin.php:1272 492 560 msgid "Sample product payload" 493 561 msgstr "" 494 562 495 #: includes/class-emporiqa-admin.php:1 094563 #: includes/class-emporiqa-admin.php:1273 496 564 msgid "Sample page payload" 497 565 msgstr "" 498 566 499 #: includes/class-emporiqa-admin.php:1 111567 #: includes/class-emporiqa-admin.php:1290 500 568 #: includes/class-emporiqa-sync.php:65 501 #: includes/class-emporiqa-sync.php:16 1502 #: includes/class-emporiqa-sync.php:20 1569 #: includes/class-emporiqa-sync.php:165 570 #: includes/class-emporiqa-sync.php:209 503 571 msgid "Permission denied." 504 572 msgstr "" 505 573 506 #: includes/class-emporiqa-cart-ajax.php:8 1507 #: includes/class-emporiqa-cart-ajax.php:19 0508 #: includes/class-emporiqa-cart-ajax.php:2 29574 #: includes/class-emporiqa-cart-ajax.php:89 575 #: includes/class-emporiqa-cart-ajax.php:198 576 #: includes/class-emporiqa-cart-ajax.php:237 509 577 msgid "No product ID provided." 510 578 msgstr "" 511 579 512 #: includes/class-emporiqa-cart-ajax.php:9 0580 #: includes/class-emporiqa-cart-ajax.php:98 513 581 msgid "Product not found." 514 582 msgstr "" 515 583 516 #: includes/class-emporiqa-cart-ajax.php: 95584 #: includes/class-emporiqa-cart-ajax.php:103 517 585 msgid "This product cannot be purchased." 518 586 msgstr "" 519 587 520 #: includes/class-emporiqa-cart-ajax.php: 98588 #: includes/class-emporiqa-cart-ajax.php:106 521 589 msgid "This product is currently out of stock." 522 590 msgstr "" 523 591 524 #: includes/class-emporiqa-cart-ajax.php:1 09592 #: includes/class-emporiqa-cart-ajax.php:117 525 593 msgid "Parent product not found." 526 594 msgstr "" 527 595 528 #: includes/class-emporiqa-cart-ajax.php:1 19596 #: includes/class-emporiqa-cart-ajax.php:127 529 597 msgid "Please select a product variation." 530 598 msgstr "" 531 599 532 #: includes/class-emporiqa-cart-ajax.php:1 28600 #: includes/class-emporiqa-cart-ajax.php:136 533 601 msgid "Variation not found." 534 602 msgstr "" 535 603 536 #: includes/class-emporiqa-cart-ajax.php:13 1604 #: includes/class-emporiqa-cart-ajax.php:139 537 605 msgid "This variation cannot be purchased." 538 606 msgstr "" 539 607 540 #: includes/class-emporiqa-cart-ajax.php:1 34608 #: includes/class-emporiqa-cart-ajax.php:142 541 609 msgid "This variation is currently out of stock." 542 610 msgstr "" 543 611 544 #: includes/class-emporiqa-cart-ajax.php:1 44612 #: includes/class-emporiqa-cart-ajax.php:152 545 613 msgid "Failed to add item to cart." 546 614 msgstr "" 547 615 548 #: includes/class-emporiqa-cart-ajax.php: 194616 #: includes/class-emporiqa-cart-ajax.php:202 549 617 msgid "Quantity must be greater than zero." 550 618 msgstr "" 551 619 552 #: includes/class-emporiqa-cart-ajax.php: 199553 #: includes/class-emporiqa-cart-ajax.php:2 34620 #: includes/class-emporiqa-cart-ajax.php:207 621 #: includes/class-emporiqa-cart-ajax.php:242 554 622 msgid "Item not found in cart." 555 623 msgstr "" 556 624 557 #: includes/class-emporiqa-cart-ajax.php:2 06625 #: includes/class-emporiqa-cart-ajax.php:214 558 626 msgid "Failed to update cart." 559 627 msgstr "" 560 628 561 #: includes/class-emporiqa-cart-ajax.php:24 1629 #: includes/class-emporiqa-cart-ajax.php:249 562 630 msgid "Failed to remove item." 563 631 msgstr "" 564 632 565 #: includes/class-emporiqa-cart-ajax.php:3 16633 #: includes/class-emporiqa-cart-ajax.php:326 566 634 msgid "Security check failed. Please refresh the page and try again." 567 635 msgstr "" 568 636 569 #: includes/class-emporiqa-connect.php:1 45637 #: includes/class-emporiqa-connect.php:160 570 638 msgid "Couldn't save the connection state. Please try again." 571 639 msgstr "" 572 640 573 #: includes/class-emporiqa-connect.php: 194641 #: includes/class-emporiqa-connect.php:209 574 642 msgid "Unexpected redirect source." 575 643 msgstr "" 576 644 577 #: includes/class-emporiqa-connect.php: 199645 #: includes/class-emporiqa-connect.php:214 578 646 msgid "Callback missing required parameters." 579 647 msgstr "" 580 648 581 #: includes/class-emporiqa-connect.php:2 07649 #: includes/class-emporiqa-connect.php:222 582 650 msgid "Connection link expired or already used. Click Connect to try again." 583 651 msgstr "" 584 652 585 653 #. translators: %s: WP HTTP API error message 586 #: includes/class-emporiqa-connect.php:2 37654 #: includes/class-emporiqa-connect.php:255 587 655 #, php-format 588 656 msgid "Couldn't reach Emporiqa: %s" … … 590 658 591 659 #. translators: %s: error code from the exchange endpoint 592 #: includes/class-emporiqa-connect.php:2 53660 #: includes/class-emporiqa-connect.php:278 593 661 #, php-format 594 662 msgid "Emporiqa refused the connection: %s" 595 663 msgstr "" 596 664 597 #: includes/class-emporiqa-formatter.php: 941598 #: includes/class-emporiqa-formatter.php: 952665 #: includes/class-emporiqa-formatter.php:869 666 #: includes/class-emporiqa-formatter.php:880 599 667 msgid "General" 600 668 msgstr "" 601 669 602 #: includes/class-emporiqa-formatter.php:1 021670 #: includes/class-emporiqa-formatter.php:1245 603 671 msgid "Generic" 604 672 msgstr "" 605 673 606 #: includes/class-emporiqa-order-tracking.php: 80674 #: includes/class-emporiqa-order-tracking.php:96 607 675 msgid "Missing signature." 608 676 msgstr "" 609 677 610 #: includes/class-emporiqa-order-tracking.php: 89611 #: includes/class-emporiqa-order-tracking.php: 99678 #: includes/class-emporiqa-order-tracking.php:105 679 #: includes/class-emporiqa-order-tracking.php:115 612 680 msgid "Invalid signature." 613 681 msgstr "" 614 682 615 #: includes/class-emporiqa-order-tracking.php:1 08683 #: includes/class-emporiqa-order-tracking.php:124 616 684 msgid "Invalid JSON in request body." 617 685 msgstr "" 618 686 619 #: includes/class-emporiqa-order-tracking.php:1 17687 #: includes/class-emporiqa-order-tracking.php:133 620 688 msgid "Request expired." 621 689 msgstr "" 622 690 623 #: includes/class-emporiqa-order-tracking.php:1 58691 #: includes/class-emporiqa-order-tracking.php:197 624 692 msgid "Invalid payload: order_identifier is required." 625 693 msgstr "" 626 694 627 #: includes/class-emporiqa-order-tracking.php:185 695 #: includes/class-emporiqa-order-tracking.php:205 696 #: includes/class-emporiqa-order-tracking.php:303 697 #: includes/class-emporiqa-order-tracking.php:312 698 msgid "Order not found." 699 msgstr "" 700 701 #: includes/class-emporiqa-order-tracking.php:230 628 702 msgid "Too many requests. Please try again later." 629 msgstr ""630 631 #: includes/class-emporiqa-order-tracking.php:251632 #: includes/class-emporiqa-order-tracking.php:260633 msgid "Order not found."634 703 msgstr "" 635 704 … … 642 711 msgstr "" 643 712 644 #: includes/class-emporiqa-sync.php:2 17713 #: includes/class-emporiqa-sync.php:225 645 714 msgid "Unknown or expired sync session — it was not finalized. Re-run the sync." 646 715 msgstr "" 647 716 648 717 #. translators: 1: number of items that failed, 2: entity type (products or pages). 649 #: includes/class-emporiqa-sync.php:2 28718 #: includes/class-emporiqa-sync.php:236 650 719 #, php-format 651 720 msgid "The %2$s sync session was not finalized because %1$d item(s) failed to sync: items missing from this sync were NOT removed from Emporiqa. Re-run the sync after resolving the errors." 652 721 msgstr "" 653 722 654 #: includes/class-emporiqa-sync.php:240 723 #. translators: %s: entity type (products or pages). 724 #: includes/class-emporiqa-sync.php:250 725 #, php-format 726 msgid "The %s sync session was not finalized because Emporiqa asked to wait and a batch was not sent again: items missing from this sync were NOT removed from Emporiqa. Re-run the sync." 727 msgstr "" 728 729 #: includes/class-emporiqa-sync.php:261 655 730 msgid "No items were synced in this session, so it was not finalized (finalizing an empty session would delete the entire remote catalog)." 656 731 msgstr "" 657 732 658 #: includes/class-emporiqa-sync.php:2 49733 #: includes/class-emporiqa-sync.php:270 659 734 msgid "Session completed." 660 735 msgstr "" 661 736 662 #: includes/class-emporiqa-sync.php:2 52737 #: includes/class-emporiqa-sync.php:273 663 738 msgid "Failed to complete sync session." 664 739 msgstr "" 665 740 666 #: includes/class-emporiqa-webhook.php:9 15741 #: includes/class-emporiqa-webhook.php:989 667 742 msgid "Unknown error" 668 743 msgstr "" 669 744 670 #: includes/class-emporiqa-webhook.php:10 00745 #: includes/class-emporiqa-webhook.php:1074 671 746 msgid "Store ID is not configured." 672 747 msgstr "" 673 748 674 #: includes/class-emporiqa-webhook.php:10 09749 #: includes/class-emporiqa-webhook.php:1083 675 750 msgid "Connection Secret is not configured." 676 751 msgstr "" 677 752 678 #: includes/class-emporiqa-webhook.php:10 18753 #: includes/class-emporiqa-webhook.php:1092 679 754 msgid "No published products found. Add a product before testing." 680 755 msgstr "" 681 756 682 #: includes/class-emporiqa-webhook.php:1 027757 #: includes/class-emporiqa-webhook.php:1101 683 758 msgid "Failed to format product for dry run." 684 759 msgstr "" 685 760 686 #: includes/class-emporiqa-webhook.php:1058 761 #. translators: %d: number of minutes 762 #: includes/class-emporiqa-webhook.php:1123 763 #, php-format 764 msgid "This server's clock is %d minutes off. Ask your host to turn on time synchronisation (NTP): Emporiqa refuses data sent with a clock more than 5 minutes off." 765 msgstr "" 766 767 #: includes/class-emporiqa-webhook.php:1149 687 768 msgid "Connection successful, but there are warnings." 688 769 msgstr "" 689 770 690 #: includes/class-emporiqa-webhook.php:1 059771 #: includes/class-emporiqa-webhook.php:1150 691 772 msgid "Connection successful! All fields validated." 692 773 msgstr "" 693 774 694 775 #. translators: %s: human-readable error message from the Emporiqa server 695 #: includes/class-emporiqa-webhook.php:1 067776 #: includes/class-emporiqa-webhook.php:1158 696 777 #, php-format 697 778 msgid "Connection failed: %s" 698 779 msgstr "" 699 780 700 #: includes/class-emporiqa-webhook.php:1 174781 #: includes/class-emporiqa-webhook.php:1249 701 782 msgid "Webhook not configured (missing Store ID)." 702 783 msgstr "" 703 784 704 #: includes/class-emporiqa-webhook.php:1 183785 #: includes/class-emporiqa-webhook.php:1258 705 786 msgid "Connection Secret is not configured. Go to Settings > Emporiqa to set it." 706 787 msgstr "" 707 788 708 #: includes/class-emporiqa-webhook.php:1 196789 #: includes/class-emporiqa-webhook.php:1271 709 790 msgid "Failed to encode payload as JSON." 710 791 msgstr "" 711 792 712 793 #. translators: %d: HTTP status code 713 #: includes/class-emporiqa-webhook.php:1 249794 #: includes/class-emporiqa-webhook.php:1323 714 795 #, php-format 715 796 msgid "Unexpected status code: %d" -
emporiqa/trunk/readme.txt
r3725261 r3730094 2 2 Contributors: rosenhristov1 3 3 Tags: ai chatbot, live chat, woocommerce chatbot, ecommerce chatbot, ai customer support 4 Requires at least: 6. 04 Requires at least: 6.2 5 5 Tested up to: 7.1 6 6 Requires Plugins: woocommerce 7 7 Requires PHP: 7.4 8 8 WC requires at least: 8.0 9 WC tested up to: 1 0.910 Stable tag: 1. 1.109 WC tested up to: 11.1 10 Stable tag: 1.2.0 11 11 License: GPLv2 or later 12 12 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 54 54 Price, stock and status changes reach Emporiqa as you make them; a few edits need one Sync All afterwards, which the [documentation](https://emporiqa.com/docs/woocommerce/) covers. The same chatbot also runs on Drupal Commerce, Magento, PrestaShop, Shopware and Sylius. 55 55 56 = Privacy and security notes = 57 58 The signed-in shopper's token is fetched from `admin-ajax.php?action=emporiqa_customer_token`. It is read-only and returns only the current session's own identity token. It cannot be nonce-protected, because the page that would carry the nonce is full-page cached; cross-site use is blocked instead by a required custom header (which forces a CORS preflight) and a Sec-Fetch-Site / Origin check, and the answer is never cached. 59 56 60 = Third-Party Service = 57 61 58 This plugin requires an [Emporiqa](https://emporiqa.com) account and sends data there. Your catalog and synced pages go to `https://emporiqa.com/webhooks/sync/`, signed with HMAC-SHA256; the widget script loads from `https://emporiqa.com/chat/embed/`; connecting uses a PKCE handshake via `https://emporiqa.com/connect/start`. What a shopper types, and any photo uploaded for visual search, is processed by Emporiqa's AI provider and never trains AI models. If you switch on voice mode, a shopper's spoken message goes to that provider to be turned into text and the reply is read aloud by an AI voice; Emporiqa does not keep the recording. Emporiqa may call your REST API for an order status. [Terms of Service](https://emporiqa.com/terms-of-service/), [Privacy Policy](https://emporiqa.com/privacy-policy/).62 This plugin requires an [Emporiqa](https://emporiqa.com) account and sends data there. Your catalog and synced pages go to `https://emporiqa.com/webhooks/sync/`, signed with HMAC-SHA256; the widget script loads from `https://emporiqa.com/chat/embed/`; connecting uses a PKCE handshake via `https://emporiqa.com/connect/start`. What a shopper types, and any photo uploaded for visual search, is processed by Emporiqa's AI provider and never trains AI models. If you switch on voice mode, a shopper's spoken message goes to that provider to be turned into text and the reply is read aloud by an AI voice; Emporiqa does not keep the recording. Sync requests carry the plugin version (`X-Emporiqa-Plugin-Version`). If you add the Order status rule, Emporiqa calls this site's `/wp-json/emporiqa/v1/actions/order-status` with a signed request to look up the order a shopper asks about; the older order tracking address `/wp-json/emporiqa/api/order/tracking` keeps working while you use it. When a shopper is signed in to your store, the chat sends Emporiqa their WordPress user ID (signed, never their name or email), so Order status can find their own orders without an email and `/wp-json/emporiqa/v1/actions/customer-prices` can answer with the prices your shop gives that customer. Emporiqa also calls `/wp-json/emporiqa/v1/actions/verify` to check the address is yours. Each of these answers only a request signed with your store's connection secret (HMAC-SHA256, within 5 minutes), checked by the route's permission callback; the one unsigned call is the connect check, answered only during a connection you started in the last 2 minutes. [Terms of Service](https://emporiqa.com/terms-of-service/), [Privacy Policy](https://emporiqa.com/privacy-policy/). 59 63 60 64 == Installation == … … 120 124 7. Order tracking: the chat asks for an order number and matching email 121 125 8. Dashboard (demo store data): chat status, spend, revenue from chat 122 9. Billing: pay-as-you-go , $0.00 a month, $0.25 a conversation, $59 cap126 9. Billing: pay-as-you-go at $0.25 a conversation, capped at $59 a month, with the next billing date 123 127 10. Voice mode, if you switch it on: the answer is read aloud in an AI voice while the product cards stay on screen 124 128 125 129 == Changelog == 130 131 = 1.2.0 = 132 * New: Order status, a ready-made rule. Shoppers ask "where is my order?" and the chat answers from your WooCommerce orders: status, order date and tracking numbers (WooCommerce Shipment Tracking and Advanced Shipment Tracking are read when installed). A signed-in shopper only gives the order number; a guest also gives the order's email. Settings > Emporiqa shows the Order status address to copy into the rule in Emporiqa, and whether the rule is on. 133 * Changed: a signed-in shopper's identity is no longer written into the page or the chat script's URL, where a full-page cache could hand it to another shopper and server logs could keep it. The chat asks for it when it opens, from an uncached request. 134 * Changed: the old order tracking keeps working as before and stays on. Where ready-made rules are offered it moves under Advanced, with how to switch it off once the Order status rule replaces it. 135 * Changed: sync requests are also signed with Emporiqa's newer per-purpose keys and carry the plugin version; connecting proves the request came from your site; a connection Emporiqa holds for an owner's approval shows Emporiqa's own explanation. 136 * Changed: prices are sent as your shop shows them. A shop that enters prices without tax but shows them with tax (or the reverse) used to send the entered amount; prices with and without tax are now both sent when taxes are on. 137 * Fix: prices are always synced as a signed-out visitor sees them, with tax at your shop's address. A role, B2B or wholesale pricing plugin could otherwise send the price of whoever saved the product or ran the sync as the public price. The chat's cart now shows each item's price with or without tax as your cart page does. 138 * Fix: Sync All pages through products and pages by id, so a product changed during a sync can no longer be skipped and then removed from Emporiqa. 139 * Fix: an order conversion that Emporiqa could not receive after every retry is sent again at the order's next paid status. 140 * Changed: a language you did not tick under Languages (Polylang or WPML) is neither synced nor offered: its pages show no chat. 141 * New: customer prices. Emporiqa can ask this site for a signed-in shopper's own prices (`actions/customer-prices`), read by WooCommerce as that shopper with tax for their address. WooCommerce has one price per product, so this is your public price unless a role, B2B or wholesale pricing plugin gives that customer another; only prices are returned, and products the shopper cannot see are left out. 142 * New: quantity price tiers. The Tiered Price Table plugin's tiers are sent when it is active, and the `emporiqa_price_tiers` filter lets any other quantity-pricing plugin send its own (WooCommerce has none of its own); each tier is the unit price the cart charges, with tax as your shop shows prices. 143 * Changed: the signed-in shopper's token request requires a custom header and refuses cross-site requests; guests no longer reach it at all. 144 * Fix: the old order tracking reads a typed number only as an order number, never as another order's internal id. 145 * Test connection warns when this server's clock is more than 2 minutes off. 146 * Changed: a variation is sent without the description, categories and brand it shares with its product, and Emporiqa reads them from the product, so syncs send less. 147 * Fix: moving a variable product to the Trash now removes its variations from Emporiqa straight away, not only at the next Sync All. 148 * Fix: on a page served from a full-page cache long enough for its security token to expire, the chat's first add to cart no longer fails with "Security check failed"; it is retried once with a fresh token. 149 * Fix: when Emporiqa is busy and asks a sync to slow down, Sync All and the WP-CLI sync wait as asked and send the same batch again, instead of failing the sync and blocking the next one for an hour. 150 * Fix: the chat's cart actions now refuse requests sent from another website, and a guest's cart security token is tied to that guest's own cart. 151 * Fix: the Mini-Cart block of block themes (such as Twenty Twenty-Five) now updates when the chat adds or removes items; it used to show the old count until the page was reloaded. 152 * Changed: requires WordPress 6.2, the oldest version WooCommerce 8.0 runs on. The signed-in shopper's token answer is marked no-store and private on every supported WordPress version. 126 153 127 154 = 1.1.10 = … … 130 157 * Text only. No functional changes. 131 158 132 = 1.1.9 =133 * Fix: a full sync now finalizes only when every batch succeeds, so a failed batch can no longer drop items still present in your store from the chat's index. Nothing in your WooCommerce store was ever modified.134 * Fix: variable products with parent-managed stock no longer over-report quantity, and decimal quantities are no longer truncated in the cart.135 136 159 Older entries are in changelog.txt, shipped with the plugin. 137 160 138 161 == Upgrade Notice == 139 162 163 = 1.2.0 = 164 Adds the Order status rule, and keeps a signed-in shopper's identity out of cached pages. Old order tracking keeps working; nothing to do after updating. 165 140 166 = 1.1.10 = 141 167 Text-only update: the plugin's setup instructions now match the renamed Integration section of the Emporiqa dashboard. 142 143 = 1.1.9 =144 Important fix: a bulk sync that hit errors could remove valid products or pages from your Emporiqa catalog. Syncs now finalize only when every batch succeeds. Also fixes over-reported stock and truncated decimal quantities. -
emporiqa/trunk/uninstall.php
r3546004 r3730094 16 16 delete_option( 'emporiqa_pending_async_events' ); 17 17 delete_option( 'emporiqa_instance_id' ); 18 delete_option( 'emporiqa_rules_status' ); 18 19 19 20 // Clean up retry transients + one-click connect handshake transients. … … 28 29 } 29 30 30 // Clean up dispatch payload transients (1.1.3+). 31 $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_emporiqa_event_%' OR option_name LIKE '_transient_timeout_emporiqa_event_%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 31 // Clean up dispatch payload transients (1.1.3+) and the Order status 32 // rate-limit and request-replay transients (1.2.0+). 33 $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_emporiqa_event_%' OR option_name LIKE '_transient_timeout_emporiqa_event_%' OR option_name LIKE '_transient_emporiqa_arl_%' OR option_name LIKE '_transient_timeout_emporiqa_arl_%' OR option_name LIKE '_transient_emporiqa_areq_%' OR option_name LIKE '_transient_timeout_emporiqa_areq_%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 32 34 33 35 // Clean up order meta.
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)