diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
index 578255e..6cffa59 100644
--- a/.github/workflows/php.yml
+++ b/.github/workflows/php.yml
@@ -95,6 +95,8 @@ jobs:
test -f release/hellotext-wordpress/hellotext.php
test -d release/hellotext-wordpress/src
test -f release/hellotext-wordpress/vendor/autoload.php
+ test -f release/hellotext-wordpress/assets/hellotext-service-worker.js
+ test -f release/hellotext-wordpress/assets/storefront.js
for path in \
release/hellotext-wordpress/tests \
diff --git a/API.md b/API.md
index ef31d6e..1f85f03 100644
--- a/API.md
+++ b/API.md
@@ -83,15 +83,9 @@ $response = Client::post(Constants::API_ENDPOINT_PROFILES, [
- `put(string $path, ?array $data = null): array`
- `delete(string $path, ?array $data = null): array`
-##### `with_sufix(string $sufix = ''): self`
+##### `api_url(): string`
-Creates a client instance with a custom API suffix. Useful for testing.
-
-**Example:**
-
-```php
-$client = Client::with_sufix('/v2');
-```
+Returns the Hellotext API URL: `https://api.hellotext.com`, or the `HELLOTEXT_API_URL` constant when it is defined.
## Event Tracking
@@ -105,7 +99,7 @@ Tracks customer activities and sends them to Hellotext.
public function __construct(?string $session = null)
```
-Creates a new event tracker. If no session is provided, it attempts to retrieve it from the `hello_session` cookie.
+Creates a new event tracker for the given session. Storefront events pass `Session::current()`; order and refund events pass `Session::for_order($order)`, because they also run from wp-admin and payment callbacks, where the browser cookie belongs to someone else.
#### Methods
@@ -305,39 +299,35 @@ $service->process();
### `Hellotext\Services\Session`
-Manages session encryption and cookies.
+Reads the `hello_session` cookie that Hellotext.js sets, and encrypts the copy stored on orders.
#### Static Methods
-##### `create(): string`
+##### `current(): ?string`
-Creates a new session identifier.
+The visitor's Hellotext.js session, or `null` when the cookie is missing.
-**Returns:** UUID session string
+##### `for_order(WC_Order $order): ?string`
-##### `encrypt(string $session): string`
+The session stored on the order at checkout, or `null` when there is none.
-Encrypts a session string using AES-256-CBC.
-
-##### `decrypt(string $encrypted_session): string`
+##### `encrypt(?string $session): string`
-Decrypts an encrypted session string.
+Encrypts a session string using AES-256-CBC.
-##### `set_cookie(string $session): void`
+##### `decrypt(mixed $encrypted_session): string|false`
-Sets the `hello_session` cookie with proper expiration and security flags.
+Decrypts an encrypted session string. Returns `false` for empty or malformed values.
**Example:**
```php
use Hellotext\Services\Session;
-$session = Session::create();
-Session::set_cookie($session);
+$order->update_meta_data('hellotext_session', Session::encrypt(Session::current()));
+$order->save_meta_data();
-// Later, encrypt for storage
-$encrypted = Session::encrypt($session);
-add_post_meta($order_id, 'hellotext_session', $encrypted);
+$session = Session::for_order($order);
```
## Constants Reference
@@ -349,12 +339,12 @@ All plugin constants are centralized in this class.
#### API Configuration
```php
-Constants::API_VERSION // 'v1'
+Constants::API_URL // 'https://api.hellotext.com'
Constants::API_ENDPOINT_TRACK // '/v1/track/events'
-Constants::API_ENDPOINT_PROFILES // '/profiles'
-Constants::API_ENDPOINT_SESSIONS // '/sessions'
-Constants::API_ENDPOINT_WEBCHATS // '/v1/wordpress/webchats'
+Constants::API_ENDPOINT_PROFILES // '/v1/profiles'
+Constants::API_ENDPOINT_SESSIONS // '/v1/sessions'
Constants::API_ENDPOINT_INTEGRATIONS_WOO // '/integrations/woo'
+Constants::SDK_URL // Hellotext.js on jsDelivr, unversioned
```
#### Session & Encryption
@@ -369,16 +359,15 @@ Constants::SESSION_COOKIE_NAME // 'hello_session'
```php
Constants::OPTION_BUSINESS_ID // 'hellotext_business_id'
Constants::OPTION_ACCESS_TOKEN // 'hellotext_access_token'
-Constants::OPTION_WEBCHAT_ID // 'hellotext_webchat_id'
-Constants::OPTION_WEBCHAT_PLACEMENT // 'hellotext_webchat_placement'
-Constants::OPTION_WEBCHAT_BEHAVIOUR // 'hellotext_webchat_behaviour'
+Constants::OPTIONS // every option above plus the webchat options of earlier versions, removed on uninstall
```
-#### User Meta Keys
+#### Meta Keys
```php
-Constants::META_PROFILE_ID // 'hellotext_profile_id'
-Constants::META_SESSION // 'hellotext_session'
+Constants::META_PROFILE_ID // 'hellotext_profile_id' (user)
+Constants::META_SESSION // 'hellotext_session' (user and order)
+Constants::META_ORDER_PLACED // 'hellotext_order_placed' (order): 'pending' after checkout, 'tracked' once order.placed is sent
```
#### Event Names
@@ -403,19 +392,26 @@ The plugin registers these WooCommerce and WordPress hooks:
| Hook | Handler | Purpose |
| --------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `woocommerce_after_single_product` | `hellotext_product_viewed` | Track `product.viewed` |
-| `woocommerce_after_cart` | `hellotext_trigger_cart_updated` | Compare current and previous cart state |
-| `woocommerce_add_to_cart` | `hellotext_trigger_cart_updated` | Compare current and previous cart state |
-| `woocommerce_cart_item_removed` | `hellotext_trigger_cart_updated` | Compare current and previous cart state |
-| `woocommerce_after_cart_item_quantity_update` | `hellotext_trigger_cart_updated` | Compare current and previous cart state |
+| `woocommerce_after_cart` | `hellotext_trigger_cart_updated` | Schedule a cart comparison at shutdown |
+| `woocommerce_add_to_cart` | `hellotext_trigger_cart_updated` | Schedule a cart comparison at shutdown |
+| `woocommerce_cart_item_removed` | `hellotext_trigger_cart_updated` | Schedule a cart comparison at shutdown |
+| `woocommerce_after_cart_item_quantity_update` | `hellotext_trigger_cart_updated` | Schedule a cart comparison at shutdown |
+| `shutdown` | `hellotext_cart_updated` | Track `cart.added` with the product's whole quantity in the cart, which Hellotext sets the line to, and `cart.removed` with the quantity removed, after WooCommerce recalculates totals. A Webchat card's addition carries its message as `source` |
+| `woocommerce_cart_emptied` | `hellotext_reset_cart_snapshot` | Forget the cart snapshot and the tracked checkout so purchased items are not reported as removed and the next checkout is reported |
+| `template_redirect` | `hellotext_cart_viewed` | Track `cart.viewed` on the cart page with the whole cart |
+| `template_redirect` | `hellotext_checkout_started` | Track `checkout.started` on the checkout page, once per cart; not on the thank-you or payment pages |
+| `template_redirect` | `hellotext_checkout_link` | `?hellotext_checkout=1&products=id:quantity,…`, the checkout link Hellotext sends stores older than WooCommerce 10.0: replace the cart with the products and open the checkout with the link's other parameters, or the page of a product that needs options |
| `woocommerce_applied_coupon` | `hellotext_coupon_redeemed` | Track valid `coupon.redeemed` events |
-| `woocommerce_after_order_details` | `hellotext_order_placed` | Track `order.placed` and persist encrypted session metadata on the order; render-based hook, so duplicate prevention is required |
-| `woocommerce_order_status_changed` | `track_order_status` | Track `order.confirmed`, `order.cancelled`, and `order.delivered` |
+| `woocommerce_checkout_order_processed` | `hellotext_checkout_order_processed` | Classic checkout: store the encrypted session on the order and create the customer's profile |
+| `woocommerce_store_api_checkout_order_processed` | `hellotext_capture_checkout` | Checkout block: store the encrypted session on the order and create the customer's profile |
+| `woocommerce_thankyou` | `hellotext_thankyou` | Track `order.placed` once the order is paid or on hold |
+| `woocommerce_order_status_changed` | `hellotext_order_status_changed` | Track `order.placed` if still pending (block themes, customers who never return from payment), then `order.confirmed`, `order.cancelled`, `order.delivered` |
| `woocommerce_order_refunded` | `hellotext_refund_created` | Track `refund.received` |
| `user_register` | `hellotext_user_registered` | Track customer registration/profile flow |
-| `hellotext_woocommerce_cart_updated` | `hellotext_cart_updated` | Track `cart.added` and `cart.removed` |
-| `update_option` | `custom_field_updated` | Recreate integration after Business ID changes |
+| `add_option_*` / `update_option_*` (Business ID, Access Token) | `hellotext_schedule_connect`, `hellotext_business_id_updated` | Connect the store once both settings are saved, sending the WooCommerce version; remove the previous business's integration first when the Business ID changes. The previous API key keeps working until Hellotext accepts the new one |
+| `pre_update_option_hellotext_access_token` | `hellotext_retry_failed_connection` | Retry a failed connection when the settings are saved again, even unchanged; the settings page shows the failure |
| `admin_init` | `hellotext_settings_init` | Register plugin settings |
-| `admin_head` / `wp_head` | `hellotext_script` | Inject frontend/admin scripts |
+| `wp_enqueue_scripts` | `hellotext_enqueue_script` | Load the latest Hellotext.js and `assets/storefront.js` on the storefront, with its settings in `window.hellotextWoo` |
## WooCommerce Compatibility
@@ -435,11 +431,11 @@ Do not add a formal HPOS compatibility declaration until the release candidate h
#### `hellotext_create_profile`
-Triggers profile creation/association.
+Triggers profile creation/association. Fire it only from requests the customer makes, since it links the profile to the browser's Hellotext.js session.
**Parameters:**
-- `$payload` - Either user ID (int) or billing data (array)
+- `$payload` - User ID (int), billing data (array), or nothing for the current user
**Usage:**
@@ -460,30 +456,47 @@ do_action('hellotext_create_profile', [
The plugin automatically hooks into these WooCommerce actions:
-- `woocommerce_after_order_details` - Track order placement
+- `woocommerce_checkout_order_processed` / `woocommerce_store_api_checkout_order_processed` - Capture the checkout session
+- `woocommerce_thankyou` - Track order placement
- `woocommerce_order_status_changed` - Track order status updates
- `woocommerce_order_refunded` - Track refunds
- `woocommerce_add_to_cart` - Track cart additions
- `woocommerce_cart_item_removed` - Track cart removals
- `woocommerce_applied_coupon` - Track coupon usage
-- `wp_footer` - Inject tracking scripts and webchat
+- `template_redirect` - Track the cart page and the checkout
+- `wp_enqueue_scripts` - Load Hellotext.js. Webchat, popups and other widgets are configured from Hellotext
-## Testing
+### Storefront script
-For testing information, see [DEVELOPMENT.md](DEVELOPMENT.md).
+`assets/storefront.js` starts Hellotext.js with the settings the plugin prints in `window.hellotextWoo`. After `Hellotext.initialize()`, it:
+
+1. Sets `Hellotext.page.section`, so every event of the page reports it, Webchat messages included.
+2. Tracks `page.viewed`, which Hellotext.js does not do on its own, except on product pages (`product.viewed`) and the checkout (`checkout.started`).
+3. Calls `Hellotext.alert.show(section)`. Hellotext shows the alert only for the sections enabled in the Smart Alert playbook.
-## Environment Variables
+It also listens for the `cart.added` event that the Webchat's product cards dispatch, and adds the card's product to the WooCommerce cart through `?wc-ajax=add_to_cart`, with the message in `hellotext_source` so the plugin credits the addition to it. It then refreshes the mini cart with `added_to_cart`, as WooCommerce's own buttons do. Products that need options open their page, and the store's "Redirect to the cart page after successful addition" setting is followed.
-The plugin respects the following environment variables:
+### Page sections
-- `APP_ENV` - Environment mode: `production`, `development`, or `test`
-- `HELLOTEXT_API_URL` - Override API URL (development only)
+| Page | Section |
+| ------------------------------------------------- | -------------------- |
+| Front page (also when the shop is the front page) | `homepage` |
+| Shop, product categories, tags and attributes | `product_collection` |
+| Product | `product_details` |
+| Anything else (cart, checkout, account, blog) | none |
-**Example (.htaccess):**
+The Smart Alert subscribes visitors to Push, which needs a service worker on the store's origin. The plugin ships `assets/hellotext-service-worker.js`, the worker from the Hellotext.js Push guide, and passes its path as `push.serviceWorkerUrl`. It registers under the plugin's folder, so it never replaces a worker the store already runs at the site root.
-```apache
-SetEnv APP_ENV development
-SetEnv HELLOTEXT_API_URL https://api-dev.hellotext.com
+## Testing
+
+For testing information, see [DEVELOPMENT.md](DEVELOPMENT.md).
+
+## Configuration Constants
+
+Define `HELLOTEXT_API_URL` in `wp-config.php` to point the plugin at another Hellotext environment:
+
+```php
+define('HELLOTEXT_API_URL', 'https://api-dev.hellotext.com');
```
## Error Handling
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index f944d0d..c2d49f0 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -53,27 +53,13 @@ This installs:
### 3. Configure Environment
-Create or modify your WordPress configuration to set environment variables.
-
-**Option A: wp-config.php**
+The plugin talks to `https://api.hellotext.com` by default. To use another Hellotext environment, define `HELLOTEXT_API_URL` in `wp-config.php`:
```php
// Add before "That's all, stop editing!"
-$_ENV['APP_ENV'] = 'development';
-$_ENV['HELLOTEXT_API_URL'] = 'https://api-dev.hellotext.com';
-```
-
-**Option B: .htaccess**
-
-```apache
-SetEnv APP_ENV development
-SetEnv HELLOTEXT_API_URL https://api-dev.hellotext.com
+define('HELLOTEXT_API_URL', 'https://api-dev.hellotext.com');
```
-**Option C: Server Configuration**
-
-For Local by Flywheel, add to site configuration or use `.env` file if supported.
-
### 4. Activate Plugin
1. Navigate to WordPress admin → Plugins
@@ -352,9 +338,12 @@ add_action('all', function($hook) {
- [ ] Inspect the production `vendor/` tree enough to confirm dev-only packages such as Pest, Mockery, stubs, PHPUnit, and PHP CS Fixer are not included in the release build.
- [ ] Verify `.distignore` excludes development files from the release zip: `.github/`, `tests/`, `composer.json`, `composer.lock`, `DEVELOPMENT.md`, and `API.md`.
- [ ] Smoke test the plugin zip in a clean WordPress/WooCommerce site.
-- [ ] Confirm settings save correctly: Business ID, access token, webchat ID, placement, and behavior.
-- [ ] Confirm script/webchat injection renders on the storefront when configured.
-- [ ] Smoke test tracking for product view, cart add/remove, coupon redemption, checkout/order placement, order status change, refund, user registration, plugin activation, and plugin deactivation.
+- [ ] Confirm settings save correctly (Business ID and access token) and the store connects once, with a single Hellotext WooCommerce API key.
+- [ ] Confirm the storefront loads `https://cdn.jsdelivr.net/npm/@hellotext/hellotext/dist/hellotext.min.js` and the Webchat configured in Hellotext appears.
+- [ ] Confirm `Hellotext.page.section` is `homepage`, `product_collection` and `product_details` on the front, shop/category and product pages, and empty on the cart.
+- [ ] With the Smart Alert playbook enabled, confirm its sections show on those pages and that "Allow" subscribes through `assets/hellotext-service-worker.js`.
+- [ ] From a Webchat product card, confirm "Add to cart" adds the product, refreshes the mini cart on a classic and a block theme, opens the page of a variable product, and that `cart.added` reaches Hellotext with the message as `source`.
+- [ ] Smoke test tracking for page view, product view, cart add/remove, cart view, checkout started (once per cart), coupon redemption, checkout/order placement, order status change, refund, user registration, plugin activation, and plugin deactivation.
- [ ] Smoke test classic template pages and WooCommerce Cart/Checkout blocks separately for product, cart, and checkout coverage.
- [ ] Smoke test order placement, order status, and refund flows with WooCommerce HPOS enabled and disabled.
- [ ] Record WooCommerce API/HPOS compatibility notes in the release description if anything changed.
@@ -525,7 +514,7 @@ composer require --dev php-stubs/wordpress-stubs php-stubs/woocommerce-stubs
**Issue**: `hello_session` cookie not being created
-**Solution**: Check that `session_start()` is called early in plugin load and cookie settings (domain, path) are correct.
+**Solution**: Hellotext.js sets the cookie in the browser. Check that the storefront loads Hellotext.js (Business ID saved, no script blocker or Content Security Policy rejecting `cdn.jsdelivr.net`).
### API Requests Failing
diff --git a/README.md b/README.md
index 9e83b36..abe26b9 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,8 @@ Click on _Save Changes_ to save the changes.
Done! Your WooCommerce store is now connected to Hellotext.
+The plugin loads Hellotext.js on your store, so the Webchat, popups, Smart Alert and other tools you set up in Hellotext appear without further changes in WordPress. It tells Hellotext whether each page is your homepage, a product collection or a product, so the Smart Alert shows the section you set up for it.
+
## Next Steps
From now on, you will start seeing your customers' activity reflected in the Audience section or from the Inbox.
@@ -103,10 +105,13 @@ With this information, you can create segments for sending campaigns and automat
The plugin automatically tracks the following customer activities:
+- **Page Viewed** - When a customer views any other page of the store
- **Product Viewed** - When a customer views a product page
-- **Cart Added/Removed** - Items added to or removed from cart
+- **Cart Added/Removed** - Items added to or removed from cart, including from the Webchat's product cards
+- **Cart Viewed** - When a customer views their cart
+- **Checkout Started** - When a customer opens the checkout with their cart
- **Coupon Redeemed** - When a customer applies a coupon code
-- **Order Placed** - When an order is successfully created
+- **Order Placed** - When an order is paid, or put on hold for offline payment
- **Order Status Updates** - Order confirmation, cancellation, delivery
- **Refund Received** - When orders are refunded
- **User Registration** - When new customers register
diff --git a/assets/hellotext-service-worker.js b/assets/hellotext-service-worker.js
new file mode 100644
index 0000000..3b52a9e
--- /dev/null
+++ b/assets/hellotext-service-worker.js
@@ -0,0 +1,59 @@
+self.addEventListener('install', event => {
+ event.waitUntil(self.skipWaiting())
+})
+
+self.addEventListener('push', event => {
+ if (!event.data) return
+
+ let payload
+
+ try {
+ payload = event.data.json()
+ } catch (error) {
+ return
+ }
+
+ if (
+ !payload ||
+ payload.source !== 'hellotext' ||
+ typeof payload.title !== 'string' ||
+ !payload.title
+ ) {
+ return
+ }
+
+ const options = payload.options || {}
+
+ event.waitUntil(
+ self.registration.showNotification(payload.title, {
+ ...options,
+ data: { ...options.data, source: 'hellotext' },
+ }),
+ )
+})
+
+self.addEventListener('notificationclick', event => {
+ const { data } = event.notification
+
+ if (!data || data.source !== 'hellotext') return
+
+ event.notification.close()
+
+ let url
+
+ try {
+ url = new URL(data.actions?.[event.action] || data.url || '/', self.location.origin)
+ } catch (error) {
+ return
+ }
+
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return
+
+ event.waitUntil(
+ self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => {
+ const client = clients.find(windowClient => windowClient.url === url.href)
+
+ return client ? client.focus() : self.clients.openWindow(url.href)
+ }),
+ )
+})
diff --git a/assets/storefront.js b/assets/storefront.js
new file mode 100644
index 0000000..30e9102
--- /dev/null
+++ b/assets/storefront.js
@@ -0,0 +1,97 @@
+/**
+ * Starts Hellotext.js on the storefront with the settings the plugin prints
+ * in window.hellotextWoo.
+ */
+;(function () {
+ var config = window.hellotextWoo
+ var Hellotext = window.Hellotext
+
+ if (!config || !Hellotext) return
+
+ // The Webchat's product cards only announce the product. Adding it to the
+ // WooCommerce cart is the store's part.
+ if (typeof Hellotext.on === 'function') {
+ Hellotext.on('cart.added', function (event) {
+ addToCart(event).catch(function (error) {
+ console.warn('Hellotext could not add the product to the cart:', error)
+ })
+ })
+ }
+
+ Hellotext.initialize(config.businessId, config.options)
+ .then(function () {
+ // Initializing builds a new page, so its section is set afterwards, and
+ // before page.viewed so the event reports it.
+ if (Hellotext.page) Hellotext.page.section = config.section
+
+ // Hellotext.js does not track page views itself. Product and checkout
+ // pages report their own events instead.
+ if (config.trackPageView) Hellotext.track('page.viewed')
+
+ if (config.section && Hellotext.alert) Hellotext.alert.show(config.section)
+ })
+ .catch(function (error) {
+ console.warn('Hellotext initialization failed:', error)
+ })
+
+ async function addToCart(event) {
+ var items = (event && event.object_parameters && event.object_parameters.items) || []
+ var added = false
+
+ for (var item of items) {
+ // Cards from another store's catalog carry that store's references.
+ if (!item.reference || (item.source && item.source !== 'woo')) continue
+
+ var response = await fetch(config.addToCartUrl, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ product_id: item.reference,
+ quantity: item.quantity || 1,
+ // The plugin reports the message that led to the addition.
+ hellotext_source: JSON.stringify((event && event.source) || {}),
+ }),
+ })
+ var result = await response.json()
+
+ if (!result || result.error) {
+ // Like WooCommerce's own buttons: products that need options picked,
+ // or are out of stock, open their page.
+ if (result) navigate(result.product_url)
+ return
+ }
+
+ added = true
+ refreshCart(result)
+ }
+
+ if (added && config.redirectToCart) navigate(config.cartUrl)
+ }
+
+ // Opens a page of this store only: an http(s) URL on the current origin.
+ function navigate(value) {
+ var url
+
+ try {
+ url = new URL(value, window.location.href)
+ } catch (error) {
+ return
+ }
+
+ if ((url.protocol === 'https:' || url.protocol === 'http:') && url.origin === window.location.origin) {
+ window.location.assign(url.href)
+ }
+ }
+
+ // Classic themes refresh the mini cart from added_to_cart. The Mini-Cart
+ // block listens to it too when jQuery is on the page, and to its own event
+ // otherwise.
+ function refreshCart(result) {
+ if (window.jQuery) {
+ window.jQuery(document.body).trigger('added_to_cart', [result.fragments, result.cart_hash])
+ } else {
+ document.body.dispatchEvent(new CustomEvent('wc-blocks_added_to_cart'))
+ }
+ }
+})()
diff --git a/changelog.txt b/changelog.txt
index 199d49e..b536202 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,5 +1,25 @@
** Hellotext for WooCommerce Changelog **
+Unreleased
+
+* Remove the Webchat settings. The Webchat, popups and other widgets are configured in Hellotext.
+* Load the latest Hellotext.js from jsDelivr on the storefront only, no longer in wp-admin.
+* Report the page section (homepage, product collection, product) on every event, Webchat messages included, and show the Smart Alert section configured for it.
+* Ship a Hellotext Push service worker, so visitors can subscribe from the Smart Alert.
+* Add the Webchat's product cards to the WooCommerce cart, crediting the addition to their message.
+* Track page.viewed, cart.viewed, and checkout.started once per cart.
+* Track order.placed for Checkout block, block themes and customers who never return from the payment page, once the order is paid or on hold.
+* Never attribute order, refund or new user events to the shop manager's browser session.
+* Create and link customer profiles on product views, cart changes and checkout, not only on registration.
+* Report cart totals after WooCommerce recalculates them, the quantity removed in cart.removed, and stop reporting purchased items as removed.
+* Stop starting a PHP session on every request; the cart snapshot lives in the WooCommerce session.
+* Reconnect after deactivation or a credential change, revoke the WooCommerce API key on deactivation, and move the integration when the Business ID changes.
+* Keep the working API key until Hellotext accepts a new one. A failed connection shows on the settings page and is retried when the settings are saved again.
+* Send the WooCommerce version when connecting, and open Hellotext checkout links (?hellotext_checkout) on stores older than WooCommerce 10.0.
+* Open only pages of the store from the Webchat's add to cart, and retry linking a customer's session when Hellotext did not link it.
+* Fix profile and session requests losing the /v1 API prefix, coupons with errors being tracked as redeemed, and orders with deleted products failing to track.
+* Point the plugin at another Hellotext environment with the HELLOTEXT_API_URL constant instead of APP_ENV.
+
2026-01-19 - version 1.3.1
* Add X-Plugin-Version header to all API requests for version tracking and analytics.
diff --git a/docs/WOOCOMMERCE-AUDIT.md b/docs/WOOCOMMERCE-AUDIT.md
index 3f23d54..77d5d09 100644
--- a/docs/WOOCOMMERCE-AUDIT.md
+++ b/docs/WOOCOMMERCE-AUDIT.md
@@ -28,17 +28,22 @@ Do not declare HPOS compatibility yet. Add the declaration only after a manual r
| Cart updates | `woocommerce_add_to_cart` | `hellotext_trigger_cart_updated()` | Appropriate | Mutation hook is still a valid WooCommerce cart hook. Tests cover event creation with mocks. |
| Cart removals | `woocommerce_cart_item_removed` | `hellotext_trigger_cart_updated()` | Appropriate | Tests cover `cart.removed` payloads. |
| Cart quantity changes | `woocommerce_after_cart_item_quantity_update` | `hellotext_trigger_cart_updated()` | Appropriate | Tests cover quantity increases and no-op unchanged carts. |
-| Cart page diff check | `woocommerce_after_cart` | `hellotext_trigger_cart_updated()` | Works for classic cart template | This hook does not cover all Cart block render paths. Future block-specific coverage should be evaluated. |
+| Cart page diff check | `woocommerce_after_cart` | `hellotext_trigger_cart_updated()` | Works for classic cart template | This hook does not cover all Cart block render paths. Cart block changes still go through the cart mutation hooks above. |
+| Cart comparison | `shutdown` | `hellotext_cart_updated()` | Appropriate | The cart hooks schedule one comparison at the end of the request, after WooCommerce recalculates totals. The snapshot lives in `WC()->session`, not a PHP session. `cart.added` carries the product's whole quantity, which Hellotext sets the cart line to. |
+| Cart and checkout pages | `template_redirect` | `hellotext_cart_viewed()`, `hellotext_checkout_started()` | Appropriate | `is_cart()` and `is_checkout()` cover classic and block pages. `checkout.started` is sent once per cart hash and skips the thank-you and payment endpoints. |
+| Webchat add to cart | `?wc-ajax=add_to_cart` | `assets/storefront.js` | Appropriate | The endpoint WooCommerce's own buttons use: it handles variations and returns the fragments `added_to_cart` refreshes. |
| Coupon redemption | `woocommerce_applied_coupon` | `hellotext_coupon_redeemed()` | Appropriate | Handler validates the coupon before sending `coupon.redeemed`; tests cover valid and invalid coupons. |
-| Order placement | `woocommerce_after_order_details` | `hellotext_order_placed()` | Render-based hook with duplicate risk | This hook can fire on thank-you/order details and My Account view-order screens. Add or keep an idempotency guard before relying on it, and evaluate `woocommerce_thankyou` for classic checkout plus `woocommerce_store_api_checkout_order_processed` for Checkout block support. |
-| Order status changes | `woocommerce_order_status_changed` | `track_order_status()` | Appropriate | Handler maps `processing`, `cancelled`, and `completed` to Hellotext events. Uses WooCommerce order object/meta access. |
+| Checkout capture | `woocommerce_checkout_order_processed`, `woocommerce_store_api_checkout_order_processed` | `hellotext_capture_checkout()` | Appropriate | Classic and block checkout store the encrypted session and a `pending` placement flag on the order, in the customer's request. |
+| Order placement | `woocommerce_thankyou` and `woocommerce_order_status_changed` | `hellotext_order_placed()` | Appropriate | Sent once per order, when it is paid or on hold. The status change covers block themes, whose order confirmation page fires no classic hooks, and customers who never return from the payment page. |
+| Order status changes | `woocommerce_order_status_changed` | `hellotext_order_status_changed()` | Appropriate | Handler maps `processing`, `cancelled`, and `completed` to Hellotext events with the session stored on the order, never the shop manager's cookie. |
| Refunds | `woocommerce_order_refunded` | `hellotext_refund_created()` | Appropriate | Handler loads orders/refunds with WooCommerce APIs and reads session meta with `$order->get_meta()`. |
| User registration | `user_register` | `hellotext_user_registered()` | Appropriate WordPress hook | Tests cover profile creation flow with mocked HTTP. |
-| Profile creation | `hellotext_create_profile` | Closure in `CreateProfile.php` | Internal plugin hook | Used by event flows to associate logged-in users/sessions. |
-| Plugin deactivation | `register_deactivation_hook()` and `hellotext_remove_integration` | `hellotext_deactivate()` and closure in `AppRemoved.php` | Appropriate WordPress APIs | Tests cover DELETE request shape for integration cleanup. |
-| Plugin uninstall | `register_uninstall_hook()` | `uninstall()` | Appropriate WordPress API | Cleans Hellotext options and WooCommerce API keys. |
-| Settings | WordPress Settings API | `src/Misc/Settings.php` | Appropriate | Stores Business ID, access token, and webchat options using WordPress options. |
-| Webchat injection | WordPress script/footer hooks | `src/Misc/Scripts.php` | Appropriate WordPress APIs | Verify rendered script manually in a real WordPress page during release smoke testing. |
+| Profile creation | `hellotext_create_profile` | Closure in `src/Events/CustomerProfile.php` | Internal plugin hook | Used by event flows to associate logged-in users/sessions. |
+| Plugin activation | `register_activation_hook()` | `hellotext_activate()` | Appropriate WordPress API | Connects the store when the settings are already saved. |
+| Plugin deactivation | `register_deactivation_hook()` | `hellotext_deactivate()` | Appropriate WordPress API | Removes the integration from Hellotext and revokes its WooCommerce API key. |
+| Plugin uninstall | `register_uninstall_hook()` | `hellotext_uninstall()` | Appropriate WordPress API | Cleans Hellotext options, including the webchat options of earlier versions, and WooCommerce API keys. |
+| Settings | WordPress Settings API | `src/Misc/Settings.php` | Appropriate | Stores the Business ID and access token. Webchat and other widgets are configured in Hellotext. |
+| Hellotext.js | `wp_enqueue_scripts` | `hellotext_enqueue_script()` | Appropriate WordPress APIs | Loads the unversioned jsDelivr build on the storefront only, with the bundled Push service worker and `assets/storefront.js`, and skips the storefront while WooCommerce is inactive. Tracks `page.viewed` and sets `Hellotext.page.section` from `is_front_page()`, `is_shop()`, `is_product_taxonomy()` and `is_product()`, and shows the Smart Alert for it. Conditional tags also work on block themes. Verify rendered script manually in a real WordPress page during release smoke testing. |
| WooCommerce API keys | Direct `$wpdb` against `{$prefix}woocommerce_api_keys` | `src/Events/AppInstalled.php`, `hellotext.php` uninstall | Acceptable exception | WooCommerce API keys are not order data and are not part of HPOS order storage. No WooCommerce CRUD replacement is required for this table access. |
## HPOS Assessment
@@ -48,9 +53,8 @@ WooCommerce HPOS changes where order data is stored. The HPOS recipe book recomm
The plugin's order session metadata paths are aligned with that guidance:
- `src/Adapters/OrderAdapter.php` loads numeric orders with `wc_get_order()`.
-- `src/Events/OrderPlaced.php` stores the Hellotext session with `$order->update_meta_data(Constants::META_SESSION, ...)` and `$order->save()`.
-- `src/Events/OrderStatus.php` reads the stored session with `$order->get_meta(Constants::META_SESSION, true)`.
-- `src/Events/RefundReceived.php` loads the order with `wc_get_order($order_id)` and reads session metadata with `$order->get_meta(Constants::META_SESSION, true)`.
+- `src/Events/OrderPlaced.php` stores the Hellotext session and placement flag with `$order->update_meta_data(...)` and `$order->save_meta_data()`.
+- `src/Events/OrderStatus.php` and `src/Events/RefundReceived.php` read the stored session through `Session::for_order()`, which uses `$order->get_meta(Constants::META_SESSION, true)`.
- The plugin does not query `shop_order` posts or use direct post meta functions for order session metadata.
The test suite uses WordPress/WooCommerce mocks, so it validates code behavior and request shape but not a real HPOS datastore. Because of that, the formal declaration is deferred.
@@ -90,8 +94,8 @@ Do not add this declaration until manual runtime testing confirms product view,
## Recommendations
1. Keep the HPOS declaration deferred until manual smoke testing passes with HPOS on and off.
-2. Add or keep an idempotency guard around `order.placed` while `woocommerce_after_order_details` remains the hook.
-3. Evaluate replacing `woocommerce_after_order_details` with a checkout-completion hook such as `woocommerce_thankyou` for classic checkout, while separately evaluating Checkout block coverage through Store API hooks.
+2. Done: `order.placed` is guarded by the `hellotext_order_placed` order flag.
+3. Done: `woocommerce_after_order_details` was replaced with the classic and Store API checkout hooks, `woocommerce_thankyou`, and the status change fallback.
4. Document classic-template limitations for `woocommerce_after_single_product`, `woocommerce_after_cart`, and any block-theme gaps in release notes until runtime coverage is confirmed.
-5. Consider migrating cart diff state from `$_SESSION` to WooCommerce session storage (`WC()->session`) in a future compatibility-focused PR.
+5. Done: the cart snapshot moved from `$_SESSION` to `WC()->session`, and the plugin no longer starts a PHP session.
6. Continue using WooCommerce CRUD methods for all future order/refund metadata reads and writes.
diff --git a/hellotext.php b/hellotext.php
index 642383a..e80f587 100644
--- a/hellotext.php
+++ b/hellotext.php
@@ -19,22 +19,6 @@
use Hellotext\Constants;
-// TODO: Refactor this to use the APP_ENV variable
-if (! isset($_ENV['APP_ENV'])) {
- $_ENV['APP_ENV'] = 'production';
-}
-
-$TEST = $_ENV['APP_ENV'] === 'test';
-$HELLOTEXT_DEV_MODE = $_ENV['APP_ENV'] === 'development';
-$HELLOTEXT_API_URL = $HELLOTEXT_DEV_MODE
- ? $_ENV['HELLOTEXT_API_URL'] ?? ''
- : 'https://api.hellotext.com';
-
-
-if (session_status() === PHP_SESSION_NONE) {
- session_start();
-}
-
// Load Composer autoloader (handles all classes)
require_once __DIR__ . '/vendor/autoload.php';
@@ -50,8 +34,9 @@
require_once $file;
}
-// Function on Events/AppRemoved.php
-register_deactivation_hook( __FILE__, 'hellotext_deactivate' );
+// Functions on Events/AppInstalled.php and Events/AppRemoved.php
+register_activation_hook(__FILE__, 'hellotext_activate');
+register_deactivation_hook(__FILE__, 'hellotext_deactivate');
/**
* Check for plugin updates from GitHub.
@@ -60,7 +45,7 @@
* @return void
*/
function hellotext_version_check() {
- if (!is_admin()) {
+ if (!is_admin() || !current_user_can('update_plugins')) {
return;
}
@@ -91,8 +76,8 @@ function hellotext_version_check() {
if (version_compare($current, $latest, '<')) {
$cache_data['message'] = sprintf(
'New version of Hellotext available: %s. View details',
- $latest,
- $release['html_url']
+ esc_html($latest),
+ esc_url($release['html_url'])
);
}
}
@@ -121,19 +106,15 @@ function hellotext_load_textdomain() {
*
* @return void
*/
-function uninstall() {
- global $wpdb;
-
- delete_option(Constants::OPTION_BUSINESS_ID);
- delete_option(Constants::OPTION_WEBCHAT_ID);
- delete_option(Constants::OPTION_WEBCHAT_PLACEMENT);
- delete_option(Constants::OPTION_WEBCHAT_BEHAVIOUR);
- delete_option(Constants::OPTION_ACCESS_TOKEN);
-
- $api_keys_table = $wpdb->prefix . 'woocommerce_api_keys';
- if ($wpdb->get_var("SHOW TABLES LIKE '$api_keys_table'") === $api_keys_table) {
- $wpdb->delete($api_keys_table, ['description' => 'Hellotext']);
+function hellotext_uninstall() {
+ foreach (Constants::OPTIONS as $option) {
+ delete_option($option);
}
+
+ delete_transient('hellotext_version_check');
+ delete_transient('hellotext_integration_triggered');
+
+ hellotext_delete_api_keys();
}
-register_uninstall_hook(__FILE__, 'uninstall');
+register_uninstall_hook(__FILE__, 'hellotext_uninstall');
diff --git a/languages/hellotext-en_US.mo b/languages/hellotext-en_US.mo
index 9f8dcd2..a26bf5b 100644
Binary files a/languages/hellotext-en_US.mo and b/languages/hellotext-en_US.mo differ
diff --git a/languages/hellotext-en_US.po b/languages/hellotext-en_US.po
index 18d0a54..00f794f 100644
--- a/languages/hellotext-en_US.po
+++ b/languages/hellotext-en_US.po
@@ -44,33 +44,12 @@ msgstr "Business ID"
msgid "settings.access_token"
msgstr "Access Token"
-msgid "settings.webchat_id"
-msgstr "Webchat ID"
-
-msgid "webchat_unavailable"
-msgstr "No active Webchat found."
-
-msgid "settings.webchat_placement"
-msgstr "Webchat Placement"
-
-msgid "settings.webchat_placement_bottom-right"
-msgstr "Bottom Right"
-
-msgid "settings.webchat_placement_bottom-left"
-msgstr "Bottom Left"
-
-msgid "settings.webchat_placement_top-right"
-msgstr "Top Right"
-
-msgid "settings.webchat_placement_top-left"
-msgstr "Top Left"
-
-msgid "settings.webchat_behaviour"
-msgstr "Webchat Behaviour"
-
msgid "settings.submit"
msgstr "Save changes"
+msgid "settings.connection_failed"
+msgstr "Hellotext could not connect your store. Check the Business ID and the access token, then save the changes to try again."
+
msgid "description.paragraphs.one"
msgstr "You can find your Business ID on the Hellotext business settings."
diff --git a/languages/hellotext-es_ES.mo b/languages/hellotext-es_ES.mo
index bf3ecf8..de876b8 100644
Binary files a/languages/hellotext-es_ES.mo and b/languages/hellotext-es_ES.mo differ
diff --git a/languages/hellotext-es_ES.po b/languages/hellotext-es_ES.po
index 105e669..8a4fb8e 100644
--- a/languages/hellotext-es_ES.po
+++ b/languages/hellotext-es_ES.po
@@ -44,33 +44,12 @@ msgstr "ID del negiocio"
msgid "settings.access_token"
msgstr "Token de acceso"
-msgid "settings.webchat_id"
-msgstr "ID del Webchat"
-
-msgid "webchat_unavailable"
-msgstr "No se encontró ningún Webchat activo."
-
-msgid "settings.webchat_placement"
-msgstr "Ubicación del Webchat"
-
-msgid "settings.webchat_placement_bottom-right"
-msgstr "Abajo a la derecha"
-
-msgid "settings.webchat_placement_bottom-left"
-msgstr "Abajo a la izquierda"
-
-msgid "settings.webchat_placement_top-right"
-msgstr "Arriba a la derecha"
-
-msgid "settings.webchat_placement_top-left"
-msgstr "Arriba a la izquierda"
-
-msgid "settings.webchat_behaviour"
-msgstr "Comportamiento del Webchat"
-
msgid "settings.submit"
msgstr "Guardar cambios"
+msgid "settings.connection_failed"
+msgstr "Hellotext no pudo conectar tu tienda. Revisa el ID de negocio y el token de acceso, y guarda los cambios para intentarlo de nuevo."
+
msgid "description.paragraphs.one"
msgstr "Puedes encontrar tu ID de negocio en la configuración de negocios de Hellotext."
diff --git a/languages/hellotext.pot b/languages/hellotext.pot
index d9c5c5a..a7c2308 100644
--- a/languages/hellotext.pot
+++ b/languages/hellotext.pot
@@ -44,31 +44,10 @@ msgstr ""
msgid "settings.access_token"
msgstr ""
-msgid "settings.webchat_id"
-msgstr ""
-
-msgid "webchat_unavailable"
-msgstr ""
-
-msgid "settings.webchat_placement"
-msgstr ""
-
-msgid "settings.webchat_placement_bottom-right"
-msgstr ""
-
-msgid "settings.webchat_placement_bottom-left"
-msgstr ""
-
-msgid "settings.webchat_placement_top-right"
-msgstr ""
-
-msgid "settings.webchat_placement_top-left"
-msgstr ""
-
msgid "settings.submit"
msgstr ""
-msgid "settings.webchat_behaviour"
+msgid "settings.connection_failed"
msgstr ""
msgid "description.paragraphs.one"
diff --git a/src/Adapters/OrderAdapter.php b/src/Adapters/OrderAdapter.php
index 072daa9..8a1fad0 100644
--- a/src/Adapters/OrderAdapter.php
+++ b/src/Adapters/OrderAdapter.php
@@ -61,18 +61,32 @@ public function get(): array {
* @return array
*/
public function adapted_products(): array {
- $items = $this->order->get_items();
+ $products = $this->products;
- foreach ($items as $item) {
+ foreach ($this->order->get_items() as $item) {
$product = $item->get_product();
- $this->products[] = [
- 'product' => (new ProductAdapter($product))->get(),
+ $products[] = [
+ 'product' => $product ? (new ProductAdapter($product))->get() : $this->deleted_product($item),
'quantity' => $item->get_quantity(),
];
}
- return $this->products;
+ return $products;
+ }
+
+ /**
+ * Describe an order item whose product was deleted from the store.
+ *
+ * @param \WC_Order_Item_Product $item Order item.
+ * @return array
+ */
+ private function deleted_product(\WC_Order_Item_Product $item): array {
+ return [
+ 'reference' => $item->get_variation_id() ?: $item->get_product_id(),
+ 'source' => 'woo',
+ 'name' => $item->get_name(),
+ ];
}
}
diff --git a/src/Adapters/ProductAdapter.php b/src/Adapters/ProductAdapter.php
index 0a6b06b..6bcbf4f 100644
--- a/src/Adapters/ProductAdapter.php
+++ b/src/Adapters/ProductAdapter.php
@@ -37,15 +37,18 @@ public function get(): array {
throw new \Exception('Product not found');
}
+ // Variations carry no categories or tags of their own.
+ $catalog_id = $this->product->get_parent_id() ?: $this->product->get_id();
+
$response = [
'reference' => $this->product->get_id(),
'source' => 'woo',
'name' => $this->product->get_name(),
- 'categories' => wp_get_post_terms($this->product->get_id(), 'product_cat', [ 'fields' => 'names' ]),
+ 'categories' => $this->term_names($catalog_id, 'product_cat'),
'price' => (new PriceAdapter($this->product->get_price()))->get(),
- 'tags' => wp_get_post_terms($this->product->get_id(), 'product_tag', [ 'fields' => 'names' ]),
+ 'tags' => $this->term_names($catalog_id, 'product_tag'),
'image_url' => wp_get_attachment_url($this->product->get_image_id()),
- 'url' => get_permalink($this->product->get_id()),
+ 'url' => $this->product->get_permalink(),
];
$response = array_filter($response, function ($value) {
@@ -55,4 +58,16 @@ public function get(): array {
return $response;
}
+ /**
+ * Term names of a product, or an empty list when the taxonomy lookup fails.
+ *
+ * @param int $product_id Product ID.
+ * @param string $taxonomy Taxonomy name.
+ * @return array
+ */
+ private function term_names(int $product_id, string $taxonomy): array {
+ $terms = wp_get_post_terms($product_id, $taxonomy, ['fields' => 'names']);
+
+ return is_array($terms) ? $terms : [];
+ }
}
diff --git a/src/Api/Client.php b/src/Api/Client.php
index 883b8f0..d73005b 100644
--- a/src/Api/Client.php
+++ b/src/Api/Client.php
@@ -12,29 +12,6 @@
* @package Hellotext\Api
*/
class Client {
- /**
- * API URL suffix.
- *
- * @var string
- */
- public static string $sufix = '/' . Constants::API_VERSION;
-
- /**
- * Set a custom API suffix and return a client instance.
- *
- * @param string $sufix Suffix to append to API URL.
- * @return self
- */
- public static function with_sufix(string $sufix = ''): self {
- if (0 < strlen($sufix) && '/' !== $sufix[0]) {
- $sufix = '/' . $sufix;
- }
-
- self::$sufix = $sufix;
-
- return new self();
- }
-
/**
* Make an HTTP request to the Hellotext API.
*
@@ -44,7 +21,7 @@ public static function with_sufix(string $sufix = ''): self {
* @return array
*/
public static function request(string $method = 'GET', string $path = '/', array $data = []): array {
- $request_url = self::get_api_url() . $path;
+ $request_url = self::api_url() . $path;
$access_token = get_option(Constants::OPTION_ACCESS_TOKEN);
$args = [
@@ -171,12 +148,13 @@ public static function delete(string $path = '/', ?array $data = null): array {
/**
* Get base API URL.
*
+ * Define HELLOTEXT_API_URL in wp-config.php to point the plugin at another
+ * Hellotext environment.
+ *
* @return string
*/
- private static function get_api_url(): string {
- global $HELLOTEXT_API_URL;
-
- return $HELLOTEXT_API_URL . self::$sufix;
+ public static function api_url(): string {
+ return defined('HELLOTEXT_API_URL') ? rtrim(HELLOTEXT_API_URL, '/') : Constants::API_URL;
}
/**
diff --git a/src/Api/Event.php b/src/Api/Event.php
index 1e33b3a..578b478 100644
--- a/src/Api/Event.php
+++ b/src/Api/Event.php
@@ -29,10 +29,14 @@ class Event {
/**
* Create a new event tracker.
*
- * @param string|null $session Optional session identifier.
+ * Storefront events pass Session::current(); order events pass the session
+ * stored on the order, since they also run from wp-admin and payment
+ * callbacks where the browser cookie belongs to someone else.
+ *
+ * @param string|null $session Session identifier.
*/
public function __construct(?string $session = null) {
- $this->hellotext_business_id = get_option(Constants::OPTION_BUSINESS_ID);
+ $this->hellotext_business_id = (string) get_option(Constants::OPTION_BUSINESS_ID);
$this->session = $session;
}
@@ -44,16 +48,20 @@ public function __construct(?string $session = null) {
* @return void
*/
public function track(string $action, array $payload): void {
+ if (!$this->hellotext_business_id) {
+ return;
+ }
+
$body = array_merge(
[
'action' => $action,
- 'session' => $this->session ?? $this->browser_session(),
+ 'session' => $this->session,
],
$payload
);
$response = wp_remote_post(
- $this->get_api_url(),
+ Client::api_url() . Constants::API_ENDPOINT_TRACK,
[
'timeout' => 10,
'blocking' => false,
@@ -61,7 +69,7 @@ public function track(string $action, array $payload): void {
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $this->hellotext_business_id,
],
- 'body' => json_encode($body),
+ 'body' => wp_json_encode($body),
'sslverify' => true,
]
);
@@ -75,28 +83,4 @@ public function track(string $action, array $payload): void {
));
}
}
-
- /**
- * Get the event tracking API URL.
- *
- * @return string
- */
- private function get_api_url(): string {
- global $HELLOTEXT_API_URL;
-
- return $HELLOTEXT_API_URL . Constants::API_ENDPOINT_TRACK;
- }
-
- /**
- * Retrieve session from browser cookie.
- *
- * @return string|null
- */
- private function browser_session(): ?string {
- if (isset($_COOKIE[Constants::SESSION_COOKIE_NAME])) {
- return sanitize_text_field($_COOKIE[Constants::SESSION_COOKIE_NAME]);
- }
-
- return null;
- }
}
diff --git a/src/Api/Webchat.php b/src/Api/Webchat.php
deleted file mode 100644
index a07782e..0000000
--- a/src/Api/Webchat.php
+++ /dev/null
@@ -1,30 +0,0 @@
-get(Constants::API_ENDPOINT_WEBCHATS);
- return is_array($body['body']) && isset($body['body']['ids']) ? $body['body']['ids'] : [];
- }
-}
diff --git a/src/Constants.php b/src/Constants.php
index 9c79959..4c0b6a2 100644
--- a/src/Constants.php
+++ b/src/Constants.php
@@ -11,13 +11,18 @@
*/
class Constants {
// API Configuration
- public const API_VERSION = 'v1';
+ public const API_URL = 'https://api.hellotext.com';
public const API_ENDPOINT_TRACK = '/v1/track/events';
- public const API_ENDPOINT_PROFILES = '/profiles';
- public const API_ENDPOINT_SESSIONS = '/sessions';
- public const API_ENDPOINT_WEBCHATS = '/v1/wordpress/webchats';
+ public const API_ENDPOINT_PROFILES = '/v1/profiles';
+ public const API_ENDPOINT_SESSIONS = '/v1/sessions';
public const API_ENDPOINT_INTEGRATIONS_WOO = '/integrations/woo';
+ // Query parameter of the checkout links Hellotext sends to stores older than WooCommerce 10.0
+ public const CHECKOUT_LINK_PARAM = 'hellotext_checkout';
+
+ // Hellotext.js, unversioned so stores always load the latest release
+ public const SDK_URL = 'https://cdn.jsdelivr.net/npm/@hellotext/hellotext/dist/hellotext.min.js';
+
// Session & Encryption
public const ENCRYPTION_METHOD = 'aes-256-cbc';
public const SESSION_COOKIE_NAME = 'hello_session';
@@ -25,16 +30,36 @@ class Constants {
// WordPress Options
public const OPTION_BUSINESS_ID = 'hellotext_business_id';
public const OPTION_ACCESS_TOKEN = 'hellotext_access_token';
- public const OPTION_WEBCHAT_ID = 'hellotext_webchat_id';
- public const OPTION_WEBCHAT_PLACEMENT = 'hellotext_webchat_placement';
- public const OPTION_WEBCHAT_BEHAVIOUR = 'hellotext_webchat_behaviour';
+ public const OPTION_CONNECTION_FAILED = 'hellotext_connection_failed';
+
+ // Every option the plugin has stored, including the webchat options of
+ // earlier versions, removed on uninstall.
+ public const OPTIONS = [
+ self::OPTION_BUSINESS_ID,
+ self::OPTION_ACCESS_TOKEN,
+ self::OPTION_CONNECTION_FAILED,
+ 'hellotext_webchat_id',
+ 'hellotext_webchat_placement',
+ 'hellotext_webchat_behaviour',
+ ];
+
+ // WooCommerce API key
+ public const API_KEY_DESCRIPTION = 'Hellotext';
+ // A new key until Hellotext accepts it.
+ public const API_KEY_PENDING_DESCRIPTION = 'Hellotext (connecting)';
// User Meta Keys
public const META_PROFILE_ID = 'hellotext_profile_id';
public const META_SESSION = 'hellotext_session';
- // Session Keys
+ // Order Meta Keys
+ public const META_ORDER_PLACED = 'hellotext_order_placed';
+ public const ORDER_PLACED_PENDING = 'pending';
+ public const ORDER_PLACED_TRACKED = 'tracked';
+
+ // WooCommerce Session Keys
public const SESSION_CART_ITEMS = 'hellotext_cart_items';
+ public const SESSION_CHECKOUT_STARTED = 'hellotext_checkout_started';
// Event Names
public const EVENT_ORDER_PLACED = 'order.placed';
@@ -45,6 +70,8 @@ class Constants {
public const EVENT_PRODUCT_VIEWED = 'product.viewed';
public const EVENT_CART_ADDED = 'cart.added';
public const EVENT_CART_REMOVED = 'cart.removed';
+ public const EVENT_CART_VIEWED = 'cart.viewed';
+ public const EVENT_CHECKOUT_STARTED = 'checkout.started';
public const EVENT_REFUND_RECEIVED = 'refund.received';
public const EVENT_COUPON_REDEEMED = 'coupon.redeemed';
diff --git a/src/Events/AppInstalled.php b/src/Events/AppInstalled.php
index 9d51e5b..87ececc 100644
--- a/src/Events/AppInstalled.php
+++ b/src/Events/AppInstalled.php
@@ -4,127 +4,151 @@
use Hellotext\Constants;
/**
- * Trigger integration creation on plugin activation.
+ * Connect the store when the plugin is activated.
*
* @return void
*/
function hellotext_activate(): void {
- $hellotext_business_id = get_option(Constants::OPTION_BUSINESS_ID);
- if (!$hellotext_business_id) {
- return;
- }
-
- do_action('hellotext_create_integration', $hellotext_business_id);
+ hellotext_connect();
}
/**
- * Create WooCommerce integration in Hellotext.
+ * Connect the store to Hellotext.
+ *
+ * Every connection issues a new WooCommerce API key for Hellotext, so
+ * reconnecting after a deactivation or a credential change works. Hellotext
+ * reads products and creates coupons and webhooks through this key, which
+ * needs read_write access.
+ *
+ * The previous key keeps working until Hellotext accepts the new one. A
+ * failed connection keeps it, is shown on the settings page, and is retried
+ * the next time the settings are saved.
*
- * @param mixed $business_id Business ID token.
* @return void
*/
-add_action('hellotext_create_integration', function (mixed $business_id): void {
- if (!$business_id) {
- $business_id = get_option(Constants::OPTION_BUSINESS_ID);
+function hellotext_connect(): void {
+ global $wpdb;
+
+ $business_id = get_option(Constants::OPTION_BUSINESS_ID);
+ $access_token = get_option(Constants::OPTION_ACCESS_TOKEN);
+ // The key acts as this user, so it must be a real shop manager.
+ $user_id = get_current_user_id();
+
+ if (!$business_id || !$access_token || !$user_id || !function_exists('wc_rand_hash')) {
+ return;
}
- global $wpdb;
$api_keys_table = $wpdb->prefix . 'woocommerce_api_keys';
- $api_keys = $wpdb->get_row("SELECT * FROM $api_keys_table WHERE description = 'Hellotext'");
- if (!$api_keys) {
- // Create a new API key
- $api_keys = (object) [
- 'consumer_key' => 'ck_' . wc_rand_hash(),
- 'consumer_secret' => 'cs_' . wc_rand_hash(),
- ];
-
- // wc_api_hash will hash the $api_keys->consumer_key in place.
- $conusmer_key = $api_keys->consumer_key;
-
- $wpdb->insert($api_keys_table, [
- 'user_id' => get_current_user_id(),
- 'description' => 'Hellotext',
- 'permissions' => 'read_write',
- 'consumer_key' => wc_api_hash($conusmer_key),
- 'consumer_secret' => $api_keys->consumer_secret,
- 'truncated_key' => substr($api_keys->consumer_key, -7),
- ]);
-
- Client::with_sufix()
- ->post(Constants::API_ENDPOINT_INTEGRATIONS_WOO, [
- 'shop' => [
- 'name' => get_bloginfo('name'),
- 'url' => get_bloginfo('url'),
- 'email' => get_bloginfo('admin_email'),
- 'consumer_key' => $api_keys->consumer_key,
- 'consumer_secret' => $api_keys->consumer_secret,
- 'currency' => get_woocommerce_currency(),
- ],
- ]);
-
- delete_transient('hellotext_integration_triggered');
+ $consumer_key = 'ck_' . wc_rand_hash();
+ $consumer_secret = 'cs_' . wc_rand_hash();
+
+ // A connection cut short leaves its pending key behind.
+ hellotext_delete_api_keys([Constants::API_KEY_PENDING_DESCRIPTION]);
+
+ $wpdb->insert($api_keys_table, [
+ 'user_id' => $user_id,
+ 'description' => Constants::API_KEY_PENDING_DESCRIPTION,
+ 'permissions' => 'read_write',
+ 'consumer_key' => wc_api_hash($consumer_key),
+ 'consumer_secret' => $consumer_secret,
+ 'truncated_key' => substr($consumer_key, -7),
+ ]);
+
+ $response = Client::post(Constants::API_ENDPOINT_INTEGRATIONS_WOO, [
+ 'shop' => [
+ 'name' => get_bloginfo('name'),
+ 'url' => get_bloginfo('url'),
+ 'email' => get_bloginfo('admin_email'),
+ 'consumer_key' => $consumer_key,
+ 'consumer_secret' => $consumer_secret,
+ 'currency' => get_woocommerce_currency(),
+ // Decides the checkout link Hellotext builds: WooCommerce's own from 10.0, the plugin's before.
+ 'version' => defined('WC_VERSION') ? WC_VERSION : null,
+ ],
+ ]);
+
+ if ($response['status'] >= 200 && $response['status'] < 300) {
+ hellotext_delete_api_keys([Constants::API_KEY_DESCRIPTION]);
+ $wpdb->update($api_keys_table, ['description' => Constants::API_KEY_DESCRIPTION], ['description' => Constants::API_KEY_PENDING_DESCRIPTION]);
+ delete_option(Constants::OPTION_CONNECTION_FAILED);
+ } else {
+ $wpdb->delete($api_keys_table, ['description' => Constants::API_KEY_PENDING_DESCRIPTION]);
+ update_option(Constants::OPTION_CONNECTION_FAILED, true, false);
}
-});
+}
/**
- * Handle business ID update.
+ * Retry a failed connection when the settings are saved again.
*
- * @param mixed $old_value Previous value.
- * @param mixed $new_value New value.
- * @return void
+ * Saving unchanged values does not run the option update hooks, so the
+ * filter, which runs on every save, schedules it.
+ *
+ * @param mixed $value Access token being saved.
+ * @return mixed
*/
-function after_business_id_save(mixed $old_value, mixed $new_value): void {
- if ($old_value !== $new_value) {
- maybe_trigger_integration($new_value);
+function hellotext_retry_failed_connection(mixed $value): mixed {
+ if (get_option(Constants::OPTION_CONNECTION_FAILED)) {
+ hellotext_schedule_connect();
}
+
+ return $value;
}
/**
- * Handle business ID creation.
+ * Delete the WooCommerce API keys issued to Hellotext, connected and pending
+ * ones by default.
*
- * @param mixed $value Business ID value.
+ * @param array $descriptions Descriptions of the keys to delete.
* @return void
*/
-function after_business_id_set(mixed $value): void {
- maybe_trigger_integration($value);
+function hellotext_delete_api_keys(array $descriptions = [Constants::API_KEY_DESCRIPTION, Constants::API_KEY_PENDING_DESCRIPTION]): void {
+ global $wpdb;
+
+ $api_keys_table = $wpdb->prefix . 'woocommerce_api_keys';
+
+ if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $api_keys_table)) !== $api_keys_table) {
+ return;
+ }
+
+ foreach ($descriptions as $description) {
+ $wpdb->delete($api_keys_table, ['description' => $description]);
+ }
}
/**
- * Trigger integration creation when possible.
+ * Connect once both settings are saved.
+ *
+ * The settings form saves the business ID before the access token, so the
+ * connection waits for the end of the request to use both new values.
*
- * @param mixed $business_id Business ID value.
* @return void
*/
-function maybe_trigger_integration(mixed $business_id): void {
- $integration_flag = get_transient('hellotext_integration_triggered');
-
- if ($integration_flag) {
- return;
+function hellotext_schedule_connect(): void {
+ if (!has_action('shutdown', 'hellotext_connect')) {
+ add_action('shutdown', 'hellotext_connect');
}
+}
- $hellotext_access_token = get_option(Constants::OPTION_ACCESS_TOKEN);
- if ($hellotext_access_token && $business_id) {
- set_transient('hellotext_integration_triggered', true, 10);
- do_action('hellotext_create_integration');
- } else {
- /**
- * Attempt integration creation during shutdown.
- *
- * @return void
- */
- add_action('shutdown', function (): void {
- $integration_flag = get_transient('hellotext_integration_triggered');
- $hellotext_access_token = get_option(Constants::OPTION_ACCESS_TOKEN);
-
- if ($hellotext_access_token && !$integration_flag) {
- set_transient('hellotext_integration_triggered', true, 10); // Expires in 10 seconds.
- do_action('hellotext_create_integration');
- }
- });
+/**
+ * Move the integration when the business ID changes.
+ *
+ * The access token is still the previous business's at this point, so the
+ * previous integration is removed with it before connecting the new one.
+ *
+ * @param mixed $old_value Previous value.
+ * @param mixed $new_value New value.
+ * @return void
+ */
+function hellotext_business_id_updated(mixed $old_value, mixed $new_value): void {
+ if ($old_value && $old_value !== $new_value) {
+ hellotext_disconnect((string) $old_value);
}
+
+ hellotext_schedule_connect();
}
-add_action('add_option_' . Constants::OPTION_BUSINESS_ID, 'after_business_id_set', 10, 1);
-add_action('add_option_' . Constants::OPTION_ACCESS_TOKEN, 'maybe_trigger_integration', 10, 3);
-add_action('update_option_' . Constants::OPTION_BUSINESS_ID, 'after_business_id_save', 10, 3);
-add_action('update_option_' . Constants::OPTION_ACCESS_TOKEN, 'maybe_trigger_integration', 10, 4);
+add_action('add_option_' . Constants::OPTION_BUSINESS_ID, 'hellotext_schedule_connect', 10, 0);
+add_action('add_option_' . Constants::OPTION_ACCESS_TOKEN, 'hellotext_schedule_connect', 10, 0);
+add_action('update_option_' . Constants::OPTION_BUSINESS_ID, 'hellotext_business_id_updated', 10, 2);
+add_action('update_option_' . Constants::OPTION_ACCESS_TOKEN, 'hellotext_schedule_connect', 10, 0);
+add_filter('pre_update_option_' . Constants::OPTION_ACCESS_TOKEN, 'hellotext_retry_failed_connection', 10, 1);
diff --git a/src/Events/AppRemoved.php b/src/Events/AppRemoved.php
index d431bc3..783f81b 100644
--- a/src/Events/AppRemoved.php
+++ b/src/Events/AppRemoved.php
@@ -4,34 +4,30 @@
use Hellotext\Constants;
/**
- * Trigger integration removal on plugin deactivation.
+ * Disconnect the store when the plugin is deactivated.
*
- * @param string|null $hellotext_business_id Business ID token.
* @return void
*/
-function hellotext_deactivate(?string $hellotext_business_id = null): void {
- if (!$hellotext_business_id) {
- $hellotext_business_id = get_option(Constants::OPTION_BUSINESS_ID);
- }
-
- if (!$hellotext_business_id) {
- return;
- }
-
- do_action('hellotext_remove_integration', $hellotext_business_id);
+function hellotext_deactivate(): void {
+ hellotext_disconnect();
}
/**
- * Remove WooCommerce integration from Hellotext.
+ * Remove the integration from Hellotext and revoke its WooCommerce API key.
*
- * @param mixed $business_id Business ID token.
+ * @param string|null $business_id Business to disconnect, the saved one by default.
* @return void
*/
-add_action('hellotext_remove_integration', function (mixed $business_id): void {
- Client::with_sufix()
- ->delete(Constants::API_ENDPOINT_INTEGRATIONS_WOO, [
- 'shop' => [
- 'business_id' => $business_id,
+function hellotext_disconnect(?string $business_id = null): void {
+ $business_id ??= get_option(Constants::OPTION_BUSINESS_ID);
+
+ if ($business_id && get_option(Constants::OPTION_ACCESS_TOKEN)) {
+ Client::delete(Constants::API_ENDPOINT_INTEGRATIONS_WOO, [
+ 'shop' => [
+ 'business_id' => $business_id,
],
]);
-});
+ }
+
+ hellotext_delete_api_keys();
+}
diff --git a/src/Events/CartUpdates.php b/src/Events/CartUpdates.php
index 324e5d8..31dcf79 100644
--- a/src/Events/CartUpdates.php
+++ b/src/Events/CartUpdates.php
@@ -1,120 +1,219 @@
session) {
+ WC()->session->set(Constants::SESSION_CART_ITEMS, []);
+ WC()->session->set(Constants::SESSION_CHECKOUT_STARTED, null);
+ }
}
-add_action('hellotext_woocommerce_cart_updated', 'hellotext_cart_updated');
+/**
+ * Schedule cart change tracking for the end of the request.
+ *
+ * Runs before WooCommerce saves its session on shutdown, so the stored cart
+ * snapshot persists.
+ *
+ * @return void
+ */
+function hellotext_trigger_cart_updated(): void {
+ if (!has_action('shutdown', 'hellotext_cart_updated')) {
+ add_action('shutdown', 'hellotext_cart_updated', 5);
+ }
+}
/**
* Track cart item add/remove changes.
*
+ * Hellotext sets each cart line to the quantity an addition carries, so
+ * cart.added reports the product's whole quantity in the cart. cart.removed
+ * reports the quantity removed.
+ *
* @return void
*/
function hellotext_cart_updated(): void {
- wc_load_cart();
+ $woocommerce = WC();
- $changes = [
- 'added' => [],
- 'removed' => [],
- ];
+ if (!$woocommerce->session || !$woocommerce->cart) {
+ return;
+ }
- // Set previous cart items and current cart items
- $previous_cart_items = isset($_SESSION[Constants::SESSION_CART_ITEMS])
- ? json_decode(sanitize_text_field($_SESSION[Constants::SESSION_CART_ITEMS]), true)
- : [];
+ $previous_cart_items = $woocommerce->session->get(Constants::SESSION_CART_ITEMS);
+ $previous_cart_items = is_array($previous_cart_items) ? $previous_cart_items : [];
+ $cart_items = hellotext_cart_items();
- $current_cart_items = WC()->cart->get_cart();
- $cart_items = [];
+ $woocommerce->session->set(Constants::SESSION_CART_ITEMS, $cart_items);
- foreach ($current_cart_items as $key => $cart_item) {
- $product = $cart_item['data']; // WC_Product object
+ $changes = [
+ Constants::EVENT_CART_ADDED => [],
+ Constants::EVENT_CART_REMOVED => [],
+ ];
- $cart_items[] = [
- 'product' => (new ProductAdapter($product))->get(),
- 'quantity' => $cart_item['quantity'],
- ];
+ foreach ($cart_items as $reference => $cart_item) {
+ if ($cart_item['quantity'] > ($previous_cart_items[$reference]['quantity'] ?? 0)) {
+ $changes[Constants::EVENT_CART_ADDED][] = $cart_item;
+ }
}
- // Save current cart items to session
- $_SESSION[Constants::SESSION_CART_ITEMS] = json_encode($cart_items);
+ foreach ($previous_cart_items as $reference => $previous_item) {
+ $removed = $previous_item['quantity'] - ($cart_items[$reference]['quantity'] ?? 0);
- // Calculate total cart value
- $cart_total = WC()->cart->get_cart_contents_total();
- $currency = get_woocommerce_currency();
+ if ($removed > 0) {
+ $changes[Constants::EVENT_CART_REMOVED][] = ['product' => $previous_item['product'], 'quantity' => $removed];
+ }
+ }
- // Get current page URL
- $current_url = home_url(add_query_arg([], $GLOBALS['wp']->request));
+ if (empty($changes[Constants::EVENT_CART_ADDED]) && empty($changes[Constants::EVENT_CART_REMOVED])) {
+ return;
+ }
- foreach ($cart_items as $cart_item) {
- $match = array_filter(
- $previous_cart_items,
- fn ($item) => $item['product']['reference'] == $cart_item['product']['reference']
- );
+ do_action('hellotext_create_profile');
- $previous_item = count($match) > 0 ? array_shift($match) : null;
+ $event = new Event(Session::current());
- if (!$previous_item || $previous_item['quantity'] < $cart_item['quantity']) {
- $changes['added'][] = $cart_item;
+ foreach ($changes as $action => $items) {
+ if (empty($items)) {
+ continue;
}
+
+ $event->track($action, array_filter([
+ 'amount' => $woocommerce->cart->get_cart_contents_total(),
+ 'currency' => get_woocommerce_currency(),
+ 'url' => hellotext_current_url(),
+ 'source' => Constants::EVENT_CART_ADDED === $action ? hellotext_cart_event_source() : null,
+ 'object_parameters' => [
+ 'items' => $items,
+ ],
+ ], fn ($value) => null !== $value));
}
+}
- // Add items that were removed from the cart
- foreach ($previous_cart_items as $previous_item) {
- $match = array_filter(
- $cart_items,
- fn ($item) => $item['product']['reference'] == $previous_item['product']['reference']
- );
+/**
+ * Track the cart page.
+ *
+ * @return void
+ */
+function hellotext_cart_viewed(): void {
+ if (!function_exists('WC') || !is_cart() || !WC()->cart || WC()->cart->is_empty()) {
+ return;
+ }
- $cart_item = count($match) > 0 ? array_shift($match) : null;
+ do_action('hellotext_create_profile');
- if (!$cart_item || $previous_item['quantity'] > $cart_item['quantity']) {
- $changes['removed'][] = $previous_item; // Use previous_item here as it's the removed item
- }
- }
+ (new Event(Session::current()))->track(Constants::EVENT_CART_VIEWED, [
+ 'object_parameters' => hellotext_cart_object_parameters() + ['url' => hellotext_current_url()],
+ ]);
+}
- // Trigger events, one for added and one for removed items
- $event_map = [
- 'added' => Constants::EVENT_CART_ADDED,
- 'removed' => Constants::EVENT_CART_REMOVED,
- ];
+/**
+ * The cart's lines, one per product or variation, keyed by reference.
+ *
+ * @return array
+ */
+function hellotext_cart_items(): array {
+ $cart_items = [];
- foreach ($changes as $event => $items) {
- if (0 == count($items)) {
- continue;
- }
+ foreach (WC()->cart->get_cart() as $cart_item) {
+ $product = (new ProductAdapter($cart_item['data']))->get();
+ $reference = (string) $product['reference'];
- if (!isset($event_map[$event])) {
+ if (isset($cart_items[$reference])) {
+ $cart_items[$reference]['quantity'] += $cart_item['quantity'];
continue;
}
- $event_data = [
- 'amount' => $cart_total,
- 'currency' => $currency,
- 'url' => $current_url,
- 'object_parameters' => [
- 'items' => $items,
- ],
+ $cart_items[$reference] = [
+ 'product' => $product,
+ 'quantity' => $cart_item['quantity'],
];
+ }
- (new Event())->track($event_map[$event], $event_data);
+ return $cart_items;
+}
+
+/**
+ * The cart as the object of cart.viewed and checkout.started.
+ *
+ * @return array
+ */
+function hellotext_cart_object_parameters(): array {
+ return [
+ 'source' => 'woo',
+ 'items' => array_values(hellotext_cart_items()),
+ 'total' => (new PriceAdapter(WC()->cart->get_total('edit')))->get(),
+ 'total_quantity' => WC()->cart->get_cart_contents_count(),
+ ];
+}
+
+/**
+ * The Webchat message whose product card added to the cart in this request.
+ *
+ * The storefront script posts it with the product, and Hellotext credits the
+ * addition to that message.
+ *
+ * @return array|null
+ */
+function hellotext_cart_event_source(): ?array {
+ if (!isset($_POST['hellotext_source']) || !is_string($_POST['hellotext_source'])) {
+ return null;
+ }
+
+ $source = json_decode(wp_unslash($_POST['hellotext_source']), true);
+
+ if (!is_array($source)) {
+ return null;
+ }
+
+ $source = array_intersect_key($source, array_flip(['kind', 'message_id', 'button_id']));
+ $source = array_map(fn ($value) => is_scalar($value) ? sanitize_text_field((string) $value) : '', $source);
+ $source = array_filter($source, fn ($value) => '' !== $value);
+
+ return $source ?: null;
+}
+
+/**
+ * The storefront page the shopper is on.
+ *
+ * Add to cart buttons post to wc-ajax or the Store API, so those requests
+ * report the page they came from.
+ *
+ * @return string
+ */
+function hellotext_current_url(): string {
+ $background_request = wp_doing_ajax()
+ || (defined('REST_REQUEST') && REST_REQUEST)
+ || isset($_GET['wc-ajax']);
+
+ if ($background_request) {
+ $referer = wp_get_referer();
+
+ if ($referer) {
+ return $referer;
+ }
}
+
+ return home_url(add_query_arg([], $GLOBALS['wp']->request ?? ''));
}
diff --git a/src/Events/CheckoutLink.php b/src/Events/CheckoutLink.php
new file mode 100644
index 0000000..2d84e71
--- /dev/null
+++ b/src/Events/CheckoutLink.php
@@ -0,0 +1,98 @@
+cart) {
+ return;
+ }
+
+ wp_safe_redirect(hellotext_fill_checkout_link_cart());
+ exit;
+}
+
+/**
+ * Replace the cart with the link's products.
+ *
+ * @return string The checkout; the page of a product whose options the
+ * shopper has to pick, as WooCommerce's own link does; or the
+ * cart, with WooCommerce's notices, when nothing could be added.
+ */
+function hellotext_fill_checkout_link_cart(): string {
+ $products = wp_unslash($_GET['products'] ?? '');
+ $lines = is_string($products) ? hellotext_checkout_link_lines($products) : [];
+
+ if (empty($lines)) {
+ return wc_get_cart_url();
+ }
+
+ WC()->cart->empty_cart();
+ $options_url = null;
+
+ foreach ($lines as $product_id => $quantity) {
+ try {
+ $added = WC()->cart->add_to_cart($product_id, $quantity);
+ } catch (\Exception $e) {
+ $added = false;
+ }
+
+ if ($added || $options_url) {
+ continue;
+ }
+
+ $product = wc_get_product($product_id);
+
+ if ($product && $product->is_type(['variable', 'variation'])) {
+ $options_url = $product->get_permalink();
+ }
+ }
+
+ if ($options_url) {
+ return $options_url;
+ }
+
+ if (WC()->cart->is_empty()) {
+ return wc_get_cart_url();
+ }
+
+ $query = wp_unslash($_GET);
+ unset($query[Constants::CHECKOUT_LINK_PARAM], $query['products']);
+
+ return add_query_arg(urlencode_deep($query), wc_get_checkout_url());
+}
+
+/**
+ * The quantity of each product of a link's `products`, which lists
+ * id:quantity separated by commas. A product listed twice adds up, and a
+ * line without a valid ID is left out.
+ *
+ * @param string $value The `products` parameter.
+ * @return array ' . esc_html__('settings.connection_failed', 'hellotext') . ' ' . wp_kses(__('description.paragraphs.one', 'hellotext'), [ 'a' => [ 'href' => [], 'target' => [], 'style' => [] ] ]) . '