Plugin Directory

Changeset 3730094


Ignore:
Timestamp:
10/06/2026 06:11:53 AM (3 weeks ago)
Author:
rosenhristov1
Message:

Release v1.2.0 — Order status rule, customer prices, price tiers, signed customer token

Location:
emporiqa
Files:
10 added
38 edited
1 copied

Legend:

Unmodified
Added
Removed
  • emporiqa/tags/1.2.0/admin/css/emporiqa-admin.css

    r3506790 r3730094  
    371371        color: #1d2327;
    372372}
     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  
    1212        var syncRunning   = false;
    1313        var currentXhr    = null;
     14
     15        // Consecutive waits for one batch before it counts as failed.
     16        var MAX_BATCH_WAITS = 30;
    1417
    1518        /**
    … …  
    335338                                var totalItems    = productCount + pageCount;
    336339
    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.
    338343                                var workQueue    = [];
    339344                                var sessionCount = sessions.length;
    340                                 var s, itemCount, totalPages, p;
     345                                var s;
    341346
    342347                                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 );
    351348                                        addLogEntry(
    352349                                                sprintf(
    … …  
    357354                                        );
    358355
    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                                        );
    370366                                }
    371367
    … …  
    401397
    402398                                        var work = workQueue[ batchIndex ];
    403                                         batchIndex++;
    404399
    405400                                        currentXhr = $.post(
    … …  
    411406                                                        language: work.language,
    412407                                                        session_id: work.session_id,
    413                                                         page: work.page,
     408                                                        after_id: work.after_id,
    414409                                                        items_per_batch: work.items_per_batch
    415410                                                }
    416411                                        ).done(
    417412                                                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
    418431                                                        if ( batchResponse.success && batchResponse.data ) {
    419432                                                                var batchProcessed = batchResponse.data.processed || 0;
    … …  
    456469
    457470                                                        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;
    459480                                                        }
    460481
    … …  
    475496                                                                );
    476497                                                        }
     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++;
    477502                                                        processBatch();
    478503                                                }
  • emporiqa/tags/1.2.0/assets/js/emporiqa-cart.js

    r3491860 r3730094  
    3535         * Automatically updates the nonce from each response to keep
    3636         * 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.
    4044         * @return {Promise<Object>} Parsed JSON response matching the EmporiqaCartHandler spec.
    4145         */
    42         function emporiqaAjax( action, data ) {
     46        function emporiqaAjax( action, data, retried ) {
    4347                var formData = new FormData();
    4448                formData.append( 'action', action );
    … …  
    6266                                config.nonce = result.nonce;
    6367                        }
    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 ) {
    7285                if ( typeof jQuery !== 'undefined' ) {
    7386                        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                        );
    7492                }
    7593        }
    … …  
    153171                return emporiqaAjax( 'emporiqa_remove_from_cart', data ).then( function( result ) {
    154172                        if ( result.success ) {
    155                                 refreshCartFragments();
     173                                refreshCartFragments( true );
    156174                        }
    157175                        return result;
    … …  
    167185                return emporiqaAjax( 'emporiqa_clear_cart' ).then( function( result ) {
    168186                        if ( result.success ) {
    169                                 refreshCartFragments();
     187                                refreshCartFragments( true );
    170188                        }
    171189                        return result;
  • emporiqa/tags/1.2.0/changelog.txt

    r3678094 r3730094  
    22
    33Older 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.
    48
    59= 1.1.8 =
  • emporiqa/tags/1.2.0/cli/class-emporiqa-cli.php

    r3601468 r3730094  
    8989                $synced   = 0;
    9090                $errors   = 0;
    91                 $page     = 1;
     91                $after_id = 0;
    9292                $per_page = 100;
    9393
     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).
    9497                while ( true ) {
    9598                        $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,
    100105                        );
    101106
    … …  
    113118                        }
    114119
    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                        );
    116125                        if ( empty( $products ) ) {
    117126                                break;
    … …  
    119128
    120129                        foreach ( $products as $product ) {
     130                                $after_id  = max( $after_id, (int) $product->get_id() );
    121131                                $formatted = $formatter->format_product_consolidated( $product, $session_id );
    122132                                foreach ( $formatted as $item ) {
    … …  
    142152
    143153                        wp_cache_flush();
    144                         ++$page;
    145154                }
    146155
    … …  
    233242                $synced   = 0;
    234243                $errors   = 0;
    235                 $paged    = 1;
     244                $after_id = 0;
    236245                $per_page = 100;
    237246
    238247                while ( true ) {
    239248                        $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,
    245257                        );
    246258
    … …  
    252264                        }
    253265
    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                        );
    255271                        if ( empty( $pages ) ) {
    256272                                break;
    … …  
    258274
    259275                        foreach ( $pages as $page ) {
     276                                $after_id  = max( $after_id, (int) $page->ID );
    260277                                $formatted = $formatter->format_page_consolidated( $page, $session_id );
    261278                                if ( ! empty( $formatted ) ) {
    … …  
    281298
    282299                        wp_cache_flush();
    283                         ++$paged;
    284300                }
    285301
  • emporiqa/tags/1.2.0/emporiqa.php

    r3678232 r3730094  
    33 * Plugin Name: Emporiqa
    44 * Description: Integrates WooCommerce with Emporiqa chat assistant.
    5  * Version:     1.1.10
     5 * Version:     1.2.0
    66 * Author:      Emporiqa
    77 * Author URI:  https://emporiqa.com
    … …  
    1010 * Text Domain: emporiqa
    1111 * Domain Path: /languages
    12  * Requires at least: 6.0
     12 * Requires at least: 6.2
    1313 * Tested up to: 7.1
    1414 * Requires PHP: 7.4
    1515 * Requires Plugins: woocommerce
    1616 * WC requires at least: 8.0
    17  * WC tested up to: 10.9
     17 * WC tested up to: 11.1
    1818 *
    1919 * @package Emporiqa
    … …  
    2525}
    2626
    27 define( 'EMPORIQA_VERSION', '1.1.10' );
     27define( 'EMPORIQA_VERSION', '1.2.0' );
    2828define( 'EMPORIQA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    2929define( 'EMPORIQA_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
    … …  
    3636require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-widget.php';
    3737require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-order-tracking.php';
     38require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-signature.php';
     39require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-order-status.php';
     40require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-customer-prices.php';
     41require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-actions.php';
    3842require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-cart-ajax.php';
    3943require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-order-webhook.php';
    … …  
    9498         */
    9599        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;
    96107
    97108        /**
    … …  
    206217                $this->widget         = new Emporiqa_Widget();
    207218                $this->order_tracking = new Emporiqa_Order_Tracking();
     219                $this->actions        = new Emporiqa_Actions();
    208220                $this->cart_ajax      = new Emporiqa_Cart_Ajax();
    209221                $this->order_webhook  = new Emporiqa_Order_Webhook();
    … …  
    229241        private function init_hooks(): void {
    230242                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 );
    231246
    232247                // Page-save entry point. Inside the handler we skip when Polylang is
    … …  
    269284                add_action( 'woocommerce_new_product', array( $this, 'handle_product_created' ) );
    270285                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 );
    272289                add_action( 'untrashed_post', array( $this, 'handle_product_untrashed' ) );
    273290                add_action( 'woocommerce_new_product_variation', array( $this, 'handle_variation_created' ) );
    … …  
    449466         * wp_trash_post fires when a product is moved to trash. WooCommerce's
    450467         * 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.
    453471         *
    454472         * @since 1.0.0
  • emporiqa/tags/1.2.0/includes/class-emporiqa-admin.php

    r3678094 r3730094  
    3434         */
    3535        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';
    3641
    3742        /**
    … …  
    156161                }
    157162
     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
    158172                $batch_size = absint( $input['batch_size'] ?? 25 );
    159173                if ( $batch_size < 1 || $batch_size > 100 ) {
    … …  
    242256
    243257                                        <?php $this->render_connection_section( $options ); ?>
     258                                        <?php $this->render_rules_section( $options ); ?>
    244259                                        <?php $this->render_advanced_section( $options ); ?>
    245260
    … …  
    338353                }
    339354
    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                }
    341360                $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                );
    342514        }
    343515
    … …  
    644816                                </td>
    645817                        </tr>
     818                        <?php
     819                        if ( self::rules_status()['available'] ) {
     820                                $this->render_legacy_order_tracking_rows( $options );
     821                        }
     822                        ?>
    646823                        <tr>
    647824                                <th scope="row"><label for="webhook_url"><?php esc_html_e( 'Emporiqa server URL', 'emporiqa' ); ?></label></th>
    … …  
    10671244                                        /* translators: %s: entity type */
    10681245                                        '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' ),
    10691248                                        /* translators: %s: entity type (products/pages) */
    10701249                                        'sessionComplete' => __( 'Completed %s sync session', 'emporiqa' ),
    … …  
    11841363                        'page_post_types'   => array( 'page' ),
    11851364                        'batch_size'        => 25,
     1365                        'order_tracking'    => true,
    11861366                );
    11871367        }
  • emporiqa/tags/1.2.0/includes/class-emporiqa-cart-ajax.php

    r3601468 r3730094  
    2626         * Nonce action used for all cart AJAX requests.
    2727         *
     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         *
    2832         * @since 1.0.0
    2933         * @var string
    3034         */
    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';
    3252
    3353        /**
    … …  
    6585         */
    6686        public function add_to_cart(): void {
     87                $this->refuse_cross_site();
    6788                $this->verify_nonce();
    6889                $this->ensure_cart_loaded();
    … …  
    174195         */
    175196        public function update_cart(): void {
     197                $this->refuse_cross_site();
    176198                $this->verify_nonce();
    177199                $this->ensure_cart_loaded();
    … …  
    215237         */
    216238        public function remove_from_cart(): void {
     239                $this->refuse_cross_site();
    217240                $this->verify_nonce();
    218241                $this->ensure_cart_loaded();
    … …  
    248271         */
    249272        public function clear_cart(): void {
     273                $this->refuse_cross_site();
    250274                $this->verify_nonce();
    251275                $this->ensure_cart_loaded();
    … …  
    314338        private function verify_nonce(): void {
    315339                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 );
    318379        }
    319380
    … …  
    352413                                'name'         => $product->get_name(),
    353414                                'quantity'     => self::parse_quantity( $item['quantity'] ),
    354                                 'unit_price'   => (float) $product->get_price(),
     415                                'unit_price'   => $this->displayed_unit_price( $product ),
    355416                                'image_url'    => $image_url ? $image_url : null,
    356417                                'product_url'  => get_permalink( $item['product_id'] ),
    … …  
    364425                        'currency'    => emporiqa_get_active_currency(),
    365426                );
     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() );
    366448        }
    367449
    … …  
    400482         *
    401483         * @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 {
    404487                wp_send_json(
    405488                        array(
    406489                                'success'     => false,
    407490                                'error'       => $message,
     491                                'code'        => $code,
    408492                                'checkoutUrl' => $this->get_checkout_url(),
    409493                                'unit_price'  => 0,
  • emporiqa/tags/1.2.0/includes/class-emporiqa-connect.php

    r3601468 r3730094  
    6666
    6767        /**
     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        /**
    6883         * Constructor — wires the admin_init handler that routes
    6984         * ?emporiqa_action= requests.
    … …  
    214229                        $this->base_url() . '/connect/exchange',
    215230                        array(
    216                                 'timeout' => 10,
     231                                'timeout' => self::EXCHANGE_TIMEOUT,
    217232                                'headers' => array(
    218233                                        'Content-Type'        => 'application/json',
    … …  
    222237                                'body'    => wp_json_encode(
    223238                                        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(),
    227243                                        )
    228244                                ),
    229245                        )
    230246                );
     247                // The exchange has returned: the origin proof can no longer be asked for.
     248                $this->finish_nonce( $state );
    231249
    232250                if ( is_wp_error( $resp ) ) {
    … …  
    244262                $status = (int) wp_remote_retrieve_response_code( $resp );
    245263                $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                }
    246271
    247272                if ( 200 !== $status || ! is_array( $body ) || empty( $body['webhook_secret'] ) || empty( $body['store_id'] ) ) {
    … …  
    257282                        $this->redirect_to_settings();
    258283                }
     284
     285                // Whether Emporiqa offers ready-made rules to this store, and which are live.
     286                Emporiqa_Admin::store_rules_status( $body );
    259287
    260288                $this->persist_credentials(
    … …  
    414442
    415443        /**
    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.
    418448         *
    419449         * @param string $state Public state from the callback.
    … …  
    422452                $key  = self::NONCE_PREFIX . hash( 'sha256', $state );
    423453                $data = get_transient( $key );
    424                 if ( ! is_array( $data ) || empty( $data['verifier'] ) ) {
     454                if ( ! is_array( $data ) || empty( $data['verifier'] ) || ! empty( $data['exchanging_at'] ) ) {
    425455                        return null;
    426456                }
    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                }
    429461                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 ) );
    430491        }
    431492
  • emporiqa/tags/1.2.0/includes/class-emporiqa-formatter.php

    r3601468 r3730094  
    3939         */
    4040        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;
    4148
    4249        /**
    … …  
    416423                        $brand_data[ $channel ] = $this->get_product_brand( $product, $languages[0] );
    417424
    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 )
    478429                        );
    479430
    … …  
    673624                $var_images = array_values( array_unique( $var_images ) );
    674625
    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 );
    677627
    678628                $names        = array();
    679                 $descriptions = array();
    680629                $links        = array();
    681630                $attributes   = array();
    682                 $cat_data     = array();
    683                 $brand_data   = array();
    684631                $price_data   = array();
    685632                $avail_data   = array();
    … …  
    689636                $max_qty_data = array();
    690637
     638                // Descriptions, categories and brand are not built here: Emporiqa stores a
     639                // variation as a lean row and reads them from the parent.
    691640                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();
    697644
    698645                        foreach ( $languages as $lang ) {
    … …  
    705652                                $names[ $channel ][ $lang ] = $title;
    706653
    707                                 $descriptions[ $channel ][ $lang ] = $this->get_translated_variation_description( $variation, $parent_product, $lang );
    708 
    709654                                $links[ $channel ][ $lang ] = $this->get_translated_link( $parent_product, $lang );
    710655
    711656                                $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
    717659                        $this->apply_translation_fallbacks( $names[ $channel ], $languages );
    718                         $this->apply_translation_fallbacks( $descriptions[ $channel ], $languages );
    719660                        $this->apply_translation_fallbacks( $links[ $channel ], $languages );
    720661
    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 );
    730663
    731664                        $avail_data[ $channel ] = $this->get_availability_status( $variation );
    … …  
    744677                        'channels'              => $channels,
    745678                        'names'                 => $names,
    746                         'descriptions'          => $descriptions,
    747679                        'links'                 => $links,
    748680                        'attributes'            => $attributes,
    749                         'categories'            => $cat_data,
    750                         'brands'                => $brand_data,
    751681                        'prices'                => $price_data,
    752682                        'availability_statuses' => $avail_data,
    … …  
    759689                        'is_virtual'            => $variation->is_virtual(),
    760690                        'parent_sku'            => $parent_sku,
    761                         'is_parent'             => false,
    762                         'variation_attributes'  => new \stdClass(),
    763691                );
    764692
    … …  
    791719         * @return array List of channel identifiers.
    792720         */
    793         private function get_product_channels( \WC_Product $product ): array {
     721        public function get_product_channels( \WC_Product $product ): array {
    794722                /**
    795723                 * Filter the product sales channels.
    … …  
    978906
    979907                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;
    9801204        }
    9811205
    … …  
    12441468
    12451469        /**
    1246          * Get the translated variation description, falling back to parent description.
    1247          *
    1248          * @since 1.0.0
    1249          *
    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 string
    1254          */
    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         /**
    12851470         * Get the translated product title.
    12861471         *
  • emporiqa/tags/1.2.0/includes/class-emporiqa-order-tracking.php

    r3506790 r3730094  
    66 * order data. Requests are authenticated via HMAC-SHA256 using the same
    77 * 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).
    813 *
    914 * @package Emporiqa
    … …  
    6570
    6671        /**
     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        /**
    6783         * Verify HMAC signature and timestamp for the incoming request.
    6884         *
    … …  
    143159         */
    144160        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 {
    145184                // H8: Rate limiting per IP.
    146185                $rate_check = $this->check_rate_limit();
    … …  
    161200                }
    162201
    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                }
    164209                $raw_verification        = $payload['verification_fields'] ?? array();
    165210                $verification_fields     = is_array( $raw_verification ) ? array_map( 'sanitize_text_field', $raw_verification ) : array();
    … …  
    234279         */
    235280        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 );
    237284
    238285                // Also try searching by order number (custom order number plugins).
    … …  
    245292                        );
    246293                        $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 ) {
    250302                        return new WP_REST_Response(
    251303                                array( 'error' => __( 'Order not found.', 'emporiqa' ) ),
  • emporiqa/tags/1.2.0/includes/class-emporiqa-order-webhook.php

    r3549190 r3730094  
    256256                        $url,
    257257                        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 ),
    262259                                'body'     => $payload,
    263260                                'timeout'  => 5,
    … …  
    340337                                $this->log( "order.completed webhook failed for order #{$order_id}. Retry #{$attempt} in {$delay}s." );
    341338                        } 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();
    342343                                $this->log( "order.completed webhook permanently failed for order #{$order_id} after {$attempt} attempt(s)." );
    343344                        }
  • emporiqa/tags/1.2.0/includes/class-emporiqa-sync.php

    r3601468 r3730094  
    150150         * AJAX handler: process a single batch of items.
    151151         *
    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.
    154158         *
    155159         * @since 1.0.0
    … …  
    164168                $entity          = sanitize_text_field( wp_unslash( $_POST['entity'] ?? '' ) );
    165169                $session_id      = sanitize_text_field( wp_unslash( $_POST['session_id'] ?? '' ) );
    166                 $page            = absint( $_POST['page'] ?? 1 );
     170                $after_id        = absint( $_POST['after_id'] ?? 0 );
    167171                $items_per_batch = absint( $_POST['items_per_batch'] ?? self::ITEMS_PER_BATCH );
    168172
    … …  
    175179                        'events'    => 0,
    176180                        'success'   => true,
     181                        'last_id'   => $after_id,
     182                        'done'      => true,
    177183                );
    178184
    179185                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 );
    181187                } 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 );
    183189                }
    184190
    185191                // 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'] ) );
    188196
    189197                wp_send_json_success( $result );
    … …  
    235243                }
    236244
     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
    237258                if ( $state['synced'] < 1 ) {
    238259                        wp_send_json_error(
    … …  
    283304         * Mirrors the Drupal reference: a failed batch send reclassifies the
    284305         * 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.
    285309         *
    286310         * @since 1.1.9
    … …  
    289313         * @param int    $processed  Items processed in this batch.
    290314         * @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 {
    293319                $state = $this->get_session_state( $session_id );
    294320                if ( ! is_array( $state ) ) {
    … …  
    296322                }
    297323
     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
    298331                if ( $success ) {
    299332                        $state['synced'] += $processed;
    300                 } else {
     333                } elseif ( ! $wait ) {
    301334                        $state['errors'] += max( 1, $processed );
    302335                }
    … …  
    422455         *
    423456         * @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 {
    429462                $formatter = emporiqa()->get_formatter();
    430463                $webhook   = emporiqa()->get_webhook();
    431464
    432465                $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(),
    437471                );
    438472
    … …  
    450484                }
    451485
    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                );
    453493                $events    = array();
    454494                $processed = count( $products );
     495                $last_id   = $after_id;
    455496
    456497                foreach ( $products as $product ) {
     498                        $last_id   = max( $last_id, (int) $product->get_id() );
    457499                        $formatted = $formatter->format_product_consolidated( $product, $session_id );
    458500                        foreach ( $formatted as $item ) {
    … …  
    464506                }
    465507
    466                 $success = true;
    467                 $error   = null;
     508                $success     = true;
     509                $error       = null;
     510                $retry_after = null;
    468511                if ( ! empty( $events ) ) {
    469512                        $success = $webhook->send_batch_events( $events );
    470513                        if ( ! $success ) {
    471                                 $error = $webhook->get_last_error();
     514                                $error       = $webhook->get_last_error();
     515                                $retry_after = $webhook->get_last_retry_after();
    472516                        }
    473517                }
    … …  
    477521                        'events'    => count( $events ),
    478522                        'success'   => $success,
     523                        'last_id'   => $last_id,
     524                        'done'      => $processed < $items_per_batch,
    479525                );
    480526                if ( ! $success && $error ) {
    481527                        $out['error'] = $error;
    482528                }
     529                if ( null !== $retry_after ) {
     530                        $out['retry_after'] = $retry_after;
     531                }
    483532
    484533                return $out;
    … …  
    491540         *
    492541         * @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 {
    498547                $post_types = Emporiqa_Admin::get_option( 'page_post_types', array( 'page' ) );
    499548
    … …  
    504553                                'events'    => 0,
    505554                                'success'   => true,
     555                                'last_id'   => $after_id,
     556                                'done'      => true,
    506557                        );
    507558                }
    … …  
    514565                        'post_status'      => 'publish',
    515566                        'posts_per_page'   => $items_per_batch,
    516                         'paged'            => $page,
     567                        'orderby'          => 'ID',
     568                        'order'            => 'ASC',
     569                        'no_found_rows'    => true,
    517570                        'suppress_filters' => false,
    518571                );
    … …  
    525578                }
    526579
    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                );
    528587                $events    = array();
    529588                $processed = count( $pages );
     589                $last_id   = $after_id;
    530590
    531591                foreach ( $pages as $page_post ) {
     592                        $last_id   = max( $last_id, (int) $page_post->ID );
    532593                        $formatted = $formatter->format_page_consolidated( $page_post, $session_id );
    533594                        if ( ! empty( $formatted ) ) {
    … …  
    539600                }
    540601
    541                 $success = true;
    542                 $error   = null;
     602                $success     = true;
     603                $error       = null;
     604                $retry_after = null;
    543605                if ( ! empty( $events ) ) {
    544606                        $success = $webhook->send_batch_events( $events );
    545607                        if ( ! $success ) {
    546                                 $error = $webhook->get_last_error();
     608                                $error       = $webhook->get_last_error();
     609                                $retry_after = $webhook->get_last_retry_after();
    547610                        }
    548611                }
    … …  
    552615                        'events'    => count( $events ),
    553616                        'success'   => $success,
     617                        'last_id'   => $last_id,
     618                        'done'      => $processed < $items_per_batch,
    554619                );
    555620                if ( ! $success && $error ) {
    556621                        $out['error'] = $error;
    557622                }
     623                if ( null !== $retry_after ) {
     624                        $out['retry_after'] = $retry_after;
     625                }
    558626
    559627                return $out;
    560628        }
     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        }
    561658}
  • emporiqa/tags/1.2.0/includes/class-emporiqa-webhook.php

    r3560454 r3730094  
    9898
    9999        /**
     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        /**
    100132         * Connect-phase budget (ms) to install on the in-flight cURL handle
    101133         * via http_api_curl. Set just before wp_remote_post in do_request(),
    … …  
    657689                                // (cURL) or by apply_fallback_request_timeout (Streams).
    658690                                '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 ),
    663692                                'body'     => $json,
    664693                        )
    … …  
    835864                $result  = $this->do_request( $payload, false, $timeout );
    836865
     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
    837878                if ( ! $result['success'] ) {
    838879                        $this->last_error = $this->build_friendly_error( $result );
    … …  
    864905        public function get_last_error(): ?string {
    865906                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 ) );
    866940        }
    867941
    … …  
    10401114                $result = $this->do_request( array( 'events' => $events ), true );
    10411115
     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
    10421128                if ( $result['success'] ) {
    10431129                        $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                        }
    10441135
    10451136                        $has_warnings = false;
    … …  
    10551146                        return array(
    10561147                                'success' => true,
    1057                                 'message' => $has_warnings
     1148                                'message' => ( $has_warnings
    10581149                                        ? __( 'Connection successful, but there are warnings.', 'emporiqa' )
    1059                                         : __( 'Connection successful! All fields validated.', 'emporiqa' ),
     1150                                        : __( 'Connection successful! All fields validated.', 'emporiqa' ) ) . $clock_warning,
    10601151                                'dry_run' => $dry_run_data,
    10611152                        );
    … …  
    10651156                        'success' => false,
    10661157                        /* 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,
    10681159                        'dry_run' => $result['response'],
    10691160                );
    … …  
    11351226
    11361227        /**
    1137          * Generate an HMAC-SHA256 signature for a payload.
    1138          *
    1139          * @since 1.0.0
    1140          *
    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         /**
    11531228         * Send an HTTP POST request to the webhook endpoint.
    11541229         *
    … …  
    11991274                }
    12001275
    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 );
    12051277
    12061278                // Hook-driven (AS worker) sends get the tight handshake cap; admin-
    … …  
    12371309                $status_code = wp_remote_retrieve_response_code( $response );
    12381310                $body        = json_decode( wp_remote_retrieve_body( $response ), true );
     1311                $clock_skew  = self::clock_skew( (string) wp_remote_retrieve_header( $response, 'date' ), time() );
    12391312
    12401313                if ( in_array( $status_code, array( 200, 201, 202 ), true ) ) {
    12411314                        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,
    12451319                        );
    12461320                }
    … …  
    12511325
    12521326                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' ) ),
    12561332                );
     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;
    12571374        }
    12581375
  • emporiqa/tags/1.2.0/includes/class-emporiqa-widget.php

    r3549215 r3730094  
    1313 * - currency: ISO 4217 currency code (e.g. EUR, USD)
    1414 * - 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.
    1621 *
    1722 * @package Emporiqa
    … …  
    3136
    3237        /**
     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        /**
    3350         * Register hooks.
    3451         *
    … …  
    3855                add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_widget' ) );
    3956                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' ) );
    4059        }
    4160
    … …  
    6786                }
    6887
     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
    6993                $query_params = array(
    7094                        'store_id' => $store_id,
    71                         'language' => $this->get_current_language(),
     95                        'language' => $language,
    7296                );
    7397
    … …  
    92116                }
    93117
    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_secret
    100                                 );
    101                                 if ( ! empty( $user_token ) ) {
    102                                         $query_params['user_id'] = $user_token;
    103                                 }
    104                         }
    105                 }
    106 
    107118                /**
    108119                 * Filter the widget embed query parameters.
    … …  
    110121                 * @since 1.0.0
    111122                 *
    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).
    113124                 */
    114125                $query_params = apply_filters( 'emporiqa_widget_params', $query_params );
    … …  
    121132                wp_enqueue_script( 'emporiqa-widget', $widget_url, array(), EMPORIQA_VERSION, true );
    122133                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'] : '' ) );
    123260        }
    124261
    … …  
    152289
    153290        /**
    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 );
    179312        }
    180313
  • emporiqa/tags/1.2.0/languages/emporiqa.pot

    r3678094 r3730094  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Emporiqa 1.1.10\n"
     5"Project-Id-Version: Emporiqa 1.2.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/emporiqa\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"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"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    1818#. Author of the plugin
    1919#: emporiqa.php
    20 #: emporiqa.php:1061
    21 #: emporiqa.php:1110
    22 #: includes/class-emporiqa-admin.php:59
     20#: emporiqa.php:1079
     21#: emporiqa.php:1128
     22#: includes/class-emporiqa-admin.php:64
    2323msgid "Emporiqa"
    2424msgstr ""
    … …  
    3434msgstr ""
    3535
    36 #: emporiqa.php:1062
     36#: emporiqa.php:1080
    3737msgid "requires WooCommerce to be installed and active."
    3838msgstr ""
    3939
    40 #: emporiqa.php:1111
     40#: emporiqa.php:1129
    4141msgid "requires WooCommerce 8.0 or higher. Some features may not work correctly with your current version."
    4242msgstr ""
    4343
    44 #: emporiqa.php:1128
     44#: emporiqa.php:1146
    4545msgid "Emporiqa activated!"
    4646msgstr ""
    4747
    48 #: emporiqa.php:1129
     48#: emporiqa.php:1147
    4949msgid "Connect your store to start syncing products and pages."
    5050msgstr ""
    5151
    52 #: emporiqa.php:1131
     52#: emporiqa.php:1149
    5353msgid "Go to Settings →"
    5454msgstr ""
    5555
    56 #: includes/class-emporiqa-admin.php:58
     56#: includes/class-emporiqa-admin.php:63
    5757msgid "Emporiqa Settings"
    5858msgstr ""
    5959
    60 #: includes/class-emporiqa-admin.php:78
    61 #: includes/class-emporiqa-admin.php:225
     60#: includes/class-emporiqa-admin.php:83
     61#: includes/class-emporiqa-admin.php:239
    6262msgid "Settings"
    6363msgstr ""
    6464
    6565#. translators: %s: link to Sync tab
    66 #: includes/class-emporiqa-admin.php:101
     66#: includes/class-emporiqa-admin.php:106
    6767#, php-format
    6868msgid "To sync your products and pages with Emporiqa, go to the <a href=\"%s\">Sync tab</a>."
    6969msgstr ""
    7070
    71 #: includes/class-emporiqa-admin.php:168
     71#: includes/class-emporiqa-admin.php:182
    7272msgid "Connection Secret is required. Webhooks and user identification will not work without it."
    7373msgstr ""
    7474
    7575#. translators: %s: link to Emporiqa dashboard integration overview
    76 #: includes/class-emporiqa-admin.php:197
     76#: includes/class-emporiqa-admin.php:211
    7777#, php-format
    7878msgid "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 &rarr; Settings &rarr; Integration &rarr; Integration overview</a>."
    7979msgstr ""
    8080
    81 #: includes/class-emporiqa-admin.php:228
     81#: includes/class-emporiqa-admin.php:242
    8282msgid "Sync"
    8383msgstr ""
    8484
    85 #: includes/class-emporiqa-admin.php:301
     85#: includes/class-emporiqa-admin.php:316
    8686msgid "Connection Settings"
    8787msgstr ""
    8888
    89 #: includes/class-emporiqa-admin.php:311
     89#: includes/class-emporiqa-admin.php:326
    9090msgid "Connected."
    9191msgstr ""
    9292
    93 #: includes/class-emporiqa-admin.php:312
     93#: includes/class-emporiqa-admin.php:327
    9494msgid "Your store is now linked to Emporiqa. Use the Sync tab to send your catalog."
    9595msgstr ""
    9696
    97 #: includes/class-emporiqa-admin.php:355
     97#: includes/class-emporiqa-admin.php:380
     98msgid "Ready-made rules"
     99msgstr ""
     100
     101#: includes/class-emporiqa-admin.php:383
     102msgid "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."
     103msgstr ""
     104
     105#: includes/class-emporiqa-admin.php:387
     106msgid "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."
     107msgstr ""
     108
     109#: includes/class-emporiqa-admin.php:392
     110msgid "Order status address"
     111msgstr ""
     112
     113#: includes/class-emporiqa-admin.php:398
     114#: includes/class-emporiqa-admin.php:453
     115#: includes/class-emporiqa-admin.php:709
     116msgid "Copy"
     117msgstr ""
     118
     119#: includes/class-emporiqa-admin.php:401
     120msgid "Emporiqa fills this in for you when you add the Order status rule. If it ever asks for the address, use this one."
     121msgstr ""
     122
     123#: includes/class-emporiqa-admin.php:403
     124msgid "Your site is not served over https, so Emporiqa cannot call it. Switch the site to https (Settings > General), then come back here."
     125msgstr ""
     126
     127#: includes/class-emporiqa-admin.php:408
     128msgid "Order status"
     129msgstr ""
     130
     131#: includes/class-emporiqa-admin.php:411
     132msgid "On"
     133msgstr ""
     134
     135#: includes/class-emporiqa-admin.php:413
     136msgid "Not added"
     137msgstr ""
     138
     139#: includes/class-emporiqa-admin.php:416
     140msgid "Open in Emporiqa"
     141msgstr ""
     142
     143#: includes/class-emporiqa-admin.php:420
     144msgid "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."
     145msgstr ""
     146
     147#: includes/class-emporiqa-admin.php:423
     148msgid "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."
     149msgstr ""
     150
     151#: includes/class-emporiqa-admin.php:442
     152msgid "Order tracking (old)"
     153msgstr ""
     154
     155#: includes/class-emporiqa-admin.php:448
     156msgid "Answer order lookups at the old order tracking address."
     157msgstr ""
     158
     159#: includes/class-emporiqa-admin.php:457
     160msgid "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."
     161msgstr ""
     162
     163#: includes/class-emporiqa-admin.php:527
    98164msgid "Connect to Emporiqa in one click"
    99165msgstr ""
    100166
    101 #: includes/class-emporiqa-admin.php:358
     167#: includes/class-emporiqa-admin.php:530
    102168msgid "We'll sign you in, link this store, and send back a fresh connection secret. No copy-pasting."
    103169msgstr ""
    104170
    105 #: includes/class-emporiqa-admin.php:371
     171#: includes/class-emporiqa-admin.php:543
    106172msgid "Connect to Emporiqa"
    107173msgstr ""
    108174
    109 #: includes/class-emporiqa-admin.php:376
    110 #: includes/class-emporiqa-connect.php:133
     175#: includes/class-emporiqa-admin.php:548
     176#: includes/class-emporiqa-connect.php:148
    111177msgid "Your site must be served over HTTPS to use one-click connect."
    112178msgstr ""
    113179
    114 #: includes/class-emporiqa-admin.php:383
     180#: includes/class-emporiqa-admin.php:555
    115181msgid "I'd rather paste credentials manually"
    116182msgstr ""
    117183
    118 #: includes/class-emporiqa-admin.php:400
     184#: includes/class-emporiqa-admin.php:572
    119185msgid "Connection in progress."
    120186msgstr ""
    121187
    122 #: includes/class-emporiqa-admin.php:401
     188#: includes/class-emporiqa-admin.php:573
    123189msgid "Finish the steps on emporiqa.com, then come back to this tab."
    124190msgstr ""
    125191
    126192#. translators: %s: link text for "start over"
    127 #: includes/class-emporiqa-admin.php:407
     193#: includes/class-emporiqa-admin.php:579
    128194#, php-format
    129195msgid "Tab got stuck? %s."
    130196msgstr ""
    131197
    132 #: includes/class-emporiqa-admin.php:411
     198#: includes/class-emporiqa-admin.php:583
    133199msgid "Start over"
    134200msgstr ""
    135201
    136 #: includes/class-emporiqa-admin.php:432
     202#: includes/class-emporiqa-admin.php:604
    137203msgid "Connected to Emporiqa."
    138204msgstr ""
    139205
    140 #: includes/class-emporiqa-admin.php:436
    141 #: includes/class-emporiqa-admin.php:480
     206#: includes/class-emporiqa-admin.php:608
     207#: includes/class-emporiqa-admin.php:652
    142208msgid "Store ID"
    143209msgstr ""
    144210
    145 #: includes/class-emporiqa-admin.php:442
     211#: includes/class-emporiqa-admin.php:614
    146212msgid "Connection secret"
    147213msgstr ""
    148214
    149 #: includes/class-emporiqa-admin.php:444
     215#: includes/class-emporiqa-admin.php:616
    150216msgid "Stored. Reconnect to rotate."
    151217msgstr ""
    152218
    153 #: includes/class-emporiqa-admin.php:450
     219#: includes/class-emporiqa-admin.php:622
    154220msgid "Reconnect"
    155221msgstr ""
    156222
    157 #: includes/class-emporiqa-admin.php:453
     223#: includes/class-emporiqa-admin.php:625
    158224msgid "Open Emporiqa dashboard"
    159225msgstr ""
    160226
    161 #: includes/class-emporiqa-admin.php:461
     227#: includes/class-emporiqa-admin.php:633
    162228msgid "Edit credentials manually"
    163229msgstr ""
    164230
    165231#. translators: %s: link to Emporiqa dashboard integration overview
    166 #: includes/class-emporiqa-admin.php:490
     232#: includes/class-emporiqa-admin.php:662
    167233#, php-format
    168234msgid "Find it in your <a href=\"%s\" target=\"_blank\">Emporiqa dashboard &rarr; Settings &rarr; Integration &rarr; Integration overview</a>"
    169235msgstr ""
    170236
    171 #: includes/class-emporiqa-admin.php:499
     237#: includes/class-emporiqa-admin.php:671
    172238msgid "Connection Secret"
    173239msgstr ""
    174240
    175 #: includes/class-emporiqa-admin.php:505
     241#: includes/class-emporiqa-admin.php:677
    176242msgid "Value is set (leave empty to keep)"
    177243msgstr ""
    178244
    179 #: includes/class-emporiqa-admin.php:511
     245#: includes/class-emporiqa-admin.php:683
    180246msgid "Not configured"
    181247msgstr ""
    182248
    183 #: includes/class-emporiqa-admin.php:516
     249#: includes/class-emporiqa-admin.php:688
    184250msgid "Required. Secret key for HMAC signature. Leave empty to keep the current value."
    185251msgstr ""
    186252
    187 #: includes/class-emporiqa-admin.php:532
     253#: includes/class-emporiqa-admin.php:704
    188254msgid "Order Tracking API URL"
    189255msgstr ""
    190256
    191 #: includes/class-emporiqa-admin.php:537
    192 msgid "Copy"
    193 msgstr ""
    194 
    195257#. translators: %s: link to Emporiqa dashboard order tracking settings
    196 #: includes/class-emporiqa-admin.php:545
     258#: includes/class-emporiqa-admin.php:717
    197259#, php-format
    198260msgid "Optional. To enable order tracking in the chat, paste this URL into your <a href=\"%s\" target=\"_blank\">Emporiqa dashboard &rarr; Settings &rarr; Integration &rarr; 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."
    199261msgstr ""
    200262
    201 #: includes/class-emporiqa-admin.php:569
     263#: includes/class-emporiqa-admin.php:741
    202264msgid "Advanced"
    203265msgstr ""
    204266
    205 #: includes/class-emporiqa-admin.php:575
     267#: includes/class-emporiqa-admin.php:747
    206268msgid "The defaults work for most stores. Open this only if you want to change what's sent to Emporiqa."
    207269msgstr ""
    208270
    209 #: includes/class-emporiqa-admin.php:579
     271#: includes/class-emporiqa-admin.php:751
    210272msgid "Sync products"
    211273msgstr ""
    212274
    213 #: includes/class-emporiqa-admin.php:584
     275#: includes/class-emporiqa-admin.php:756
    214276msgid "Send new and updated products to Emporiqa so the chat can recommend them."
    215277msgstr ""
    216278
    217 #: includes/class-emporiqa-admin.php:589
     279#: includes/class-emporiqa-admin.php:761
    218280msgid "Sync pages"
    219281msgstr ""
    220282
    221 #: includes/class-emporiqa-admin.php:594
     283#: includes/class-emporiqa-admin.php:766
    222284msgid "Send pages and blog content so the chat can answer questions about your shipping, returns, FAQs, and similar."
    223285msgstr ""
    224286
    225 #: includes/class-emporiqa-admin.php:599
     287#: includes/class-emporiqa-admin.php:771
    226288msgid "Languages"
    227289msgstr ""
    228290
    229 #: includes/class-emporiqa-admin.php:616
     291#: includes/class-emporiqa-admin.php:788
    230292msgid "Pick the languages of your products and pages to send. The chat replies in each shopper's language for any of these."
    231293msgstr ""
    232294
    233 #: includes/class-emporiqa-admin.php:621
     295#: includes/class-emporiqa-admin.php:793
    234296msgid "Pages and posts to include"
    235297msgstr ""
    236298
    237 #: includes/class-emporiqa-admin.php:642
     299#: includes/class-emporiqa-admin.php:814
    238300msgid "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."
    239301msgstr ""
    240302
    241 #: includes/class-emporiqa-admin.php:647
     303#: includes/class-emporiqa-admin.php:824
    242304msgid "Emporiqa server URL"
    243305msgstr ""
    244306
    245 #: includes/class-emporiqa-admin.php:652
     307#: includes/class-emporiqa-admin.php:829
    246308msgid "Where this site sends product and page updates. Keep the default (https://emporiqa.com/webhooks/sync/) unless Emporiqa support asked you to change it."
    247309msgstr ""
    248310
    249 #: includes/class-emporiqa-admin.php:657
     311#: includes/class-emporiqa-admin.php:834
    250312msgid "Items per batch"
    251313msgstr ""
    252314
    253 #: includes/class-emporiqa-admin.php:662
     315#: includes/class-emporiqa-admin.php:839
    254316msgid "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)."
    255317msgstr ""
    256318
    257 #: includes/class-emporiqa-admin.php:811
     319#: includes/class-emporiqa-admin.php:988
    258320msgid "Connected to Emporiqa. One more step."
    259321msgstr ""
    260322
    261 #: includes/class-emporiqa-admin.php:815
     323#: includes/class-emporiqa-admin.php:992
    262324msgid "Send your products and pages to Emporiqa so the chat has something to recommend. We'll send everything in batches in the background."
    263325msgstr ""
    264326
    265 #: includes/class-emporiqa-admin.php:828
     327#: includes/class-emporiqa-admin.php:1005
    266328msgid "Send my catalog"
    267329msgstr ""
    268330
    269331#. translators: 1: total item count, 2: typical time
    270 #: includes/class-emporiqa-admin.php:834
     332#: includes/class-emporiqa-admin.php:1011
    271333#, php-format
    272334msgid "%1$d items to send. Typically a few minutes."
    273335msgstr ""
    274336
    275 #: includes/class-emporiqa-admin.php:842
     337#: includes/class-emporiqa-admin.php:1019
    276338msgid "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."
    277339msgstr ""
    278340
    279 #: includes/class-emporiqa-admin.php:847
     341#: includes/class-emporiqa-admin.php:1024
    280342msgid "Bulk Sync"
    281343msgstr ""
    282344
    283 #: includes/class-emporiqa-admin.php:849
     345#: includes/class-emporiqa-admin.php:1026
    284346msgid "Use \"Sync All\" for your initial sync. After that, product and page changes are sent automatically in real-time via webhooks."
    285347msgstr ""
    286348
    287 #: includes/class-emporiqa-admin.php:855
     349#: includes/class-emporiqa-admin.php:1032
    288350msgid "products"
    289351msgstr ""
    290352
    291353#. translators: %s: language codes
    292 #: includes/class-emporiqa-admin.php:859
     354#: includes/class-emporiqa-admin.php:1036
    293355#, php-format
    294356msgid "(all languages synced per product: %s)"
    295357msgstr ""
    296358
    297 #: includes/class-emporiqa-admin.php:866
     359#: includes/class-emporiqa-admin.php:1043
    298360msgid "No published products found. Make sure you have published products before syncing."
    299361msgstr ""
    300362
    301363#. translators: %s: link to settings tab
    302 #: includes/class-emporiqa-admin.php:875
     364#: includes/class-emporiqa-admin.php:1052
    303365#, php-format
    304366msgid "Product sync is disabled &mdash; <a href=\"%s\">enable it in the Settings tab</a>"
    305367msgstr ""
    306368
    307 #: includes/class-emporiqa-admin.php:885
     369#: includes/class-emporiqa-admin.php:1062
    308370msgid "pages"
    309371msgstr ""
    310372
    311373#. translators: %s: language codes
    312 #: includes/class-emporiqa-admin.php:889
     374#: includes/class-emporiqa-admin.php:1066
    313375#, php-format
    314376msgid "(all languages synced per page: %s)"
    315377msgstr ""
    316378
    317 #: includes/class-emporiqa-admin.php:896
     379#: includes/class-emporiqa-admin.php:1073
    318380msgid "No published pages found. Make sure you have published pages before syncing."
    319381msgstr ""
    320382
    321383#. translators: %s: link to settings tab
    322 #: includes/class-emporiqa-admin.php:905
     384#: includes/class-emporiqa-admin.php:1082
    323385#, php-format
    324386msgid "Page sync is disabled &mdash; <a href=\"%s\">enable it in the Settings tab</a>"
    325387msgstr ""
    326388
    327 #: includes/class-emporiqa-admin.php:917
     389#: includes/class-emporiqa-admin.php:1094
    328390msgid "Please configure your Store ID and Connection Secret in the Settings tab before syncing."
    329391msgstr ""
    330392
    331 #: includes/class-emporiqa-admin.php:929
     393#: includes/class-emporiqa-admin.php:1106
    332394msgid "Sync Products"
    333395msgstr ""
    334396
    335 #: includes/class-emporiqa-admin.php:931
     397#: includes/class-emporiqa-admin.php:1108
    336398msgid "Send all products to Emporiqa."
    337399msgstr ""
    338400
    339 #: includes/class-emporiqa-admin.php:936
    340 #: includes/class-emporiqa-admin.php:957
    341 #: includes/class-emporiqa-admin.php:976
     401#: includes/class-emporiqa-admin.php:1113
     402#: includes/class-emporiqa-admin.php:1134
     403#: includes/class-emporiqa-admin.php:1153
    342404msgid "Configure your Store ID and Connection Secret first"
    343405msgstr ""
    344406
    345 #: includes/class-emporiqa-admin.php:938
     407#: includes/class-emporiqa-admin.php:1115
    346408msgid "Enable product sync in the Settings tab"
    347409msgstr ""
    348410
    349 #: includes/class-emporiqa-admin.php:950
     411#: includes/class-emporiqa-admin.php:1127
    350412msgid "Sync Pages"
    351413msgstr ""
    352414
    353 #: includes/class-emporiqa-admin.php:952
     415#: includes/class-emporiqa-admin.php:1129
    354416msgid "Send all pages to Emporiqa."
    355417msgstr ""
    356418
    357 #: includes/class-emporiqa-admin.php:959
     419#: includes/class-emporiqa-admin.php:1136
    358420msgid "Enable page sync in the Settings tab"
    359421msgstr ""
    360422
    361 #: includes/class-emporiqa-admin.php:971
     423#: includes/class-emporiqa-admin.php:1148
    362424msgid "Sync All"
    363425msgstr ""
    364426
    365 #: includes/class-emporiqa-admin.php:973
     427#: includes/class-emporiqa-admin.php:1150
    366428msgid "Send all products and pages at once."
    367429msgstr ""
    368430
    369 #: includes/class-emporiqa-admin.php:984
     431#: includes/class-emporiqa-admin.php:1161
    370432msgid "Cancel"
    371433msgstr ""
    372434
    373 #: includes/class-emporiqa-admin.php:995
     435#: includes/class-emporiqa-admin.php:1172
    374436msgid "Sync log"
    375437msgstr ""
    376438
    377 #: includes/class-emporiqa-admin.php:998
     439#: includes/class-emporiqa-admin.php:1175
    378440msgid "Test Connection"
    379441msgstr ""
    380442
    381 #: includes/class-emporiqa-admin.php:1000
     443#: includes/class-emporiqa-admin.php:1177
    382444msgid "Sends a real product to Emporiqa in dry run mode. Validates the connection, signature, and payload without storing anything."
    383445msgstr ""
    384446
    385 #: includes/class-emporiqa-admin.php:1004
     447#: includes/class-emporiqa-admin.php:1181
    386448msgid "Test Connection (Dry Run)"
    387449msgstr ""
    388450
    389 #: includes/class-emporiqa-admin.php:1011
     451#: includes/class-emporiqa-admin.php:1188
    390452msgid "WP-CLI Commands (for developers)"
    391453msgstr ""
    392454
    393 #: includes/class-emporiqa-admin.php:1059
     455#: includes/class-emporiqa-admin.php:1236
    394456msgid "Testing..."
    395457msgstr ""
    396458
    397 #: includes/class-emporiqa-admin.php:1060
     459#: includes/class-emporiqa-admin.php:1237
    398460msgid "Copied!"
    399461msgstr ""
    400462
    401 #: includes/class-emporiqa-admin.php:1062
     463#: includes/class-emporiqa-admin.php:1239
    402464msgid "Initializing sync..."
    403465msgstr ""
    404466
    405467#. translators: %s: entity type (products/pages)
    406 #: includes/class-emporiqa-admin.php:1064
     468#: includes/class-emporiqa-admin.php:1241
    407469#, php-format
    408470msgid "Started %s sync"
    … …  
    410472
    411473#. translators: 1: number of items, 2: number of events, 3: entity type
    412 #: includes/class-emporiqa-admin.php:1066
     474#: includes/class-emporiqa-admin.php:1243
    413475#, php-format
    414476msgid "Processed %1$d items (%2$d events) for %3$s"
    … …  
    416478
    417479#. translators: %s: entity type
    418 #: includes/class-emporiqa-admin.php:1068
     480#: includes/class-emporiqa-admin.php:1245
    419481#, php-format
    420482msgid "Batch failed for %s"
    421483msgstr ""
    422484
     485#. translators: 1: entity type (products/pages), 2: seconds
     486#: includes/class-emporiqa-admin.php:1247
     487#, php-format
     488msgid "Emporiqa is busy and asked to wait. Sending this %1$s batch again in %2$d seconds."
     489msgstr ""
     490
    423491#. translators: %s: entity type (products/pages)
    424 #: includes/class-emporiqa-admin.php:1070
     492#: includes/class-emporiqa-admin.php:1249
    425493#, php-format
    426494msgid "Completed %s sync session"
    … …  
    428496
    429497#. translators: %s: entity type (products/pages)
    430 #: includes/class-emporiqa-admin.php:1072
     498#: includes/class-emporiqa-admin.php:1251
    431499#, php-format
    432500msgid "Failed to complete %s session"
    … …  
    434502
    435503#. translators: 1: entity type (products/pages), 2: total count
    436 #: includes/class-emporiqa-admin.php:1074
     504#: includes/class-emporiqa-admin.php:1253
    437505#, php-format
    438506msgid "Total %1$s synced: %2$d items"
    439507msgstr ""
    440508
    441 #: includes/class-emporiqa-admin.php:1075
     509#: includes/class-emporiqa-admin.php:1254
    442510msgid "Sync completed successfully!"
    443511msgstr ""
    444512
    445513#. translators: %s: entity type (products/pages)
    446 #: includes/class-emporiqa-admin.php:1077
     514#: includes/class-emporiqa-admin.php:1256
    447515#, php-format
    448516msgid "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."
    … …  
    450518
    451519#. translators: %s: entity type (products/pages)
    452 #: includes/class-emporiqa-admin.php:1079
     520#: includes/class-emporiqa-admin.php:1258
    453521#, php-format
    454522msgid "No %s were synced, so the session was not finalized."
    455523msgstr ""
    456524
    457 #: includes/class-emporiqa-admin.php:1080
     525#: includes/class-emporiqa-admin.php:1259
    458526msgid "Sync finished with errors — see the log above. Re-run the sync after resolving them."
    459527msgstr ""
    460528
    461529#. translators: 1: Products link placeholder, 2: Pages link placeholder
    462 #: includes/class-emporiqa-admin.php:1082
     530#: includes/class-emporiqa-admin.php:1261
    463531#, php-format
    464532msgid "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."
    … …  
    466534
    467535#. translators: used as link text in sync log
    468 #: includes/class-emporiqa-admin.php:1086
     536#: includes/class-emporiqa-admin.php:1265
    469537msgid "Products"
    470538msgstr ""
    471539
    472540#. translators: used as link text in sync log
    473 #: includes/class-emporiqa-admin.php:1088
     541#: includes/class-emporiqa-admin.php:1267
    474542msgid "Pages"
    475543msgstr ""
    476544
    477 #: includes/class-emporiqa-admin.php:1089
     545#: includes/class-emporiqa-admin.php:1268
    478546msgid "Sync cancelled."
    479547msgstr ""
    480548
    481549#. translators: %s: error message
    482 #: includes/class-emporiqa-admin.php:1091
     550#: includes/class-emporiqa-admin.php:1270
    483551#, php-format
    484552msgid "Sync failed: %s"
    485553msgstr ""
    486554
    487 #: includes/class-emporiqa-admin.php:1092
     555#: includes/class-emporiqa-admin.php:1271
    488556msgid "Failed to initialize sync."
    489557msgstr ""
    490558
    491 #: includes/class-emporiqa-admin.php:1093
     559#: includes/class-emporiqa-admin.php:1272
    492560msgid "Sample product payload"
    493561msgstr ""
    494562
    495 #: includes/class-emporiqa-admin.php:1094
     563#: includes/class-emporiqa-admin.php:1273
    496564msgid "Sample page payload"
    497565msgstr ""
    498566
    499 #: includes/class-emporiqa-admin.php:1111
     567#: includes/class-emporiqa-admin.php:1290
    500568#: includes/class-emporiqa-sync.php:65
    501 #: includes/class-emporiqa-sync.php:161
    502 #: includes/class-emporiqa-sync.php:201
     569#: includes/class-emporiqa-sync.php:165
     570#: includes/class-emporiqa-sync.php:209
    503571msgid "Permission denied."
    504572msgstr ""
    505573
    506 #: includes/class-emporiqa-cart-ajax.php:81
    507 #: includes/class-emporiqa-cart-ajax.php:190
    508 #: includes/class-emporiqa-cart-ajax.php:229
     574#: includes/class-emporiqa-cart-ajax.php:89
     575#: includes/class-emporiqa-cart-ajax.php:198
     576#: includes/class-emporiqa-cart-ajax.php:237
    509577msgid "No product ID provided."
    510578msgstr ""
    511579
    512 #: includes/class-emporiqa-cart-ajax.php:90
     580#: includes/class-emporiqa-cart-ajax.php:98
    513581msgid "Product not found."
    514582msgstr ""
    515583
    516 #: includes/class-emporiqa-cart-ajax.php:95
     584#: includes/class-emporiqa-cart-ajax.php:103
    517585msgid "This product cannot be purchased."
    518586msgstr ""
    519587
    520 #: includes/class-emporiqa-cart-ajax.php:98
     588#: includes/class-emporiqa-cart-ajax.php:106
    521589msgid "This product is currently out of stock."
    522590msgstr ""
    523591
    524 #: includes/class-emporiqa-cart-ajax.php:109
     592#: includes/class-emporiqa-cart-ajax.php:117
    525593msgid "Parent product not found."
    526594msgstr ""
    527595
    528 #: includes/class-emporiqa-cart-ajax.php:119
     596#: includes/class-emporiqa-cart-ajax.php:127
    529597msgid "Please select a product variation."
    530598msgstr ""
    531599
    532 #: includes/class-emporiqa-cart-ajax.php:128
     600#: includes/class-emporiqa-cart-ajax.php:136
    533601msgid "Variation not found."
    534602msgstr ""
    535603
    536 #: includes/class-emporiqa-cart-ajax.php:131
     604#: includes/class-emporiqa-cart-ajax.php:139
    537605msgid "This variation cannot be purchased."
    538606msgstr ""
    539607
    540 #: includes/class-emporiqa-cart-ajax.php:134
     608#: includes/class-emporiqa-cart-ajax.php:142
    541609msgid "This variation is currently out of stock."
    542610msgstr ""
    543611
    544 #: includes/class-emporiqa-cart-ajax.php:144
     612#: includes/class-emporiqa-cart-ajax.php:152
    545613msgid "Failed to add item to cart."
    546614msgstr ""
    547615
    548 #: includes/class-emporiqa-cart-ajax.php:194
     616#: includes/class-emporiqa-cart-ajax.php:202
    549617msgid "Quantity must be greater than zero."
    550618msgstr ""
    551619
    552 #: includes/class-emporiqa-cart-ajax.php:199
    553 #: includes/class-emporiqa-cart-ajax.php:234
     620#: includes/class-emporiqa-cart-ajax.php:207
     621#: includes/class-emporiqa-cart-ajax.php:242
    554622msgid "Item not found in cart."
    555623msgstr ""
    556624
    557 #: includes/class-emporiqa-cart-ajax.php:206
     625#: includes/class-emporiqa-cart-ajax.php:214
    558626msgid "Failed to update cart."
    559627msgstr ""
    560628
    561 #: includes/class-emporiqa-cart-ajax.php:241
     629#: includes/class-emporiqa-cart-ajax.php:249
    562630msgid "Failed to remove item."
    563631msgstr ""
    564632
    565 #: includes/class-emporiqa-cart-ajax.php:316
     633#: includes/class-emporiqa-cart-ajax.php:326
    566634msgid "Security check failed. Please refresh the page and try again."
    567635msgstr ""
    568636
    569 #: includes/class-emporiqa-connect.php:145
     637#: includes/class-emporiqa-connect.php:160
    570638msgid "Couldn't save the connection state. Please try again."
    571639msgstr ""
    572640
    573 #: includes/class-emporiqa-connect.php:194
     641#: includes/class-emporiqa-connect.php:209
    574642msgid "Unexpected redirect source."
    575643msgstr ""
    576644
    577 #: includes/class-emporiqa-connect.php:199
     645#: includes/class-emporiqa-connect.php:214
    578646msgid "Callback missing required parameters."
    579647msgstr ""
    580648
    581 #: includes/class-emporiqa-connect.php:207
     649#: includes/class-emporiqa-connect.php:222
    582650msgid "Connection link expired or already used. Click Connect to try again."
    583651msgstr ""
    584652
    585653#. translators: %s: WP HTTP API error message
    586 #: includes/class-emporiqa-connect.php:237
     654#: includes/class-emporiqa-connect.php:255
    587655#, php-format
    588656msgid "Couldn't reach Emporiqa: %s"
    … …  
    590658
    591659#. translators: %s: error code from the exchange endpoint
    592 #: includes/class-emporiqa-connect.php:253
     660#: includes/class-emporiqa-connect.php:278
    593661#, php-format
    594662msgid "Emporiqa refused the connection: %s"
    595663msgstr ""
    596664
    597 #: includes/class-emporiqa-formatter.php:941
    598 #: includes/class-emporiqa-formatter.php:952
     665#: includes/class-emporiqa-formatter.php:869
     666#: includes/class-emporiqa-formatter.php:880
    599667msgid "General"
    600668msgstr ""
    601669
    602 #: includes/class-emporiqa-formatter.php:1021
     670#: includes/class-emporiqa-formatter.php:1245
    603671msgid "Generic"
    604672msgstr ""
    605673
    606 #: includes/class-emporiqa-order-tracking.php:80
     674#: includes/class-emporiqa-order-tracking.php:96
    607675msgid "Missing signature."
    608676msgstr ""
    609677
    610 #: includes/class-emporiqa-order-tracking.php:89
    611 #: includes/class-emporiqa-order-tracking.php:99
     678#: includes/class-emporiqa-order-tracking.php:105
     679#: includes/class-emporiqa-order-tracking.php:115
    612680msgid "Invalid signature."
    613681msgstr ""
    614682
    615 #: includes/class-emporiqa-order-tracking.php:108
     683#: includes/class-emporiqa-order-tracking.php:124
    616684msgid "Invalid JSON in request body."
    617685msgstr ""
    618686
    619 #: includes/class-emporiqa-order-tracking.php:117
     687#: includes/class-emporiqa-order-tracking.php:133
    620688msgid "Request expired."
    621689msgstr ""
    622690
    623 #: includes/class-emporiqa-order-tracking.php:158
     691#: includes/class-emporiqa-order-tracking.php:197
    624692msgid "Invalid payload: order_identifier is required."
    625693msgstr ""
    626694
    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
     698msgid "Order not found."
     699msgstr ""
     700
     701#: includes/class-emporiqa-order-tracking.php:230
    628702msgid "Too many requests. Please try again later."
    629 msgstr ""
    630 
    631 #: includes/class-emporiqa-order-tracking.php:251
    632 #: includes/class-emporiqa-order-tracking.php:260
    633 msgid "Order not found."
    634703msgstr ""
    635704
    … …  
    642711msgstr ""
    643712
    644 #: includes/class-emporiqa-sync.php:217
     713#: includes/class-emporiqa-sync.php:225
    645714msgid "Unknown or expired sync session — it was not finalized. Re-run the sync."
    646715msgstr ""
    647716
    648717#. translators: 1: number of items that failed, 2: entity type (products or pages).
    649 #: includes/class-emporiqa-sync.php:228
     718#: includes/class-emporiqa-sync.php:236
    650719#, php-format
    651720msgid "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."
    652721msgstr ""
    653722
    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
     726msgid "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."
     727msgstr ""
     728
     729#: includes/class-emporiqa-sync.php:261
    655730msgid "No items were synced in this session, so it was not finalized (finalizing an empty session would delete the entire remote catalog)."
    656731msgstr ""
    657732
    658 #: includes/class-emporiqa-sync.php:249
     733#: includes/class-emporiqa-sync.php:270
    659734msgid "Session completed."
    660735msgstr ""
    661736
    662 #: includes/class-emporiqa-sync.php:252
     737#: includes/class-emporiqa-sync.php:273
    663738msgid "Failed to complete sync session."
    664739msgstr ""
    665740
    666 #: includes/class-emporiqa-webhook.php:915
     741#: includes/class-emporiqa-webhook.php:989
    667742msgid "Unknown error"
    668743msgstr ""
    669744
    670 #: includes/class-emporiqa-webhook.php:1000
     745#: includes/class-emporiqa-webhook.php:1074
    671746msgid "Store ID is not configured."
    672747msgstr ""
    673748
    674 #: includes/class-emporiqa-webhook.php:1009
     749#: includes/class-emporiqa-webhook.php:1083
    675750msgid "Connection Secret is not configured."
    676751msgstr ""
    677752
    678 #: includes/class-emporiqa-webhook.php:1018
     753#: includes/class-emporiqa-webhook.php:1092
    679754msgid "No published products found. Add a product before testing."
    680755msgstr ""
    681756
    682 #: includes/class-emporiqa-webhook.php:1027
     757#: includes/class-emporiqa-webhook.php:1101
    683758msgid "Failed to format product for dry run."
    684759msgstr ""
    685760
    686 #: includes/class-emporiqa-webhook.php:1058
     761#. translators: %d: number of minutes
     762#: includes/class-emporiqa-webhook.php:1123
     763#, php-format
     764msgid "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."
     765msgstr ""
     766
     767#: includes/class-emporiqa-webhook.php:1149
    687768msgid "Connection successful, but there are warnings."
    688769msgstr ""
    689770
    690 #: includes/class-emporiqa-webhook.php:1059
     771#: includes/class-emporiqa-webhook.php:1150
    691772msgid "Connection successful! All fields validated."
    692773msgstr ""
    693774
    694775#. translators: %s: human-readable error message from the Emporiqa server
    695 #: includes/class-emporiqa-webhook.php:1067
     776#: includes/class-emporiqa-webhook.php:1158
    696777#, php-format
    697778msgid "Connection failed: %s"
    698779msgstr ""
    699780
    700 #: includes/class-emporiqa-webhook.php:1174
     781#: includes/class-emporiqa-webhook.php:1249
    701782msgid "Webhook not configured (missing Store ID)."
    702783msgstr ""
    703784
    704 #: includes/class-emporiqa-webhook.php:1183
     785#: includes/class-emporiqa-webhook.php:1258
    705786msgid "Connection Secret is not configured. Go to Settings > Emporiqa to set it."
    706787msgstr ""
    707788
    708 #: includes/class-emporiqa-webhook.php:1196
     789#: includes/class-emporiqa-webhook.php:1271
    709790msgid "Failed to encode payload as JSON."
    710791msgstr ""
    711792
    712793#. translators: %d: HTTP status code
    713 #: includes/class-emporiqa-webhook.php:1249
     794#: includes/class-emporiqa-webhook.php:1323
    714795#, php-format
    715796msgid "Unexpected status code: %d"
  • emporiqa/tags/1.2.0/readme.txt

    r3725261 r3730094  
    22Contributors: rosenhristov1
    33Tags: ai chatbot, live chat, woocommerce chatbot, ecommerce chatbot, ai customer support
    4 Requires at least: 6.0
     4Requires at least: 6.2
    55Tested up to: 7.1
    66Requires Plugins: woocommerce
    77Requires PHP: 7.4
    88WC requires at least: 8.0
    9 WC tested up to: 10.9
    10 Stable tag: 1.1.10
     9WC tested up to: 11.1
     10Stable tag: 1.2.0
    1111License: GPLv2 or later
    1212License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    5454Price, 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.
    5555
     56= Privacy and security notes =
     57
     58The 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
    5660= Third-Party Service =
    5761
    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/).
     62This 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/).
    5963
    6064== Installation ==
    … …  
    1201247. Order tracking: the chat asks for an order number and matching email
    1211258. 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 cap
     1269. Billing: pay-as-you-go at $0.25 a conversation, capped at $59 a month, with the next billing date
    12312710. Voice mode, if you switch it on: the answer is read aloud in an AI voice while the product cards stay on screen
    124128
    125129== 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.
    126153
    127154= 1.1.10 =
    … …  
    130157* Text only. No functional changes.
    131158
    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 
    136159Older entries are in changelog.txt, shipped with the plugin.
    137160
    138161== Upgrade Notice ==
    139162
     163= 1.2.0 =
     164Adds 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
    140166= 1.1.10 =
    141167Text-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  
    1616delete_option( 'emporiqa_pending_async_events' );
    1717delete_option( 'emporiqa_instance_id' );
     18delete_option( 'emporiqa_rules_status' );
    1819
    1920// Clean up retry transients + one-click connect handshake transients.
    … …  
    2829}
    2930
    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
    3234
    3335// Clean up order meta.
  • emporiqa/trunk/admin/css/emporiqa-admin.css

    r3506790 r3730094  
    371371        color: #1d2327;
    372372}
     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  
    1212        var syncRunning   = false;
    1313        var currentXhr    = null;
     14
     15        // Consecutive waits for one batch before it counts as failed.
     16        var MAX_BATCH_WAITS = 30;
    1417
    1518        /**
    … …  
    335338                                var totalItems    = productCount + pageCount;
    336339
    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.
    338343                                var workQueue    = [];
    339344                                var sessionCount = sessions.length;
    340                                 var s, itemCount, totalPages, p;
     345                                var s;
    341346
    342347                                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 );
    351348                                        addLogEntry(
    352349                                                sprintf(
    … …  
    357354                                        );
    358355
    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                                        );
    370366                                }
    371367
    … …  
    401397
    402398                                        var work = workQueue[ batchIndex ];
    403                                         batchIndex++;
    404399
    405400                                        currentXhr = $.post(
    … …  
    411406                                                        language: work.language,
    412407                                                        session_id: work.session_id,
    413                                                         page: work.page,
     408                                                        after_id: work.after_id,
    414409                                                        items_per_batch: work.items_per_batch
    415410                                                }
    416411                                        ).done(
    417412                                                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
    418431                                                        if ( batchResponse.success && batchResponse.data ) {
    419432                                                                var batchProcessed = batchResponse.data.processed || 0;
    … …  
    456469
    457470                                                        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;
    459480                                                        }
    460481
    … …  
    475496                                                                );
    476497                                                        }
     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++;
    477502                                                        processBatch();
    478503                                                }
  • emporiqa/trunk/assets/js/emporiqa-cart.js

    r3491860 r3730094  
    3535         * Automatically updates the nonce from each response to keep
    3636         * 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.
    4044         * @return {Promise<Object>} Parsed JSON response matching the EmporiqaCartHandler spec.
    4145         */
    42         function emporiqaAjax( action, data ) {
     46        function emporiqaAjax( action, data, retried ) {
    4347                var formData = new FormData();
    4448                formData.append( 'action', action );
    … …  
    6266                                config.nonce = result.nonce;
    6367                        }
    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 ) {
    7285                if ( typeof jQuery !== 'undefined' ) {
    7386                        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                        );
    7492                }
    7593        }
    … …  
    153171                return emporiqaAjax( 'emporiqa_remove_from_cart', data ).then( function( result ) {
    154172                        if ( result.success ) {
    155                                 refreshCartFragments();
     173                                refreshCartFragments( true );
    156174                        }
    157175                        return result;
    … …  
    167185                return emporiqaAjax( 'emporiqa_clear_cart' ).then( function( result ) {
    168186                        if ( result.success ) {
    169                                 refreshCartFragments();
     187                                refreshCartFragments( true );
    170188                        }
    171189                        return result;
  • emporiqa/trunk/changelog.txt

    r3678094 r3730094  
    22
    33Older 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.
    48
    59= 1.1.8 =
  • emporiqa/trunk/cli/class-emporiqa-cli.php

    r3601468 r3730094  
    8989                $synced   = 0;
    9090                $errors   = 0;
    91                 $page     = 1;
     91                $after_id = 0;
    9292                $per_page = 100;
    9393
     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).
    9497                while ( true ) {
    9598                        $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,
    100105                        );
    101106
    … …  
    113118                        }
    114119
    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                        );
    116125                        if ( empty( $products ) ) {
    117126                                break;
    … …  
    119128
    120129                        foreach ( $products as $product ) {
     130                                $after_id  = max( $after_id, (int) $product->get_id() );
    121131                                $formatted = $formatter->format_product_consolidated( $product, $session_id );
    122132                                foreach ( $formatted as $item ) {
    … …  
    142152
    143153                        wp_cache_flush();
    144                         ++$page;
    145154                }
    146155
    … …  
    233242                $synced   = 0;
    234243                $errors   = 0;
    235                 $paged    = 1;
     244                $after_id = 0;
    236245                $per_page = 100;
    237246
    238247                while ( true ) {
    239248                        $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,
    245257                        );
    246258
    … …  
    252264                        }
    253265
    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                        );
    255271                        if ( empty( $pages ) ) {
    256272                                break;
    … …  
    258274
    259275                        foreach ( $pages as $page ) {
     276                                $after_id  = max( $after_id, (int) $page->ID );
    260277                                $formatted = $formatter->format_page_consolidated( $page, $session_id );
    261278                                if ( ! empty( $formatted ) ) {
    … …  
    281298
    282299                        wp_cache_flush();
    283                         ++$paged;
    284300                }
    285301
  • emporiqa/trunk/emporiqa.php

    r3678232 r3730094  
    33 * Plugin Name: Emporiqa
    44 * Description: Integrates WooCommerce with Emporiqa chat assistant.
    5  * Version:     1.1.10
     5 * Version:     1.2.0
    66 * Author:      Emporiqa
    77 * Author URI:  https://emporiqa.com
    … …  
    1010 * Text Domain: emporiqa
    1111 * Domain Path: /languages
    12  * Requires at least: 6.0
     12 * Requires at least: 6.2
    1313 * Tested up to: 7.1
    1414 * Requires PHP: 7.4
    1515 * Requires Plugins: woocommerce
    1616 * WC requires at least: 8.0
    17  * WC tested up to: 10.9
     17 * WC tested up to: 11.1
    1818 *
    1919 * @package Emporiqa
    … …  
    2525}
    2626
    27 define( 'EMPORIQA_VERSION', '1.1.10' );
     27define( 'EMPORIQA_VERSION', '1.2.0' );
    2828define( 'EMPORIQA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    2929define( 'EMPORIQA_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
    … …  
    3636require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-widget.php';
    3737require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-order-tracking.php';
     38require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-signature.php';
     39require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-order-status.php';
     40require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-customer-prices.php';
     41require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-actions.php';
    3842require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-cart-ajax.php';
    3943require_once EMPORIQA_PLUGIN_DIR . 'includes/class-emporiqa-order-webhook.php';
    … …  
    9498         */
    9599        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;
    96107
    97108        /**
    … …  
    206217                $this->widget         = new Emporiqa_Widget();
    207218                $this->order_tracking = new Emporiqa_Order_Tracking();
     219                $this->actions        = new Emporiqa_Actions();
    208220                $this->cart_ajax      = new Emporiqa_Cart_Ajax();
    209221                $this->order_webhook  = new Emporiqa_Order_Webhook();
    … …  
    229241        private function init_hooks(): void {
    230242                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 );
    231246
    232247                // Page-save entry point. Inside the handler we skip when Polylang is
    … …  
    269284                add_action( 'woocommerce_new_product', array( $this, 'handle_product_created' ) );
    270285                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 );
    272289                add_action( 'untrashed_post', array( $this, 'handle_product_untrashed' ) );
    273290                add_action( 'woocommerce_new_product_variation', array( $this, 'handle_variation_created' ) );
    … …  
    449466         * wp_trash_post fires when a product is moved to trash. WooCommerce's
    450467         * 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.
    453471         *
    454472         * @since 1.0.0
  • emporiqa/trunk/includes/class-emporiqa-admin.php

    r3678094 r3730094  
    3434         */
    3535        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';
    3641
    3742        /**
    … …  
    156161                }
    157162
     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
    158172                $batch_size = absint( $input['batch_size'] ?? 25 );
    159173                if ( $batch_size < 1 || $batch_size > 100 ) {
    … …  
    242256
    243257                                        <?php $this->render_connection_section( $options ); ?>
     258                                        <?php $this->render_rules_section( $options ); ?>
    244259                                        <?php $this->render_advanced_section( $options ); ?>
    245260
    … …  
    338353                }
    339354
    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                }
    341360                $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                );
    342514        }
    343515
    … …  
    644816                                </td>
    645817                        </tr>
     818                        <?php
     819                        if ( self::rules_status()['available'] ) {
     820                                $this->render_legacy_order_tracking_rows( $options );
     821                        }
     822                        ?>
    646823                        <tr>
    647824                                <th scope="row"><label for="webhook_url"><?php esc_html_e( 'Emporiqa server URL', 'emporiqa' ); ?></label></th>
    … …  
    10671244                                        /* translators: %s: entity type */
    10681245                                        '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' ),
    10691248                                        /* translators: %s: entity type (products/pages) */
    10701249                                        'sessionComplete' => __( 'Completed %s sync session', 'emporiqa' ),
    … …  
    11841363                        'page_post_types'   => array( 'page' ),
    11851364                        'batch_size'        => 25,
     1365                        'order_tracking'    => true,
    11861366                );
    11871367        }
  • emporiqa/trunk/includes/class-emporiqa-cart-ajax.php

    r3601468 r3730094  
    2626         * Nonce action used for all cart AJAX requests.
    2727         *
     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         *
    2832         * @since 1.0.0
    2933         * @var string
    3034         */
    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';
    3252
    3353        /**
    … …  
    6585         */
    6686        public function add_to_cart(): void {
     87                $this->refuse_cross_site();
    6788                $this->verify_nonce();
    6889                $this->ensure_cart_loaded();
    … …  
    174195         */
    175196        public function update_cart(): void {
     197                $this->refuse_cross_site();
    176198                $this->verify_nonce();
    177199                $this->ensure_cart_loaded();
    … …  
    215237         */
    216238        public function remove_from_cart(): void {
     239                $this->refuse_cross_site();
    217240                $this->verify_nonce();
    218241                $this->ensure_cart_loaded();
    … …  
    248271         */
    249272        public function clear_cart(): void {
     273                $this->refuse_cross_site();
    250274                $this->verify_nonce();
    251275                $this->ensure_cart_loaded();
    … …  
    314338        private function verify_nonce(): void {
    315339                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 );
    318379        }
    319380
    … …  
    352413                                'name'         => $product->get_name(),
    353414                                'quantity'     => self::parse_quantity( $item['quantity'] ),
    354                                 'unit_price'   => (float) $product->get_price(),
     415                                'unit_price'   => $this->displayed_unit_price( $product ),
    355416                                'image_url'    => $image_url ? $image_url : null,
    356417                                'product_url'  => get_permalink( $item['product_id'] ),
    … …  
    364425                        'currency'    => emporiqa_get_active_currency(),
    365426                );
     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() );
    366448        }
    367449
    … …  
    400482         *
    401483         * @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 {
    404487                wp_send_json(
    405488                        array(
    406489                                'success'     => false,
    407490                                'error'       => $message,
     491                                'code'        => $code,
    408492                                'checkoutUrl' => $this->get_checkout_url(),
    409493                                'unit_price'  => 0,
  • emporiqa/trunk/includes/class-emporiqa-connect.php

    r3601468 r3730094  
    6666
    6767        /**
     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        /**
    6883         * Constructor — wires the admin_init handler that routes
    6984         * ?emporiqa_action= requests.
    … …  
    214229                        $this->base_url() . '/connect/exchange',
    215230                        array(
    216                                 'timeout' => 10,
     231                                'timeout' => self::EXCHANGE_TIMEOUT,
    217232                                'headers' => array(
    218233                                        'Content-Type'        => 'application/json',
    … …  
    222237                                'body'    => wp_json_encode(
    223238                                        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(),
    227243                                        )
    228244                                ),
    229245                        )
    230246                );
     247                // The exchange has returned: the origin proof can no longer be asked for.
     248                $this->finish_nonce( $state );
    231249
    232250                if ( is_wp_error( $resp ) ) {
    … …  
    244262                $status = (int) wp_remote_retrieve_response_code( $resp );
    245263                $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                }
    246271
    247272                if ( 200 !== $status || ! is_array( $body ) || empty( $body['webhook_secret'] ) || empty( $body['store_id'] ) ) {
    … …  
    257282                        $this->redirect_to_settings();
    258283                }
     284
     285                // Whether Emporiqa offers ready-made rules to this store, and which are live.
     286                Emporiqa_Admin::store_rules_status( $body );
    259287
    260288                $this->persist_credentials(
    … …  
    414442
    415443        /**
    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.
    418448         *
    419449         * @param string $state Public state from the callback.
    … …  
    422452                $key  = self::NONCE_PREFIX . hash( 'sha256', $state );
    423453                $data = get_transient( $key );
    424                 if ( ! is_array( $data ) || empty( $data['verifier'] ) ) {
     454                if ( ! is_array( $data ) || empty( $data['verifier'] ) || ! empty( $data['exchanging_at'] ) ) {
    425455                        return null;
    426456                }
    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                }
    429461                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 ) );
    430491        }
    431492
  • emporiqa/trunk/includes/class-emporiqa-formatter.php

    r3601468 r3730094  
    3939         */
    4040        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;
    4148
    4249        /**
    … …  
    416423                        $brand_data[ $channel ] = $this->get_product_brand( $product, $languages[0] );
    417424
    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 )
    478429                        );
    479430
    … …  
    673624                $var_images = array_values( array_unique( $var_images ) );
    674625
    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 );
    677627
    678628                $names        = array();
    679                 $descriptions = array();
    680629                $links        = array();
    681630                $attributes   = array();
    682                 $cat_data     = array();
    683                 $brand_data   = array();
    684631                $price_data   = array();
    685632                $avail_data   = array();
    … …  
    689636                $max_qty_data = array();
    690637
     638                // Descriptions, categories and brand are not built here: Emporiqa stores a
     639                // variation as a lean row and reads them from the parent.
    691640                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();
    697644
    698645                        foreach ( $languages as $lang ) {
    … …  
    705652                                $names[ $channel ][ $lang ] = $title;
    706653
    707                                 $descriptions[ $channel ][ $lang ] = $this->get_translated_variation_description( $variation, $parent_product, $lang );
    708 
    709654                                $links[ $channel ][ $lang ] = $this->get_translated_link( $parent_product, $lang );
    710655
    711656                                $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
    717659                        $this->apply_translation_fallbacks( $names[ $channel ], $languages );
    718                         $this->apply_translation_fallbacks( $descriptions[ $channel ], $languages );
    719660                        $this->apply_translation_fallbacks( $links[ $channel ], $languages );
    720661
    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 );
    730663
    731664                        $avail_data[ $channel ] = $this->get_availability_status( $variation );
    … …  
    744677                        'channels'              => $channels,
    745678                        'names'                 => $names,
    746                         'descriptions'          => $descriptions,
    747679                        'links'                 => $links,
    748680                        'attributes'            => $attributes,
    749                         'categories'            => $cat_data,
    750                         'brands'                => $brand_data,
    751681                        'prices'                => $price_data,
    752682                        'availability_statuses' => $avail_data,
    … …  
    759689                        'is_virtual'            => $variation->is_virtual(),
    760690                        'parent_sku'            => $parent_sku,
    761                         'is_parent'             => false,
    762                         'variation_attributes'  => new \stdClass(),
    763691                );
    764692
    … …  
    791719         * @return array List of channel identifiers.
    792720         */
    793         private function get_product_channels( \WC_Product $product ): array {
     721        public function get_product_channels( \WC_Product $product ): array {
    794722                /**
    795723                 * Filter the product sales channels.
    … …  
    978906
    979907                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;
    9801204        }
    9811205
    … …  
    12441468
    12451469        /**
    1246          * Get the translated variation description, falling back to parent description.
    1247          *
    1248          * @since 1.0.0
    1249          *
    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 string
    1254          */
    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         /**
    12851470         * Get the translated product title.
    12861471         *
  • emporiqa/trunk/includes/class-emporiqa-order-tracking.php

    r3506790 r3730094  
    66 * order data. Requests are authenticated via HMAC-SHA256 using the same
    77 * 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).
    813 *
    914 * @package Emporiqa
    … …  
    6570
    6671        /**
     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        /**
    6783         * Verify HMAC signature and timestamp for the incoming request.
    6884         *
    … …  
    143159         */
    144160        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 {
    145184                // H8: Rate limiting per IP.
    146185                $rate_check = $this->check_rate_limit();
    … …  
    161200                }
    162201
    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                }
    164209                $raw_verification        = $payload['verification_fields'] ?? array();
    165210                $verification_fields     = is_array( $raw_verification ) ? array_map( 'sanitize_text_field', $raw_verification ) : array();
    … …  
    234279         */
    235280        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 );
    237284
    238285                // Also try searching by order number (custom order number plugins).
    … …  
    245292                        );
    246293                        $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 ) {
    250302                        return new WP_REST_Response(
    251303                                array( 'error' => __( 'Order not found.', 'emporiqa' ) ),
  • emporiqa/trunk/includes/class-emporiqa-order-webhook.php

    r3549190 r3730094  
    256256                        $url,
    257257                        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 ),
    262259                                'body'     => $payload,
    263260                                'timeout'  => 5,
    … …  
    340337                                $this->log( "order.completed webhook failed for order #{$order_id}. Retry #{$attempt} in {$delay}s." );
    341338                        } 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();
    342343                                $this->log( "order.completed webhook permanently failed for order #{$order_id} after {$attempt} attempt(s)." );
    343344                        }
  • emporiqa/trunk/includes/class-emporiqa-sync.php

    r3601468 r3730094  
    150150         * AJAX handler: process a single batch of items.
    151151         *
    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.
    154158         *
    155159         * @since 1.0.0
    … …  
    164168                $entity          = sanitize_text_field( wp_unslash( $_POST['entity'] ?? '' ) );
    165169                $session_id      = sanitize_text_field( wp_unslash( $_POST['session_id'] ?? '' ) );
    166                 $page            = absint( $_POST['page'] ?? 1 );
     170                $after_id        = absint( $_POST['after_id'] ?? 0 );
    167171                $items_per_batch = absint( $_POST['items_per_batch'] ?? self::ITEMS_PER_BATCH );
    168172
    … …  
    175179                        'events'    => 0,
    176180                        'success'   => true,
     181                        'last_id'   => $after_id,
     182                        'done'      => true,
    177183                );
    178184
    179185                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 );
    181187                } 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 );
    183189                }
    184190
    185191                // 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'] ) );
    188196
    189197                wp_send_json_success( $result );
    … …  
    235243                }
    236244
     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
    237258                if ( $state['synced'] < 1 ) {
    238259                        wp_send_json_error(
    … …  
    283304         * Mirrors the Drupal reference: a failed batch send reclassifies the
    284305         * 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.
    285309         *
    286310         * @since 1.1.9
    … …  
    289313         * @param int    $processed  Items processed in this batch.
    290314         * @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 {
    293319                $state = $this->get_session_state( $session_id );
    294320                if ( ! is_array( $state ) ) {
    … …  
    296322                }
    297323
     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
    298331                if ( $success ) {
    299332                        $state['synced'] += $processed;
    300                 } else {
     333                } elseif ( ! $wait ) {
    301334                        $state['errors'] += max( 1, $processed );
    302335                }
    … …  
    422455         *
    423456         * @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 {
    429462                $formatter = emporiqa()->get_formatter();
    430463                $webhook   = emporiqa()->get_webhook();
    431464
    432465                $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(),
    437471                );
    438472
    … …  
    450484                }
    451485
    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                );
    453493                $events    = array();
    454494                $processed = count( $products );
     495                $last_id   = $after_id;
    455496
    456497                foreach ( $products as $product ) {
     498                        $last_id   = max( $last_id, (int) $product->get_id() );
    457499                        $formatted = $formatter->format_product_consolidated( $product, $session_id );
    458500                        foreach ( $formatted as $item ) {
    … …  
    464506                }
    465507
    466                 $success = true;
    467                 $error   = null;
     508                $success     = true;
     509                $error       = null;
     510                $retry_after = null;
    468511                if ( ! empty( $events ) ) {
    469512                        $success = $webhook->send_batch_events( $events );
    470513                        if ( ! $success ) {
    471                                 $error = $webhook->get_last_error();
     514                                $error       = $webhook->get_last_error();
     515                                $retry_after = $webhook->get_last_retry_after();
    472516                        }
    473517                }
    … …  
    477521                        'events'    => count( $events ),
    478522                        'success'   => $success,
     523                        'last_id'   => $last_id,
     524                        'done'      => $processed < $items_per_batch,
    479525                );
    480526                if ( ! $success && $error ) {
    481527                        $out['error'] = $error;
    482528                }
     529                if ( null !== $retry_after ) {
     530                        $out['retry_after'] = $retry_after;
     531                }
    483532
    484533                return $out;
    … …  
    491540         *
    492541         * @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 {
    498547                $post_types = Emporiqa_Admin::get_option( 'page_post_types', array( 'page' ) );
    499548
    … …  
    504553                                'events'    => 0,
    505554                                'success'   => true,
     555                                'last_id'   => $after_id,
     556                                'done'      => true,
    506557                        );
    507558                }
    … …  
    514565                        'post_status'      => 'publish',
    515566                        'posts_per_page'   => $items_per_batch,
    516                         'paged'            => $page,
     567                        'orderby'          => 'ID',
     568                        'order'            => 'ASC',
     569                        'no_found_rows'    => true,
    517570                        'suppress_filters' => false,
    518571                );
    … …  
    525578                }
    526579
    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                );
    528587                $events    = array();
    529588                $processed = count( $pages );
     589                $last_id   = $after_id;
    530590
    531591                foreach ( $pages as $page_post ) {
     592                        $last_id   = max( $last_id, (int) $page_post->ID );
    532593                        $formatted = $formatter->format_page_consolidated( $page_post, $session_id );
    533594                        if ( ! empty( $formatted ) ) {
    … …  
    539600                }
    540601
    541                 $success = true;
    542                 $error   = null;
     602                $success     = true;
     603                $error       = null;
     604                $retry_after = null;
    543605                if ( ! empty( $events ) ) {
    544606                        $success = $webhook->send_batch_events( $events );
    545607                        if ( ! $success ) {
    546                                 $error = $webhook->get_last_error();
     608                                $error       = $webhook->get_last_error();
     609                                $retry_after = $webhook->get_last_retry_after();
    547610                        }
    548611                }
    … …  
    552615                        'events'    => count( $events ),
    553616                        'success'   => $success,
     617                        'last_id'   => $last_id,
     618                        'done'      => $processed < $items_per_batch,
    554619                );
    555620                if ( ! $success && $error ) {
    556621                        $out['error'] = $error;
    557622                }
     623                if ( null !== $retry_after ) {
     624                        $out['retry_after'] = $retry_after;
     625                }
    558626
    559627                return $out;
    560628        }
     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        }
    561658}
  • emporiqa/trunk/includes/class-emporiqa-webhook.php

    r3560454 r3730094  
    9898
    9999        /**
     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        /**
    100132         * Connect-phase budget (ms) to install on the in-flight cURL handle
    101133         * via http_api_curl. Set just before wp_remote_post in do_request(),
    … …  
    657689                                // (cURL) or by apply_fallback_request_timeout (Streams).
    658690                                '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 ),
    663692                                'body'     => $json,
    664693                        )
    … …  
    835864                $result  = $this->do_request( $payload, false, $timeout );
    836865
     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
    837878                if ( ! $result['success'] ) {
    838879                        $this->last_error = $this->build_friendly_error( $result );
    … …  
    864905        public function get_last_error(): ?string {
    865906                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 ) );
    866940        }
    867941
    … …  
    10401114                $result = $this->do_request( array( 'events' => $events ), true );
    10411115
     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
    10421128                if ( $result['success'] ) {
    10431129                        $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                        }
    10441135
    10451136                        $has_warnings = false;
    … …  
    10551146                        return array(
    10561147                                'success' => true,
    1057                                 'message' => $has_warnings
     1148                                'message' => ( $has_warnings
    10581149                                        ? __( 'Connection successful, but there are warnings.', 'emporiqa' )
    1059                                         : __( 'Connection successful! All fields validated.', 'emporiqa' ),
     1150                                        : __( 'Connection successful! All fields validated.', 'emporiqa' ) ) . $clock_warning,
    10601151                                'dry_run' => $dry_run_data,
    10611152                        );
    … …  
    10651156                        'success' => false,
    10661157                        /* 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,
    10681159                        'dry_run' => $result['response'],
    10691160                );
    … …  
    11351226
    11361227        /**
    1137          * Generate an HMAC-SHA256 signature for a payload.
    1138          *
    1139          * @since 1.0.0
    1140          *
    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         /**
    11531228         * Send an HTTP POST request to the webhook endpoint.
    11541229         *
    … …  
    11991274                }
    12001275
    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 );
    12051277
    12061278                // Hook-driven (AS worker) sends get the tight handshake cap; admin-
    … …  
    12371309                $status_code = wp_remote_retrieve_response_code( $response );
    12381310                $body        = json_decode( wp_remote_retrieve_body( $response ), true );
     1311                $clock_skew  = self::clock_skew( (string) wp_remote_retrieve_header( $response, 'date' ), time() );
    12391312
    12401313                if ( in_array( $status_code, array( 200, 201, 202 ), true ) ) {
    12411314                        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,
    12451319                        );
    12461320                }
    … …  
    12511325
    12521326                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' ) ),
    12561332                );
     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;
    12571374        }
    12581375
  • emporiqa/trunk/includes/class-emporiqa-widget.php

    r3549215 r3730094  
    1313 * - currency: ISO 4217 currency code (e.g. EUR, USD)
    1414 * - 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.
    1621 *
    1722 * @package Emporiqa
    … …  
    3136
    3237        /**
     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        /**
    3350         * Register hooks.
    3451         *
    … …  
    3855                add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_widget' ) );
    3956                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' ) );
    4059        }
    4160
    … …  
    6786                }
    6887
     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
    6993                $query_params = array(
    7094                        'store_id' => $store_id,
    71                         'language' => $this->get_current_language(),
     95                        'language' => $language,
    7296                );
    7397
    … …  
    92116                }
    93117
    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_secret
    100                                 );
    101                                 if ( ! empty( $user_token ) ) {
    102                                         $query_params['user_id'] = $user_token;
    103                                 }
    104                         }
    105                 }
    106 
    107118                /**
    108119                 * Filter the widget embed query parameters.
    … …  
    110121                 * @since 1.0.0
    111122                 *
    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).
    113124                 */
    114125                $query_params = apply_filters( 'emporiqa_widget_params', $query_params );
    … …  
    121132                wp_enqueue_script( 'emporiqa-widget', $widget_url, array(), EMPORIQA_VERSION, true );
    122133                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'] : '' ) );
    123260        }
    124261
    … …  
    152289
    153290        /**
    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 );
    179312        }
    180313
  • emporiqa/trunk/languages/emporiqa.pot

    r3678094 r3730094  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Emporiqa 1.1.10\n"
     5"Project-Id-Version: Emporiqa 1.2.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/emporiqa\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"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"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    1818#. Author of the plugin
    1919#: emporiqa.php
    20 #: emporiqa.php:1061
    21 #: emporiqa.php:1110
    22 #: includes/class-emporiqa-admin.php:59
     20#: emporiqa.php:1079
     21#: emporiqa.php:1128
     22#: includes/class-emporiqa-admin.php:64
    2323msgid "Emporiqa"
    2424msgstr ""
    … …  
    3434msgstr ""
    3535
    36 #: emporiqa.php:1062
     36#: emporiqa.php:1080
    3737msgid "requires WooCommerce to be installed and active."
    3838msgstr ""
    3939
    40 #: emporiqa.php:1111
     40#: emporiqa.php:1129
    4141msgid "requires WooCommerce 8.0 or higher. Some features may not work correctly with your current version."
    4242msgstr ""
    4343
    44 #: emporiqa.php:1128
     44#: emporiqa.php:1146
    4545msgid "Emporiqa activated!"
    4646msgstr ""
    4747
    48 #: emporiqa.php:1129
     48#: emporiqa.php:1147
    4949msgid "Connect your store to start syncing products and pages."
    5050msgstr ""
    5151
    52 #: emporiqa.php:1131
     52#: emporiqa.php:1149
    5353msgid "Go to Settings →"
    5454msgstr ""
    5555
    56 #: includes/class-emporiqa-admin.php:58
     56#: includes/class-emporiqa-admin.php:63
    5757msgid "Emporiqa Settings"
    5858msgstr ""
    5959
    60 #: includes/class-emporiqa-admin.php:78
    61 #: includes/class-emporiqa-admin.php:225
     60#: includes/class-emporiqa-admin.php:83
     61#: includes/class-emporiqa-admin.php:239
    6262msgid "Settings"
    6363msgstr ""
    6464
    6565#. translators: %s: link to Sync tab
    66 #: includes/class-emporiqa-admin.php:101
     66#: includes/class-emporiqa-admin.php:106
    6767#, php-format
    6868msgid "To sync your products and pages with Emporiqa, go to the <a href=\"%s\">Sync tab</a>."
    6969msgstr ""
    7070
    71 #: includes/class-emporiqa-admin.php:168
     71#: includes/class-emporiqa-admin.php:182
    7272msgid "Connection Secret is required. Webhooks and user identification will not work without it."
    7373msgstr ""
    7474
    7575#. translators: %s: link to Emporiqa dashboard integration overview
    76 #: includes/class-emporiqa-admin.php:197
     76#: includes/class-emporiqa-admin.php:211
    7777#, php-format
    7878msgid "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 &rarr; Settings &rarr; Integration &rarr; Integration overview</a>."
    7979msgstr ""
    8080
    81 #: includes/class-emporiqa-admin.php:228
     81#: includes/class-emporiqa-admin.php:242
    8282msgid "Sync"
    8383msgstr ""
    8484
    85 #: includes/class-emporiqa-admin.php:301
     85#: includes/class-emporiqa-admin.php:316
    8686msgid "Connection Settings"
    8787msgstr ""
    8888
    89 #: includes/class-emporiqa-admin.php:311
     89#: includes/class-emporiqa-admin.php:326
    9090msgid "Connected."
    9191msgstr ""
    9292
    93 #: includes/class-emporiqa-admin.php:312
     93#: includes/class-emporiqa-admin.php:327
    9494msgid "Your store is now linked to Emporiqa. Use the Sync tab to send your catalog."
    9595msgstr ""
    9696
    97 #: includes/class-emporiqa-admin.php:355
     97#: includes/class-emporiqa-admin.php:380
     98msgid "Ready-made rules"
     99msgstr ""
     100
     101#: includes/class-emporiqa-admin.php:383
     102msgid "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."
     103msgstr ""
     104
     105#: includes/class-emporiqa-admin.php:387
     106msgid "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."
     107msgstr ""
     108
     109#: includes/class-emporiqa-admin.php:392
     110msgid "Order status address"
     111msgstr ""
     112
     113#: includes/class-emporiqa-admin.php:398
     114#: includes/class-emporiqa-admin.php:453
     115#: includes/class-emporiqa-admin.php:709
     116msgid "Copy"
     117msgstr ""
     118
     119#: includes/class-emporiqa-admin.php:401
     120msgid "Emporiqa fills this in for you when you add the Order status rule. If it ever asks for the address, use this one."
     121msgstr ""
     122
     123#: includes/class-emporiqa-admin.php:403
     124msgid "Your site is not served over https, so Emporiqa cannot call it. Switch the site to https (Settings > General), then come back here."
     125msgstr ""
     126
     127#: includes/class-emporiqa-admin.php:408
     128msgid "Order status"
     129msgstr ""
     130
     131#: includes/class-emporiqa-admin.php:411
     132msgid "On"
     133msgstr ""
     134
     135#: includes/class-emporiqa-admin.php:413
     136msgid "Not added"
     137msgstr ""
     138
     139#: includes/class-emporiqa-admin.php:416
     140msgid "Open in Emporiqa"
     141msgstr ""
     142
     143#: includes/class-emporiqa-admin.php:420
     144msgid "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."
     145msgstr ""
     146
     147#: includes/class-emporiqa-admin.php:423
     148msgid "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."
     149msgstr ""
     150
     151#: includes/class-emporiqa-admin.php:442
     152msgid "Order tracking (old)"
     153msgstr ""
     154
     155#: includes/class-emporiqa-admin.php:448
     156msgid "Answer order lookups at the old order tracking address."
     157msgstr ""
     158
     159#: includes/class-emporiqa-admin.php:457
     160msgid "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."
     161msgstr ""
     162
     163#: includes/class-emporiqa-admin.php:527
    98164msgid "Connect to Emporiqa in one click"
    99165msgstr ""
    100166
    101 #: includes/class-emporiqa-admin.php:358
     167#: includes/class-emporiqa-admin.php:530
    102168msgid "We'll sign you in, link this store, and send back a fresh connection secret. No copy-pasting."
    103169msgstr ""
    104170
    105 #: includes/class-emporiqa-admin.php:371
     171#: includes/class-emporiqa-admin.php:543
    106172msgid "Connect to Emporiqa"
    107173msgstr ""
    108174
    109 #: includes/class-emporiqa-admin.php:376
    110 #: includes/class-emporiqa-connect.php:133
     175#: includes/class-emporiqa-admin.php:548
     176#: includes/class-emporiqa-connect.php:148
    111177msgid "Your site must be served over HTTPS to use one-click connect."
    112178msgstr ""
    113179
    114 #: includes/class-emporiqa-admin.php:383
     180#: includes/class-emporiqa-admin.php:555
    115181msgid "I'd rather paste credentials manually"
    116182msgstr ""
    117183
    118 #: includes/class-emporiqa-admin.php:400
     184#: includes/class-emporiqa-admin.php:572
    119185msgid "Connection in progress."
    120186msgstr ""
    121187
    122 #: includes/class-emporiqa-admin.php:401
     188#: includes/class-emporiqa-admin.php:573
    123189msgid "Finish the steps on emporiqa.com, then come back to this tab."
    124190msgstr ""
    125191
    126192#. translators: %s: link text for "start over"
    127 #: includes/class-emporiqa-admin.php:407
     193#: includes/class-emporiqa-admin.php:579
    128194#, php-format
    129195msgid "Tab got stuck? %s."
    130196msgstr ""
    131197
    132 #: includes/class-emporiqa-admin.php:411
     198#: includes/class-emporiqa-admin.php:583
    133199msgid "Start over"
    134200msgstr ""
    135201
    136 #: includes/class-emporiqa-admin.php:432
     202#: includes/class-emporiqa-admin.php:604
    137203msgid "Connected to Emporiqa."
    138204msgstr ""
    139205
    140 #: includes/class-emporiqa-admin.php:436
    141 #: includes/class-emporiqa-admin.php:480
     206#: includes/class-emporiqa-admin.php:608
     207#: includes/class-emporiqa-admin.php:652
    142208msgid "Store ID"
    143209msgstr ""
    144210
    145 #: includes/class-emporiqa-admin.php:442
     211#: includes/class-emporiqa-admin.php:614
    146212msgid "Connection secret"
    147213msgstr ""
    148214
    149 #: includes/class-emporiqa-admin.php:444
     215#: includes/class-emporiqa-admin.php:616
    150216msgid "Stored. Reconnect to rotate."
    151217msgstr ""
    152218
    153 #: includes/class-emporiqa-admin.php:450
     219#: includes/class-emporiqa-admin.php:622
    154220msgid "Reconnect"
    155221msgstr ""
    156222
    157 #: includes/class-emporiqa-admin.php:453
     223#: includes/class-emporiqa-admin.php:625
    158224msgid "Open Emporiqa dashboard"
    159225msgstr ""
    160226
    161 #: includes/class-emporiqa-admin.php:461
     227#: includes/class-emporiqa-admin.php:633
    162228msgid "Edit credentials manually"
    163229msgstr ""
    164230
    165231#. translators: %s: link to Emporiqa dashboard integration overview
    166 #: includes/class-emporiqa-admin.php:490
     232#: includes/class-emporiqa-admin.php:662
    167233#, php-format
    168234msgid "Find it in your <a href=\"%s\" target=\"_blank\">Emporiqa dashboard &rarr; Settings &rarr; Integration &rarr; Integration overview</a>"
    169235msgstr ""
    170236
    171 #: includes/class-emporiqa-admin.php:499
     237#: includes/class-emporiqa-admin.php:671
    172238msgid "Connection Secret"
    173239msgstr ""
    174240
    175 #: includes/class-emporiqa-admin.php:505
     241#: includes/class-emporiqa-admin.php:677
    176242msgid "Value is set (leave empty to keep)"
    177243msgstr ""
    178244
    179 #: includes/class-emporiqa-admin.php:511
     245#: includes/class-emporiqa-admin.php:683
    180246msgid "Not configured"
    181247msgstr ""
    182248
    183 #: includes/class-emporiqa-admin.php:516
     249#: includes/class-emporiqa-admin.php:688
    184250msgid "Required. Secret key for HMAC signature. Leave empty to keep the current value."
    185251msgstr ""
    186252
    187 #: includes/class-emporiqa-admin.php:532
     253#: includes/class-emporiqa-admin.php:704
    188254msgid "Order Tracking API URL"
    189255msgstr ""
    190256
    191 #: includes/class-emporiqa-admin.php:537
    192 msgid "Copy"
    193 msgstr ""
    194 
    195257#. translators: %s: link to Emporiqa dashboard order tracking settings
    196 #: includes/class-emporiqa-admin.php:545
     258#: includes/class-emporiqa-admin.php:717
    197259#, php-format
    198260msgid "Optional. To enable order tracking in the chat, paste this URL into your <a href=\"%s\" target=\"_blank\">Emporiqa dashboard &rarr; Settings &rarr; Integration &rarr; 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."
    199261msgstr ""
    200262
    201 #: includes/class-emporiqa-admin.php:569
     263#: includes/class-emporiqa-admin.php:741
    202264msgid "Advanced"
    203265msgstr ""
    204266
    205 #: includes/class-emporiqa-admin.php:575
     267#: includes/class-emporiqa-admin.php:747
    206268msgid "The defaults work for most stores. Open this only if you want to change what's sent to Emporiqa."
    207269msgstr ""
    208270
    209 #: includes/class-emporiqa-admin.php:579
     271#: includes/class-emporiqa-admin.php:751
    210272msgid "Sync products"
    211273msgstr ""
    212274
    213 #: includes/class-emporiqa-admin.php:584
     275#: includes/class-emporiqa-admin.php:756
    214276msgid "Send new and updated products to Emporiqa so the chat can recommend them."
    215277msgstr ""
    216278
    217 #: includes/class-emporiqa-admin.php:589
     279#: includes/class-emporiqa-admin.php:761
    218280msgid "Sync pages"
    219281msgstr ""
    220282
    221 #: includes/class-emporiqa-admin.php:594
     283#: includes/class-emporiqa-admin.php:766
    222284msgid "Send pages and blog content so the chat can answer questions about your shipping, returns, FAQs, and similar."
    223285msgstr ""
    224286
    225 #: includes/class-emporiqa-admin.php:599
     287#: includes/class-emporiqa-admin.php:771
    226288msgid "Languages"
    227289msgstr ""
    228290
    229 #: includes/class-emporiqa-admin.php:616
     291#: includes/class-emporiqa-admin.php:788
    230292msgid "Pick the languages of your products and pages to send. The chat replies in each shopper's language for any of these."
    231293msgstr ""
    232294
    233 #: includes/class-emporiqa-admin.php:621
     295#: includes/class-emporiqa-admin.php:793
    234296msgid "Pages and posts to include"
    235297msgstr ""
    236298
    237 #: includes/class-emporiqa-admin.php:642
     299#: includes/class-emporiqa-admin.php:814
    238300msgid "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."
    239301msgstr ""
    240302
    241 #: includes/class-emporiqa-admin.php:647
     303#: includes/class-emporiqa-admin.php:824
    242304msgid "Emporiqa server URL"
    243305msgstr ""
    244306
    245 #: includes/class-emporiqa-admin.php:652
     307#: includes/class-emporiqa-admin.php:829
    246308msgid "Where this site sends product and page updates. Keep the default (https://emporiqa.com/webhooks/sync/) unless Emporiqa support asked you to change it."
    247309msgstr ""
    248310
    249 #: includes/class-emporiqa-admin.php:657
     311#: includes/class-emporiqa-admin.php:834
    250312msgid "Items per batch"
    251313msgstr ""
    252314
    253 #: includes/class-emporiqa-admin.php:662
     315#: includes/class-emporiqa-admin.php:839
    254316msgid "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)."
    255317msgstr ""
    256318
    257 #: includes/class-emporiqa-admin.php:811
     319#: includes/class-emporiqa-admin.php:988
    258320msgid "Connected to Emporiqa. One more step."
    259321msgstr ""
    260322
    261 #: includes/class-emporiqa-admin.php:815
     323#: includes/class-emporiqa-admin.php:992
    262324msgid "Send your products and pages to Emporiqa so the chat has something to recommend. We'll send everything in batches in the background."
    263325msgstr ""
    264326
    265 #: includes/class-emporiqa-admin.php:828
     327#: includes/class-emporiqa-admin.php:1005
    266328msgid "Send my catalog"
    267329msgstr ""
    268330
    269331#. translators: 1: total item count, 2: typical time
    270 #: includes/class-emporiqa-admin.php:834
     332#: includes/class-emporiqa-admin.php:1011
    271333#, php-format
    272334msgid "%1$d items to send. Typically a few minutes."
    273335msgstr ""
    274336
    275 #: includes/class-emporiqa-admin.php:842
     337#: includes/class-emporiqa-admin.php:1019
    276338msgid "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."
    277339msgstr ""
    278340
    279 #: includes/class-emporiqa-admin.php:847
     341#: includes/class-emporiqa-admin.php:1024
    280342msgid "Bulk Sync"
    281343msgstr ""
    282344
    283 #: includes/class-emporiqa-admin.php:849
     345#: includes/class-emporiqa-admin.php:1026
    284346msgid "Use \"Sync All\" for your initial sync. After that, product and page changes are sent automatically in real-time via webhooks."
    285347msgstr ""
    286348
    287 #: includes/class-emporiqa-admin.php:855
     349#: includes/class-emporiqa-admin.php:1032
    288350msgid "products"
    289351msgstr ""
    290352
    291353#. translators: %s: language codes
    292 #: includes/class-emporiqa-admin.php:859
     354#: includes/class-emporiqa-admin.php:1036
    293355#, php-format
    294356msgid "(all languages synced per product: %s)"
    295357msgstr ""
    296358
    297 #: includes/class-emporiqa-admin.php:866
     359#: includes/class-emporiqa-admin.php:1043
    298360msgid "No published products found. Make sure you have published products before syncing."
    299361msgstr ""
    300362
    301363#. translators: %s: link to settings tab
    302 #: includes/class-emporiqa-admin.php:875
     364#: includes/class-emporiqa-admin.php:1052
    303365#, php-format
    304366msgid "Product sync is disabled &mdash; <a href=\"%s\">enable it in the Settings tab</a>"
    305367msgstr ""
    306368
    307 #: includes/class-emporiqa-admin.php:885
     369#: includes/class-emporiqa-admin.php:1062
    308370msgid "pages"
    309371msgstr ""
    310372
    311373#. translators: %s: language codes
    312 #: includes/class-emporiqa-admin.php:889
     374#: includes/class-emporiqa-admin.php:1066
    313375#, php-format
    314376msgid "(all languages synced per page: %s)"
    315377msgstr ""
    316378
    317 #: includes/class-emporiqa-admin.php:896
     379#: includes/class-emporiqa-admin.php:1073
    318380msgid "No published pages found. Make sure you have published pages before syncing."
    319381msgstr ""
    320382
    321383#. translators: %s: link to settings tab
    322 #: includes/class-emporiqa-admin.php:905
     384#: includes/class-emporiqa-admin.php:1082
    323385#, php-format
    324386msgid "Page sync is disabled &mdash; <a href=\"%s\">enable it in the Settings tab</a>"
    325387msgstr ""
    326388
    327 #: includes/class-emporiqa-admin.php:917
     389#: includes/class-emporiqa-admin.php:1094
    328390msgid "Please configure your Store ID and Connection Secret in the Settings tab before syncing."
    329391msgstr ""
    330392
    331 #: includes/class-emporiqa-admin.php:929
     393#: includes/class-emporiqa-admin.php:1106
    332394msgid "Sync Products"
    333395msgstr ""
    334396
    335 #: includes/class-emporiqa-admin.php:931
     397#: includes/class-emporiqa-admin.php:1108
    336398msgid "Send all products to Emporiqa."
    337399msgstr ""
    338400
    339 #: includes/class-emporiqa-admin.php:936
    340 #: includes/class-emporiqa-admin.php:957
    341 #: includes/class-emporiqa-admin.php:976
     401#: includes/class-emporiqa-admin.php:1113
     402#: includes/class-emporiqa-admin.php:1134
     403#: includes/class-emporiqa-admin.php:1153
    342404msgid "Configure your Store ID and Connection Secret first"
    343405msgstr ""
    344406
    345 #: includes/class-emporiqa-admin.php:938
     407#: includes/class-emporiqa-admin.php:1115
    346408msgid "Enable product sync in the Settings tab"
    347409msgstr ""
    348410
    349 #: includes/class-emporiqa-admin.php:950
     411#: includes/class-emporiqa-admin.php:1127
    350412msgid "Sync Pages"
    351413msgstr ""
    352414
    353 #: includes/class-emporiqa-admin.php:952
     415#: includes/class-emporiqa-admin.php:1129
    354416msgid "Send all pages to Emporiqa."
    355417msgstr ""
    356418
    357 #: includes/class-emporiqa-admin.php:959
     419#: includes/class-emporiqa-admin.php:1136
    358420msgid "Enable page sync in the Settings tab"
    359421msgstr ""
    360422
    361 #: includes/class-emporiqa-admin.php:971
     423#: includes/class-emporiqa-admin.php:1148
    362424msgid "Sync All"
    363425msgstr ""
    364426
    365 #: includes/class-emporiqa-admin.php:973
     427#: includes/class-emporiqa-admin.php:1150
    366428msgid "Send all products and pages at once."
    367429msgstr ""
    368430
    369 #: includes/class-emporiqa-admin.php:984
     431#: includes/class-emporiqa-admin.php:1161
    370432msgid "Cancel"
    371433msgstr ""
    372434
    373 #: includes/class-emporiqa-admin.php:995
     435#: includes/class-emporiqa-admin.php:1172
    374436msgid "Sync log"
    375437msgstr ""
    376438
    377 #: includes/class-emporiqa-admin.php:998
     439#: includes/class-emporiqa-admin.php:1175
    378440msgid "Test Connection"
    379441msgstr ""
    380442
    381 #: includes/class-emporiqa-admin.php:1000
     443#: includes/class-emporiqa-admin.php:1177
    382444msgid "Sends a real product to Emporiqa in dry run mode. Validates the connection, signature, and payload without storing anything."
    383445msgstr ""
    384446
    385 #: includes/class-emporiqa-admin.php:1004
     447#: includes/class-emporiqa-admin.php:1181
    386448msgid "Test Connection (Dry Run)"
    387449msgstr ""
    388450
    389 #: includes/class-emporiqa-admin.php:1011
     451#: includes/class-emporiqa-admin.php:1188
    390452msgid "WP-CLI Commands (for developers)"
    391453msgstr ""
    392454
    393 #: includes/class-emporiqa-admin.php:1059
     455#: includes/class-emporiqa-admin.php:1236
    394456msgid "Testing..."
    395457msgstr ""
    396458
    397 #: includes/class-emporiqa-admin.php:1060
     459#: includes/class-emporiqa-admin.php:1237
    398460msgid "Copied!"
    399461msgstr ""
    400462
    401 #: includes/class-emporiqa-admin.php:1062
     463#: includes/class-emporiqa-admin.php:1239
    402464msgid "Initializing sync..."
    403465msgstr ""
    404466
    405467#. translators: %s: entity type (products/pages)
    406 #: includes/class-emporiqa-admin.php:1064
     468#: includes/class-emporiqa-admin.php:1241
    407469#, php-format
    408470msgid "Started %s sync"
    … …  
    410472
    411473#. translators: 1: number of items, 2: number of events, 3: entity type
    412 #: includes/class-emporiqa-admin.php:1066
     474#: includes/class-emporiqa-admin.php:1243
    413475#, php-format
    414476msgid "Processed %1$d items (%2$d events) for %3$s"
    … …  
    416478
    417479#. translators: %s: entity type
    418 #: includes/class-emporiqa-admin.php:1068
     480#: includes/class-emporiqa-admin.php:1245
    419481#, php-format
    420482msgid "Batch failed for %s"
    421483msgstr ""
    422484
     485#. translators: 1: entity type (products/pages), 2: seconds
     486#: includes/class-emporiqa-admin.php:1247
     487#, php-format
     488msgid "Emporiqa is busy and asked to wait. Sending this %1$s batch again in %2$d seconds."
     489msgstr ""
     490
    423491#. translators: %s: entity type (products/pages)
    424 #: includes/class-emporiqa-admin.php:1070
     492#: includes/class-emporiqa-admin.php:1249
    425493#, php-format
    426494msgid "Completed %s sync session"
    … …  
    428496
    429497#. translators: %s: entity type (products/pages)
    430 #: includes/class-emporiqa-admin.php:1072
     498#: includes/class-emporiqa-admin.php:1251
    431499#, php-format
    432500msgid "Failed to complete %s session"
    … …  
    434502
    435503#. translators: 1: entity type (products/pages), 2: total count
    436 #: includes/class-emporiqa-admin.php:1074
     504#: includes/class-emporiqa-admin.php:1253
    437505#, php-format
    438506msgid "Total %1$s synced: %2$d items"
    439507msgstr ""
    440508
    441 #: includes/class-emporiqa-admin.php:1075
     509#: includes/class-emporiqa-admin.php:1254
    442510msgid "Sync completed successfully!"
    443511msgstr ""
    444512
    445513#. translators: %s: entity type (products/pages)
    446 #: includes/class-emporiqa-admin.php:1077
     514#: includes/class-emporiqa-admin.php:1256
    447515#, php-format
    448516msgid "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."
    … …  
    450518
    451519#. translators: %s: entity type (products/pages)
    452 #: includes/class-emporiqa-admin.php:1079
     520#: includes/class-emporiqa-admin.php:1258
    453521#, php-format
    454522msgid "No %s were synced, so the session was not finalized."
    455523msgstr ""
    456524
    457 #: includes/class-emporiqa-admin.php:1080
     525#: includes/class-emporiqa-admin.php:1259
    458526msgid "Sync finished with errors — see the log above. Re-run the sync after resolving them."
    459527msgstr ""
    460528
    461529#. translators: 1: Products link placeholder, 2: Pages link placeholder
    462 #: includes/class-emporiqa-admin.php:1082
     530#: includes/class-emporiqa-admin.php:1261
    463531#, php-format
    464532msgid "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."
    … …  
    466534
    467535#. translators: used as link text in sync log
    468 #: includes/class-emporiqa-admin.php:1086
     536#: includes/class-emporiqa-admin.php:1265
    469537msgid "Products"
    470538msgstr ""
    471539
    472540#. translators: used as link text in sync log
    473 #: includes/class-emporiqa-admin.php:1088
     541#: includes/class-emporiqa-admin.php:1267
    474542msgid "Pages"
    475543msgstr ""
    476544
    477 #: includes/class-emporiqa-admin.php:1089
     545#: includes/class-emporiqa-admin.php:1268
    478546msgid "Sync cancelled."
    479547msgstr ""
    480548
    481549#. translators: %s: error message
    482 #: includes/class-emporiqa-admin.php:1091
     550#: includes/class-emporiqa-admin.php:1270
    483551#, php-format
    484552msgid "Sync failed: %s"
    485553msgstr ""
    486554
    487 #: includes/class-emporiqa-admin.php:1092
     555#: includes/class-emporiqa-admin.php:1271
    488556msgid "Failed to initialize sync."
    489557msgstr ""
    490558
    491 #: includes/class-emporiqa-admin.php:1093
     559#: includes/class-emporiqa-admin.php:1272
    492560msgid "Sample product payload"
    493561msgstr ""
    494562
    495 #: includes/class-emporiqa-admin.php:1094
     563#: includes/class-emporiqa-admin.php:1273
    496564msgid "Sample page payload"
    497565msgstr ""
    498566
    499 #: includes/class-emporiqa-admin.php:1111
     567#: includes/class-emporiqa-admin.php:1290
    500568#: includes/class-emporiqa-sync.php:65
    501 #: includes/class-emporiqa-sync.php:161
    502 #: includes/class-emporiqa-sync.php:201
     569#: includes/class-emporiqa-sync.php:165
     570#: includes/class-emporiqa-sync.php:209
    503571msgid "Permission denied."
    504572msgstr ""
    505573
    506 #: includes/class-emporiqa-cart-ajax.php:81
    507 #: includes/class-emporiqa-cart-ajax.php:190
    508 #: includes/class-emporiqa-cart-ajax.php:229
     574#: includes/class-emporiqa-cart-ajax.php:89
     575#: includes/class-emporiqa-cart-ajax.php:198
     576#: includes/class-emporiqa-cart-ajax.php:237
    509577msgid "No product ID provided."
    510578msgstr ""
    511579
    512 #: includes/class-emporiqa-cart-ajax.php:90
     580#: includes/class-emporiqa-cart-ajax.php:98
    513581msgid "Product not found."
    514582msgstr ""
    515583
    516 #: includes/class-emporiqa-cart-ajax.php:95
     584#: includes/class-emporiqa-cart-ajax.php:103
    517585msgid "This product cannot be purchased."
    518586msgstr ""
    519587
    520 #: includes/class-emporiqa-cart-ajax.php:98
     588#: includes/class-emporiqa-cart-ajax.php:106
    521589msgid "This product is currently out of stock."
    522590msgstr ""
    523591
    524 #: includes/class-emporiqa-cart-ajax.php:109
     592#: includes/class-emporiqa-cart-ajax.php:117
    525593msgid "Parent product not found."
    526594msgstr ""
    527595
    528 #: includes/class-emporiqa-cart-ajax.php:119
     596#: includes/class-emporiqa-cart-ajax.php:127
    529597msgid "Please select a product variation."
    530598msgstr ""
    531599
    532 #: includes/class-emporiqa-cart-ajax.php:128
     600#: includes/class-emporiqa-cart-ajax.php:136
    533601msgid "Variation not found."
    534602msgstr ""
    535603
    536 #: includes/class-emporiqa-cart-ajax.php:131
     604#: includes/class-emporiqa-cart-ajax.php:139
    537605msgid "This variation cannot be purchased."
    538606msgstr ""
    539607
    540 #: includes/class-emporiqa-cart-ajax.php:134
     608#: includes/class-emporiqa-cart-ajax.php:142
    541609msgid "This variation is currently out of stock."
    542610msgstr ""
    543611
    544 #: includes/class-emporiqa-cart-ajax.php:144
     612#: includes/class-emporiqa-cart-ajax.php:152
    545613msgid "Failed to add item to cart."
    546614msgstr ""
    547615
    548 #: includes/class-emporiqa-cart-ajax.php:194
     616#: includes/class-emporiqa-cart-ajax.php:202
    549617msgid "Quantity must be greater than zero."
    550618msgstr ""
    551619
    552 #: includes/class-emporiqa-cart-ajax.php:199
    553 #: includes/class-emporiqa-cart-ajax.php:234
     620#: includes/class-emporiqa-cart-ajax.php:207
     621#: includes/class-emporiqa-cart-ajax.php:242
    554622msgid "Item not found in cart."
    555623msgstr ""
    556624
    557 #: includes/class-emporiqa-cart-ajax.php:206
     625#: includes/class-emporiqa-cart-ajax.php:214
    558626msgid "Failed to update cart."
    559627msgstr ""
    560628
    561 #: includes/class-emporiqa-cart-ajax.php:241
     629#: includes/class-emporiqa-cart-ajax.php:249
    562630msgid "Failed to remove item."
    563631msgstr ""
    564632
    565 #: includes/class-emporiqa-cart-ajax.php:316
     633#: includes/class-emporiqa-cart-ajax.php:326
    566634msgid "Security check failed. Please refresh the page and try again."
    567635msgstr ""
    568636
    569 #: includes/class-emporiqa-connect.php:145
     637#: includes/class-emporiqa-connect.php:160
    570638msgid "Couldn't save the connection state. Please try again."
    571639msgstr ""
    572640
    573 #: includes/class-emporiqa-connect.php:194
     641#: includes/class-emporiqa-connect.php:209
    574642msgid "Unexpected redirect source."
    575643msgstr ""
    576644
    577 #: includes/class-emporiqa-connect.php:199
     645#: includes/class-emporiqa-connect.php:214
    578646msgid "Callback missing required parameters."
    579647msgstr ""
    580648
    581 #: includes/class-emporiqa-connect.php:207
     649#: includes/class-emporiqa-connect.php:222
    582650msgid "Connection link expired or already used. Click Connect to try again."
    583651msgstr ""
    584652
    585653#. translators: %s: WP HTTP API error message
    586 #: includes/class-emporiqa-connect.php:237
     654#: includes/class-emporiqa-connect.php:255
    587655#, php-format
    588656msgid "Couldn't reach Emporiqa: %s"
    … …  
    590658
    591659#. translators: %s: error code from the exchange endpoint
    592 #: includes/class-emporiqa-connect.php:253
     660#: includes/class-emporiqa-connect.php:278
    593661#, php-format
    594662msgid "Emporiqa refused the connection: %s"
    595663msgstr ""
    596664
    597 #: includes/class-emporiqa-formatter.php:941
    598 #: includes/class-emporiqa-formatter.php:952
     665#: includes/class-emporiqa-formatter.php:869
     666#: includes/class-emporiqa-formatter.php:880
    599667msgid "General"
    600668msgstr ""
    601669
    602 #: includes/class-emporiqa-formatter.php:1021
     670#: includes/class-emporiqa-formatter.php:1245
    603671msgid "Generic"
    604672msgstr ""
    605673
    606 #: includes/class-emporiqa-order-tracking.php:80
     674#: includes/class-emporiqa-order-tracking.php:96
    607675msgid "Missing signature."
    608676msgstr ""
    609677
    610 #: includes/class-emporiqa-order-tracking.php:89
    611 #: includes/class-emporiqa-order-tracking.php:99
     678#: includes/class-emporiqa-order-tracking.php:105
     679#: includes/class-emporiqa-order-tracking.php:115
    612680msgid "Invalid signature."
    613681msgstr ""
    614682
    615 #: includes/class-emporiqa-order-tracking.php:108
     683#: includes/class-emporiqa-order-tracking.php:124
    616684msgid "Invalid JSON in request body."
    617685msgstr ""
    618686
    619 #: includes/class-emporiqa-order-tracking.php:117
     687#: includes/class-emporiqa-order-tracking.php:133
    620688msgid "Request expired."
    621689msgstr ""
    622690
    623 #: includes/class-emporiqa-order-tracking.php:158
     691#: includes/class-emporiqa-order-tracking.php:197
    624692msgid "Invalid payload: order_identifier is required."
    625693msgstr ""
    626694
    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
     698msgid "Order not found."
     699msgstr ""
     700
     701#: includes/class-emporiqa-order-tracking.php:230
    628702msgid "Too many requests. Please try again later."
    629 msgstr ""
    630 
    631 #: includes/class-emporiqa-order-tracking.php:251
    632 #: includes/class-emporiqa-order-tracking.php:260
    633 msgid "Order not found."
    634703msgstr ""
    635704
    … …  
    642711msgstr ""
    643712
    644 #: includes/class-emporiqa-sync.php:217
     713#: includes/class-emporiqa-sync.php:225
    645714msgid "Unknown or expired sync session — it was not finalized. Re-run the sync."
    646715msgstr ""
    647716
    648717#. translators: 1: number of items that failed, 2: entity type (products or pages).
    649 #: includes/class-emporiqa-sync.php:228
     718#: includes/class-emporiqa-sync.php:236
    650719#, php-format
    651720msgid "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."
    652721msgstr ""
    653722
    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
     726msgid "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."
     727msgstr ""
     728
     729#: includes/class-emporiqa-sync.php:261
    655730msgid "No items were synced in this session, so it was not finalized (finalizing an empty session would delete the entire remote catalog)."
    656731msgstr ""
    657732
    658 #: includes/class-emporiqa-sync.php:249
     733#: includes/class-emporiqa-sync.php:270
    659734msgid "Session completed."
    660735msgstr ""
    661736
    662 #: includes/class-emporiqa-sync.php:252
     737#: includes/class-emporiqa-sync.php:273
    663738msgid "Failed to complete sync session."
    664739msgstr ""
    665740
    666 #: includes/class-emporiqa-webhook.php:915
     741#: includes/class-emporiqa-webhook.php:989
    667742msgid "Unknown error"
    668743msgstr ""
    669744
    670 #: includes/class-emporiqa-webhook.php:1000
     745#: includes/class-emporiqa-webhook.php:1074
    671746msgid "Store ID is not configured."
    672747msgstr ""
    673748
    674 #: includes/class-emporiqa-webhook.php:1009
     749#: includes/class-emporiqa-webhook.php:1083
    675750msgid "Connection Secret is not configured."
    676751msgstr ""
    677752
    678 #: includes/class-emporiqa-webhook.php:1018
     753#: includes/class-emporiqa-webhook.php:1092
    679754msgid "No published products found. Add a product before testing."
    680755msgstr ""
    681756
    682 #: includes/class-emporiqa-webhook.php:1027
     757#: includes/class-emporiqa-webhook.php:1101
    683758msgid "Failed to format product for dry run."
    684759msgstr ""
    685760
    686 #: includes/class-emporiqa-webhook.php:1058
     761#. translators: %d: number of minutes
     762#: includes/class-emporiqa-webhook.php:1123
     763#, php-format
     764msgid "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."
     765msgstr ""
     766
     767#: includes/class-emporiqa-webhook.php:1149
    687768msgid "Connection successful, but there are warnings."
    688769msgstr ""
    689770
    690 #: includes/class-emporiqa-webhook.php:1059
     771#: includes/class-emporiqa-webhook.php:1150
    691772msgid "Connection successful! All fields validated."
    692773msgstr ""
    693774
    694775#. translators: %s: human-readable error message from the Emporiqa server
    695 #: includes/class-emporiqa-webhook.php:1067
     776#: includes/class-emporiqa-webhook.php:1158
    696777#, php-format
    697778msgid "Connection failed: %s"
    698779msgstr ""
    699780
    700 #: includes/class-emporiqa-webhook.php:1174
     781#: includes/class-emporiqa-webhook.php:1249
    701782msgid "Webhook not configured (missing Store ID)."
    702783msgstr ""
    703784
    704 #: includes/class-emporiqa-webhook.php:1183
     785#: includes/class-emporiqa-webhook.php:1258
    705786msgid "Connection Secret is not configured. Go to Settings > Emporiqa to set it."
    706787msgstr ""
    707788
    708 #: includes/class-emporiqa-webhook.php:1196
     789#: includes/class-emporiqa-webhook.php:1271
    709790msgid "Failed to encode payload as JSON."
    710791msgstr ""
    711792
    712793#. translators: %d: HTTP status code
    713 #: includes/class-emporiqa-webhook.php:1249
     794#: includes/class-emporiqa-webhook.php:1323
    714795#, php-format
    715796msgid "Unexpected status code: %d"
  • emporiqa/trunk/readme.txt

    r3725261 r3730094  
    22Contributors: rosenhristov1
    33Tags: ai chatbot, live chat, woocommerce chatbot, ecommerce chatbot, ai customer support
    4 Requires at least: 6.0
     4Requires at least: 6.2
    55Tested up to: 7.1
    66Requires Plugins: woocommerce
    77Requires PHP: 7.4
    88WC requires at least: 8.0
    9 WC tested up to: 10.9
    10 Stable tag: 1.1.10
     9WC tested up to: 11.1
     10Stable tag: 1.2.0
    1111License: GPLv2 or later
    1212License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    5454Price, 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.
    5555
     56= Privacy and security notes =
     57
     58The 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
    5660= Third-Party Service =
    5761
    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/).
     62This 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/).
    5963
    6064== Installation ==
    … …  
    1201247. Order tracking: the chat asks for an order number and matching email
    1211258. 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 cap
     1269. Billing: pay-as-you-go at $0.25 a conversation, capped at $59 a month, with the next billing date
    12312710. Voice mode, if you switch it on: the answer is read aloud in an AI voice while the product cards stay on screen
    124128
    125129== 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.
    126153
    127154= 1.1.10 =
    … …  
    130157* Text only. No functional changes.
    131158
    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 
    136159Older entries are in changelog.txt, shipped with the plugin.
    137160
    138161== Upgrade Notice ==
    139162
     163= 1.2.0 =
     164Adds 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
    140166= 1.1.10 =
    141167Text-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  
    1616delete_option( 'emporiqa_pending_async_events' );
    1717delete_option( 'emporiqa_instance_id' );
     18delete_option( 'emporiqa_rules_status' );
    1819
    1920// Clean up retry transients + one-click connect handshake transients.
    … …  
    2829}
    2930
    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
    3234
    3335// Clean up order meta.
Note: See TracChangeset for help on using the changeset viewer.