From 75c8cb658110d8d08cbaaa5317a346b547f9f040 Mon Sep 17 00:00:00 2001
From: rockwellll
Date: Fri, 6 Nov 2026 19:19:47 -0300
Subject: [PATCH 1/4] fix: simplify setup and fix tracking bugs
Webchat, popups and other widgets are configured in Hellotext, so the
settings page keeps only the Business ID and Access Token, and the store
loads the latest unversioned Hellotext.js from jsDelivr on the storefront.
- Register hellotext_create_profile from an always-loaded file; it only
existed after the CreateProfile class autoloaded, so most requests never
created or linked profiles.
- Send profile and session requests to /v1; the client's static suffix
dropped it for the rest of the request.
- Use the session stored on the order for status changes and refunds, and
never link a shop manager's session to a customer or new user.
- Capture the session at classic and block checkout and track order.placed
once, when the order is paid or on hold, from the thank-you page or the
status change.
- Compare carts once at shutdown in WC()->session: totals after
recalculation, changed quantities, no removals after checkout, referer
URL for background add to cart. Stop starting a PHP session.
- Track only valid coupons, validated against the cart.
- Register the activation hook, reissue the API key on every connection,
revoke it on deactivation, and move the integration when the Business
ID changes.
- Handle deleted products and variation terms, trim saved credentials,
prefix global functions, and replace APP_ENV with HELLOTEXT_API_URL.
Co-Authored-By: Claude Opus 5.5
---
API.md | 103 +++---
DEVELOPMENT.md | 24 +-
README.md | 4 +-
changelog.txt | 13 +
docs/WOOCOMMERCE-AUDIT.md | 28 +-
hellotext.php | 49 +--
languages/hellotext-en_US.mo | Bin 1569 -> 1067 bytes
languages/hellotext-en_US.po | 24 --
languages/hellotext-es_ES.mo | Bin 1800 -> 1227 bytes
languages/hellotext-es_ES.po | 24 --
languages/hellotext.pot | 24 --
src/Adapters/OrderAdapter.php | 24 +-
src/Adapters/ProductAdapter.php | 21 +-
src/Api/Client.php | 34 +-
src/Api/Event.php | 42 +--
src/Api/Webchat.php | 30 --
src/Constants.php | 33 +-
src/Events/AppInstalled.php | 163 +++++-----
src/Events/AppRemoved.php | 36 +--
src/Events/CartUpdates.php | 161 ++++++----
src/Events/CouponRedeemed.php | 31 +-
src/Events/CustomOptionsUpdated.php | 22 --
src/Events/CustomerProfile.php | 25 ++
src/Events/OrderPlaced.php | 94 ++++--
src/Events/OrderStatus.php | 46 ++-
src/Events/ProductViewed.php | 7 +-
src/Events/RefundReceived.php | 13 +-
src/Misc/Scripts.php | 41 +--
src/Misc/Settings.php | 177 ++---------
src/Services/CreateProfile.php | 207 +++++-------
src/Services/Session.php | 48 ++-
tests/Mocks.php | 192 +++++++++++-
tests/Unit/Api/ClientTest.php | 44 +--
tests/Unit/Events/EventTrackingTest.php | 364 +++++++++++++++++-----
tests/Unit/Services/CreateProfileTest.php | 8 +-
35 files changed, 1159 insertions(+), 997 deletions(-)
delete mode 100644 src/Api/Webchat.php
delete mode 100644 src/Events/CustomOptionsUpdated.php
create mode 100644 src/Events/CustomerProfile.php
diff --git a/API.md b/API.md
index ef31d6e..b91f34c 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.
+##### `encrypt(?string $session): string`
-##### `decrypt(string $encrypted_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,22 @@ 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` and `cart.removed` with the quantity that changed, after WooCommerce recalculates totals |
+| `woocommerce_cart_emptied` | `hellotext_reset_cart_snapshot` | Forget the cart snapshot so purchased items are not reported as removed |
| `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; remove the previous business's integration first when the Business ID changes |
| `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 on the storefront |
## WooCommerce Compatibility
@@ -435,11 +427,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 +452,25 @@ 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
+- `wp_enqueue_scripts` - Load Hellotext.js. Webchat, popups and other widgets are configured from Hellotext
## Testing
For testing information, see [DEVELOPMENT.md](DEVELOPMENT.md).
-## Environment Variables
-
-The plugin respects the following environment variables:
+## Configuration Constants
-- `APP_ENV` - Environment mode: `production`, `development`, or `test`
-- `HELLOTEXT_API_URL` - Override API URL (development only)
+Define `HELLOTEXT_API_URL` in `wp-config.php` to point the plugin at another Hellotext environment:
-**Example (.htaccess):**
-
-```apache
-SetEnv APP_ENV development
-SetEnv HELLOTEXT_API_URL https://api-dev.hellotext.com
+```php
+define('HELLOTEXT_API_URL', 'https://api-dev.hellotext.com');
```
## Error Handling
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index f944d0d..2fad14a 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,8 +338,8 @@ 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.
+- [ ] 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.
- [ ] 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.
- [ ] 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.
@@ -525,7 +511,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..13c93e1 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 and other tools you set up in Hellotext appear without further changes in WordPress.
+
## Next Steps
From now on, you will start seeing your customers' activity reflected in the Audience section or from the Inbox.
@@ -106,7 +108,7 @@ The plugin automatically tracks the following customer activities:
- **Product Viewed** - When a customer views a product page
- **Cart Added/Removed** - Items added to or removed from 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/changelog.txt b/changelog.txt
index 199d49e..91d05fb 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,5 +1,18 @@
** 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.
+* 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 the quantity that changed in cart events, with totals after WooCommerce recalculates them, 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.
+* 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..d0d0321 100644
--- a/docs/WOOCOMMERCE-AUDIT.md
+++ b/docs/WOOCOMMERCE-AUDIT.md
@@ -28,17 +28,20 @@ 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. |
| 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. |
+| 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. 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 +51,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 +92,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 9f8dcd2f513fd2e964e91bc216c21c4950b5a328..6aa1434ebe3514bdcaaf2d4c4dad041fa6247fab 100644
GIT binary patch
delta 206
zcmZ3;vznv+o)F7a1|VPuVi_O~0b*_-?g3&D*a5^+K)e%5UjfphK>QwvMSz%F*r5{Tyj`D>XN7&ZcFFJ=Y?9w7Y(NQ2DhWC8Qd8AO2$4xoS%kOt~u
q0LnA40x=A*0U1Cu7=Y4nz|Jt4i?MlgGm{+So=TvQxKTJF6cPwhMx14DUSNbJ^f=T7H~E
zIf7og45wibPQiUB_Zn~%R-nwCz)5)9>7Sqr{Tp0@O}IcvmAI^gEMl+?)+hc8@S~
z1wEbV4gSgrp8xBxzD)Zd^Uq2KEmJ*9<5@-`1%AL1LTzkXW-O~wHCSL&=-g04R4~8d
z)x17c^Qayz>e=vne15NYP1F3Eb{4g+IKtFOmuMQf#*y{jBKF!>rv$oJXHK?4#4ID-
oiwd-ELkY{<=+wKdKaTQzdn$|eXoo8_A@nj+%yUw_WF+0>8;#$(Q2+n{
diff --git a/languages/hellotext-en_US.po b/languages/hellotext-en_US.po
index 18d0a54..bee299a 100644
--- a/languages/hellotext-en_US.po
+++ b/languages/hellotext-en_US.po
@@ -44,30 +44,6 @@ 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"
diff --git a/languages/hellotext-es_ES.mo b/languages/hellotext-es_ES.mo
index bf3ecf8051a869d2b4e613456331b364f7ba10d1..db3c607574017ed3b50fe2621629bf908065e044 100644
GIT binary patch
delta 222
zcmX}kAr8VY7>41mrN9VekOj;_n8Qt>Q3$Sp-~f085{tksxZMB@nME)M*&Vn52f*?!
zH2le%r)}CE!m6`>MeZafpfRnfOF7l^pp>2z)5jYbX~a6kAuh0wD-3aiTiVf#{o6`s
yOd8T1E9CxCNB4V}doNgja6{e-#NGe2Sn-#d{-lk8ve;aU(oRg)bJJq(2KEcRL=}Jl
delta 805
zcmaKp!Al!K6vih(g?dP>tsaUk11*)dY}mwl2_lGIq_y;*^eAt4$7E!8CQc?1ye!^4
z6>1SLy%dkyLkaXC9xMSb`Zq{V{Udtv8M!x+Py
z!u;$6##H7nJuDwVKLlTQxB*T;?}87&L+~zm0_MOo(D^UH`{1vR{tZ5Zp2#vb%~;Cj
zuy}$%1U?1#KzHCPcntFoa0WUVf(E~U=ith4J7I35&Bq<)L07;$=n7cr=n6Oqy$!m8
zKY-(|=(nZF_~(kQ&&YD6-`L}`y85rn20ZPHx}DV(HAu!3#DcvudQ*hf
zYo?jW%!^fT(Fn3qD{qOcDDk3Sob~*ISMXO0uS$j4QnC2b_e;M2X+QgX^bM(Uoyzbv
zPqX*3>7q`m+E_{?-a+$7#Qc5xHQDk~k6a66NTF;sRZn;!piRw*$He8%R$U4c5xW(P
sgc0~0wqT45sNXnuJKVLt)x0LSP@z`Vw3=MulUt{%TNtO%%7)If8>K4Na{vGU
diff --git a/languages/hellotext-es_ES.po b/languages/hellotext-es_ES.po
index 105e669..40cbbb2 100644
--- a/languages/hellotext-es_ES.po
+++ b/languages/hellotext-es_ES.po
@@ -44,30 +44,6 @@ 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"
diff --git a/languages/hellotext.pot b/languages/hellotext.pot
index d9c5c5a..f08d7b6 100644
--- a/languages/hellotext.pot
+++ b/languages/hellotext.pot
@@ -44,33 +44,9 @@ 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"
-msgstr ""
-
msgid "description.paragraphs.one"
msgstr ""
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..b1106f4 100644
--- a/src/Constants.php
+++ b/src/Constants.php
@@ -11,13 +11,15 @@
*/
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';
+ // 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,15 +27,30 @@ 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';
+
+ // 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,
+ 'hellotext_webchat_id',
+ 'hellotext_webchat_placement',
+ 'hellotext_webchat_behaviour',
+ ];
+
+ // WooCommerce API key
+ public const API_KEY_DESCRIPTION = 'Hellotext';
// 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';
// Event Names
diff --git a/src/Events/AppInstalled.php b/src/Events/AppInstalled.php
index 9d51e5b..5dd29a4 100644
--- a/src/Events/AppInstalled.php
+++ b/src/Events/AppInstalled.php
@@ -4,127 +4,110 @@
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 and
+ * replaces the previous one, 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.
*
- * @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;
- $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');
+
+ $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;
}
-});
+
+ $consumer_key = 'ck_' . wc_rand_hash();
+ $consumer_secret = 'cs_' . wc_rand_hash();
+
+ hellotext_delete_api_keys();
+
+ $wpdb->insert($wpdb->prefix . 'woocommerce_api_keys', [
+ 'user_id' => $user_id,
+ 'description' => Constants::API_KEY_DESCRIPTION,
+ 'permissions' => 'read_write',
+ 'consumer_key' => wc_api_hash($consumer_key),
+ 'consumer_secret' => $consumer_secret,
+ 'truncated_key' => substr($consumer_key, -7),
+ ]);
+
+ 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(),
+ ],
+ ]);
+}
/**
- * Handle business ID update.
+ * Delete the WooCommerce API keys issued to Hellotext.
*
- * @param mixed $old_value Previous value.
- * @param mixed $new_value New value.
* @return void
*/
-function after_business_id_save(mixed $old_value, mixed $new_value): void {
- if ($old_value !== $new_value) {
- maybe_trigger_integration($new_value);
+function hellotext_delete_api_keys(): 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) {
+ $wpdb->delete($api_keys_table, ['description' => Constants::API_KEY_DESCRIPTION]);
}
}
/**
- * Handle business ID creation.
+ * 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 $value Business ID value.
* @return void
*/
-function after_business_id_set(mixed $value): void {
- maybe_trigger_integration($value);
+function hellotext_schedule_connect(): void {
+ if (!has_action('shutdown', 'hellotext_connect')) {
+ add_action('shutdown', 'hellotext_connect');
+ }
}
/**
- * Trigger integration creation when possible.
+ * Move the integration when the business ID changes.
*
- * @param mixed $business_id Business ID value.
+ * 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 maybe_trigger_integration(mixed $business_id): void {
- $integration_flag = get_transient('hellotext_integration_triggered');
-
- if ($integration_flag) {
- return;
+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_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');
- }
- });
- }
+ 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);
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..ee3fd7b 100644
--- a/src/Events/CartUpdates.php
+++ b/src/Events/CartUpdates.php
@@ -3,26 +3,43 @@
use Hellotext\Adapters\ProductAdapter;
use Hellotext\Api\Event;
use Hellotext\Constants;
+use Hellotext\Services\Session;
-// We could listen for woocommerce_cart_updated event but this event is
-// triggered too many times per cart update. Instead, we listen for the
-// cart page load and trigger our own event.
+// Cart hooks fire several times per change and before WooCommerce
+// recalculates the totals, so they only schedule one comparison at the end
+// of the request. Viewing the cart page also compares, to catch changes made
+// without these hooks.
add_action('woocommerce_after_cart', 'hellotext_trigger_cart_updated');
add_action('woocommerce_add_to_cart', 'hellotext_trigger_cart_updated');
add_action('woocommerce_cart_item_removed', 'hellotext_trigger_cart_updated');
add_action('woocommerce_after_cart_item_quantity_update', 'hellotext_trigger_cart_updated');
+add_action('woocommerce_cart_emptied', 'hellotext_reset_cart_snapshot');
/**
- * Trigger cart update tracking actions.
+ * Forget the cart snapshot when WooCommerce empties the cart, such as after
+ * checkout, so the purchased items are not reported as removed.
*
* @return void
*/
-function hellotext_trigger_cart_updated(): void {
- do_action('hellotext_create_profile');
- do_action('hellotext_woocommerce_cart_updated');
+function hellotext_reset_cart_snapshot(): void {
+ if (WC()->session) {
+ WC()->session->set(Constants::SESSION_CART_ITEMS, []);
+ }
}
-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.
@@ -30,91 +47,99 @@ function hellotext_trigger_cart_updated(): void {
* @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 : [];
- $current_cart_items = WC()->cart->get_cart();
$cart_items = [];
- foreach ($current_cart_items as $key => $cart_item) {
- $product = $cart_item['data']; // WC_Product object
+ foreach ($woocommerce->cart->get_cart() as $cart_item) {
+ $product = (new ProductAdapter($cart_item['data']))->get();
+ $reference = (string) $product['reference'];
+
+ if (isset($cart_items[$reference])) {
+ $cart_items[$reference]['quantity'] += $cart_item['quantity'];
+ continue;
+ }
- $cart_items[] = [
- 'product' => (new ProductAdapter($product))->get(),
+ $cart_items[$reference] = [
+ 'product' => $product,
'quantity' => $cart_item['quantity'],
];
}
- // Save current cart items to session
- $_SESSION[Constants::SESSION_CART_ITEMS] = json_encode($cart_items);
-
- // Calculate total cart value
- $cart_total = WC()->cart->get_cart_contents_total();
- $currency = get_woocommerce_currency();
+ $woocommerce->session->set(Constants::SESSION_CART_ITEMS, $cart_items);
- // Get current page URL
- $current_url = home_url(add_query_arg([], $GLOBALS['wp']->request));
-
- foreach ($cart_items as $cart_item) {
- $match = array_filter(
- $previous_cart_items,
- fn ($item) => $item['product']['reference'] == $cart_item['product']['reference']
- );
+ $changes = [
+ Constants::EVENT_CART_ADDED => [],
+ Constants::EVENT_CART_REMOVED => [],
+ ];
- $previous_item = count($match) > 0 ? array_shift($match) : null;
+ foreach ($cart_items as $reference => $cart_item) {
+ $added = $cart_item['quantity'] - ($previous_cart_items[$reference]['quantity'] ?? 0);
- if (!$previous_item || $previous_item['quantity'] < $cart_item['quantity']) {
- $changes['added'][] = $cart_item;
+ if ($added > 0) {
+ $changes[Constants::EVENT_CART_ADDED][] = ['product' => $cart_item['product'], 'quantity' => $added];
}
}
- // 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']
- );
-
- $cart_item = count($match) > 0 ? array_shift($match) : null;
+ foreach ($previous_cart_items as $reference => $previous_item) {
+ $removed = $previous_item['quantity'] - ($cart_items[$reference]['quantity'] ?? 0);
- if (!$cart_item || $previous_item['quantity'] > $cart_item['quantity']) {
- $changes['removed'][] = $previous_item; // Use previous_item here as it's the removed item
+ if ($removed > 0) {
+ $changes[Constants::EVENT_CART_REMOVED][] = ['product' => $previous_item['product'], 'quantity' => $removed];
}
}
- // Trigger events, one for added and one for removed items
- $event_map = [
- 'added' => Constants::EVENT_CART_ADDED,
- 'removed' => Constants::EVENT_CART_REMOVED,
- ];
+ if (empty($changes[Constants::EVENT_CART_ADDED]) && empty($changes[Constants::EVENT_CART_REMOVED])) {
+ return;
+ }
- foreach ($changes as $event => $items) {
- if (0 == count($items)) {
- continue;
- }
+ do_action('hellotext_create_profile');
+
+ $event = new Event(Session::current());
- if (!isset($event_map[$event])) {
+ foreach ($changes as $action => $items) {
+ if (empty($items)) {
continue;
}
- $event_data = [
- 'amount' => $cart_total,
- 'currency' => $currency,
- 'url' => $current_url,
- 'object_parameters' => [
- 'items' => $items,
- ],
- ];
+ $event->track($action, [
+ 'amount' => $woocommerce->cart->get_cart_contents_total(),
+ 'currency' => get_woocommerce_currency(),
+ 'url' => hellotext_current_url(),
+ 'object_parameters' => [
+ 'items' => $items,
+ ],
+ ]);
+ }
+}
- (new Event())->track($event_map[$event], $event_data);
+/**
+ * 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/CouponRedeemed.php b/src/Events/CouponRedeemed.php
index d5217b4..aaf9f6e 100644
--- a/src/Events/CouponRedeemed.php
+++ b/src/Events/CouponRedeemed.php
@@ -2,6 +2,7 @@
use Hellotext\Api\Event;
use Hellotext\Constants;
+use Hellotext\Services\Session;
add_action('woocommerce_applied_coupon', 'hellotext_coupon_redeemed', 10, 1);
@@ -12,22 +13,22 @@
* @return void
*/
function hellotext_coupon_redeemed(string $code): void {
- do_action('hellotext_create_profile');
-
$coupon = new \WC_Coupon($code);
- $discounts = new \WC_Discounts();
-
- $valid = $discounts->is_coupon_valid($coupon);
+ $discounts = new \WC_Discounts(WC()->cart);
- if ($valid) {
- (new Event())->track(Constants::EVENT_COUPON_REDEEMED, [
- 'object_parameters' => [
- 'type' => 'coupon',
- 'reference' => $coupon->get_id(),
- 'code' => $code,
- 'description' => $coupon->get_description(),
- 'destination_url' => site_url('/cart'),
- ],
- ]);
+ if (true !== $discounts->is_coupon_valid($coupon)) {
+ return;
}
+
+ do_action('hellotext_create_profile');
+
+ (new Event(Session::current()))->track(Constants::EVENT_COUPON_REDEEMED, [
+ 'object_parameters' => [
+ 'type' => 'coupon',
+ 'reference' => $coupon->get_id(),
+ 'code' => $code,
+ 'description' => $coupon->get_description(),
+ 'destination_url' => wc_get_cart_url(),
+ ],
+ ]);
}
diff --git a/src/Events/CustomOptionsUpdated.php b/src/Events/CustomOptionsUpdated.php
deleted file mode 100644
index e805cdf..0000000
--- a/src/Events/CustomOptionsUpdated.php
+++ /dev/null
@@ -1,22 +0,0 @@
-process();
+ return;
+ }
+
+ $user_id = is_numeric($customer) && (int) $customer > 0 ? (int) $customer : get_current_user_id();
+
+ if ($user_id > 0) {
+ (new CreateProfile($user_id))->process();
+ }
+}, 10, 1);
diff --git a/src/Events/OrderPlaced.php b/src/Events/OrderPlaced.php
index a58eeba..f5a62a7 100644
--- a/src/Events/OrderPlaced.php
+++ b/src/Events/OrderPlaced.php
@@ -5,35 +5,93 @@
use Hellotext\Constants;
use Hellotext\Services\Session;
-add_action('woocommerce_after_order_details', 'hellotext_order_placed');
+// Checkout runs in the customer's browser request, so it is where the
+// customer's session and profile are captured. order.placed is sent later,
+// once the order is paid or on hold: from the thank-you page, or from the
+// status change for block themes and customers who never return from the
+// payment page.
+add_action('woocommerce_checkout_order_processed', 'hellotext_checkout_order_processed', 10, 3);
+add_action('woocommerce_store_api_checkout_order_processed', 'hellotext_capture_checkout', 10, 1);
+add_action('woocommerce_thankyou', 'hellotext_thankyou', 10, 1);
/**
- * Track order placement.
+ * Capture a classic checkout order.
*
- * @param \WC_Order $order WooCommerce order instance.
+ * @param int $order_id Order ID.
+ * @param array $posted_data Checkout form data.
+ * @param \WC_Order $order Order instance.
* @return void
*/
-function hellotext_order_placed(\WC_Order $order): void {
- if ($order->get_meta(Constants::META_SESSION, true)) {
+function hellotext_checkout_order_processed(int $order_id, array $posted_data, \WC_Order $order): void {
+ hellotext_capture_checkout($order);
+}
+
+/**
+ * Store the customer's session on the order and create their profile.
+ *
+ * Must only run in the customer's own request.
+ *
+ * @param \WC_Order $order Order instance.
+ * @return void
+ */
+function hellotext_capture_checkout(\WC_Order $order): void {
+ $customer_id = $order->get_user_id();
+ do_action('hellotext_create_profile', $customer_id > 0 ? $customer_id : $order->get_address('billing'));
+
+ $order->update_meta_data(Constants::META_SESSION, Session::encrypt(Session::current()));
+
+ if (!$order->get_meta(Constants::META_ORDER_PLACED, true)) {
+ $order->update_meta_data(Constants::META_ORDER_PLACED, Constants::ORDER_PLACED_PENDING);
+ }
+
+ $order->save_meta_data();
+}
+
+/**
+ * Track the order from the thank-you page.
+ *
+ * Themes and gateways fire this hook too, sometimes with a string ID, so it
+ * accepts any value rather than risk breaking the page.
+ *
+ * @param mixed $order_id Order ID.
+ * @return void
+ */
+function hellotext_thankyou(mixed $order_id): void {
+ $order = wc_get_order($order_id);
+
+ if (!$order) {
return;
}
- $userId = $order->get_user_id();
- $userId = $userId > 0 ? $userId : $order->data['billing'];
+ // Orders from checkout flows that skip the checkout hooks, such as some
+ // express payment buttons. Orders tracked by earlier versions of the
+ // plugin already carry a session and are left alone.
+ if (!$order->get_meta(Constants::META_ORDER_PLACED, true) && !$order->get_meta(Constants::META_SESSION, true)) {
+ hellotext_capture_checkout($order);
+ }
- do_action('hellotext_create_profile', $userId ?? $order->data['billing']);
+ hellotext_order_placed($order);
+}
- $event = new Event();
- $parsedOrder = (new OrderAdapter($order))->get();
+/**
+ * Track order placement once per order, when it is paid or on hold.
+ *
+ * @param \WC_Order $order WooCommerce order instance.
+ * @return void
+ */
+function hellotext_order_placed(\WC_Order $order): void {
+ if (Constants::ORDER_PLACED_PENDING !== $order->get_meta(Constants::META_ORDER_PLACED, true)) {
+ return;
+ }
+
+ if (!$order->is_paid() && !$order->has_status('on-hold')) {
+ return;
+ }
- $session = isset($_COOKIE[Constants::SESSION_COOKIE_NAME])
- ? sanitize_text_field($_COOKIE[Constants::SESSION_COOKIE_NAME])
- : null;
- $encrypted_session = Session::encrypt($session);
- $order->update_meta_data(Constants::META_SESSION, $encrypted_session);
- $order->save();
+ $order->update_meta_data(Constants::META_ORDER_PLACED, Constants::ORDER_PLACED_TRACKED);
+ $order->save_meta_data();
- $event->track(Constants::EVENT_ORDER_PLACED, [
- 'object_parameters' => $parsedOrder,
+ (new Event(Session::for_order($order)))->track(Constants::EVENT_ORDER_PLACED, [
+ 'object_parameters' => (new OrderAdapter($order))->get(),
]);
}
diff --git a/src/Events/OrderStatus.php b/src/Events/OrderStatus.php
index c88ad10..50297aa 100644
--- a/src/Events/OrderStatus.php
+++ b/src/Events/OrderStatus.php
@@ -5,43 +5,37 @@
use Hellotext\Constants;
use Hellotext\Services\Session;
-add_action('woocommerce_order_status_changed', 'track_order_status', 10, 4);
+add_action('woocommerce_order_status_changed', 'hellotext_order_status_changed', 10, 4);
/**
* Track order status transitions.
*
+ * Runs from wp-admin and payment callbacks too, so it only uses the session
+ * stored on the order and never creates profiles.
+ *
* @param int $order_id Order ID.
* @param string $old_status Previous status.
* @param string $new_status New status.
* @param \WC_Order $order WooCommerce order instance.
* @return void
*/
-function track_order_status(int $order_id, string $old_status, string $new_status, \WC_Order $order): void {
- $encrypted_session = $order->get_meta(Constants::META_SESSION, true);
- $session = Session::decrypt($encrypted_session);
-
- $orderAdapter = new OrderAdapter($order);
- $event = new Event($session);
-
- do_action('hellotext_create_profile', $order->get_user_id());
-
- switch ($new_status) {
- case 'processing':
- $event->track(Constants::EVENT_ORDER_CONFIRMED, [
- 'object_parameters' => $orderAdapter->get(),
- ]);
- break;
+function hellotext_order_status_changed(int $order_id, string $old_status, string $new_status, \WC_Order $order): void {
+ if (in_array($new_status, ['processing', 'on-hold', 'completed'], true)) {
+ hellotext_order_placed($order);
+ }
- case 'cancelled':
- $event->track(Constants::EVENT_ORDER_CANCELLED, [
- 'object_parameters' => $orderAdapter->get(),
- ]);
- break;
+ $action = match ($new_status) {
+ 'processing' => Constants::EVENT_ORDER_CONFIRMED,
+ 'cancelled' => Constants::EVENT_ORDER_CANCELLED,
+ 'completed' => Constants::EVENT_ORDER_DELIVERED,
+ default => null,
+ };
- case 'completed':
- $event->track(Constants::EVENT_ORDER_DELIVERED, [
- 'object_parameters' => $orderAdapter->get(),
- ]);
- break;
+ if (!$action) {
+ return;
}
+
+ (new Event(Session::for_order($order)))->track($action, [
+ 'object_parameters' => (new OrderAdapter($order))->get(),
+ ]);
}
diff --git a/src/Events/ProductViewed.php b/src/Events/ProductViewed.php
index 5b92b59..4de7cbd 100644
--- a/src/Events/ProductViewed.php
+++ b/src/Events/ProductViewed.php
@@ -3,6 +3,7 @@
use Hellotext\Adapters\ProductAdapter;
use Hellotext\Api\Event;
use Hellotext\Constants;
+use Hellotext\Services\Session;
add_action('woocommerce_after_single_product', 'hellotext_product_viewed');
@@ -14,9 +15,13 @@
function hellotext_product_viewed(): void {
global $product;
+ if (!$product instanceof \WC_Product) {
+ return;
+ }
+
do_action('hellotext_create_profile');
- (new Event())->track(Constants::EVENT_PRODUCT_VIEWED, [
+ (new Event(Session::current()))->track(Constants::EVENT_PRODUCT_VIEWED, [
'object_parameters' => (new ProductAdapter($product))->get(),
]);
}
diff --git a/src/Events/RefundReceived.php b/src/Events/RefundReceived.php
index bcdc3f8..58dc960 100644
--- a/src/Events/RefundReceived.php
+++ b/src/Events/RefundReceived.php
@@ -10,20 +10,23 @@
/**
* Track refund creation.
*
+ * Refunds are issued from wp-admin or payment callbacks, so this only uses
+ * the session stored on the order.
+ *
* @param int $order_id Order ID.
* @param int $refund_id Refund ID.
* @return void
*/
function hellotext_refund_created(int $order_id, int $refund_id): void {
$order = wc_get_order($order_id);
- $refund = new WC_Order_Refund($refund_id);
- do_action('hellotext_create_profile', $order->get_user_id());
+ if (!$order instanceof \WC_Order) {
+ return;
+ }
- $encrypted_session = $order->get_meta(Constants::META_SESSION, true);
- $session = Session::decrypt($encrypted_session);
+ $refund = new \WC_Order_Refund($refund_id);
- (new Event($session))->track(Constants::EVENT_REFUND_RECEIVED, [
+ (new Event(Session::for_order($order)))->track(Constants::EVENT_REFUND_RECEIVED, [
'object_parameters' => (new RefundAdapter($refund, $order))->get(),
]);
}
diff --git a/src/Misc/Scripts.php b/src/Misc/Scripts.php
index 21c5d0e..38f2f98 100644
--- a/src/Misc/Scripts.php
+++ b/src/Misc/Scripts.php
@@ -1,36 +1,25 @@
-
- 'sanitize_text_field']);
+ register_setting('hellotext-form', Constants::OPTION_ACCESS_TOKEN, ['sanitize_callback' => 'sanitize_text_field']);
}
/**
@@ -113,112 +87,30 @@ function hellotext_access_token_field(): void {
' . __('webchat_unavailable', 'hellotext') . '
';
- return;
- }
-
- ?>
-
-
-
-
-
-
-
- Important: Please select any Permalink structure other than "Plain" in Settings > Permalinks. Otherwise, the plugin will not work.
-
- HTML;
- }
- ?>
+
+
+ Important: Please select any Permalink structure other than "Plain" in Settings > Permalinks. Otherwise, the plugin will not work.
+
+
user_id = $user_id;
- $this->session = isset($_COOKIE[Constants::SESSION_COOKIE_NAME])
- ? sanitize_text_field($_COOKIE[Constants::SESSION_COOKIE_NAME])
- : null;
- $this->client = Client::class;
$this->billing = $billing;
+ $this->session = $this->visitor_is_customer() ? Session::current() : null;
}
/**
@@ -76,139 +60,110 @@ public function __construct(?int $user_id, array $billing = []) {
* @return void
*/
public function process(): void {
- if (isset($this->billing) && !empty($this->billing)) {
- $this->create_hellotext_profile();
- $this->attach_profile_to_session();
+ if (!$this->user_id) {
+ if (!empty($this->billing)) {
+ $this->attach_profile_to_session($this->create_hellotext_profile());
+ }
+
return;
}
- if (! $this->user_id) {
+ if (!get_user_by('id', $this->user_id)) {
return;
}
- if (! $this->verify_if_profile_exists()) {
- $this->get_user();
- $this->create_hellotext_profile();
- $this->attach_profile_to_session();
- }
+ $profile_id = get_user_meta($this->user_id, Constants::META_PROFILE_ID, true);
- if ($this->session_changed()) {
- $this->attach_profile_to_session();
- }
- }
+ if (!$profile_id) {
+ $profile_id = $this->create_hellotext_profile();
- /**
- * Load the WordPress user by ID.
- *
- * @return void
- * @throws \Exception When user is not found.
- */
- private function get_user(): void {
- $this->user = get_user_by('id', $this->user_id);
+ if ($profile_id) {
+ update_user_meta($this->user_id, Constants::META_PROFILE_ID, $profile_id);
+ }
+ }
- if (!$this->user) {
- throw new \Exception("User with id {$this->user_id} not found");
+ if ($profile_id && $this->session && get_user_meta($this->user_id, Constants::META_SESSION, true) !== $this->session) {
+ $this->attach_profile_to_session($profile_id);
+ update_user_meta($this->user_id, Constants::META_SESSION, $this->session);
}
}
/**
- * Check if a Hellotext profile already exists.
+ * Create a Hellotext profile.
*
- * @return bool
+ * @return string|null The new profile ID.
*/
- private function verify_if_profile_exists(): bool {
- $hellotext_profile_id = get_user_meta($this->user_id ?? $this->session, Constants::META_PROFILE_ID, true);
+ public function create_hellotext_profile(): ?string {
+ $response = $this->client::post(Constants::API_ENDPOINT_PROFILES, array_filter([
+ 'session' => $this->session,
+ 'reference' => $this->user_id,
+ 'first_name' => $this->attribute('first_name'),
+ 'last_name' => $this->attribute('last_name'),
+ 'email' => $this->attribute('email'),
+ 'phone' => $this->attribute('phone'),
+ 'lists' => ['WooCommerce'],
+ ]));
- return false != $hellotext_profile_id && '' != $hellotext_profile_id;
- }
+ $profile_id = $response['body']['id'] ?? null;
- /**
- * Determine if the session value has changed.
- *
- * @return bool
- */
- private function session_changed(): bool {
- return get_user_meta($this->user_id, Constants::META_SESSION, true) != $this->session;
+ return is_scalar($profile_id) && '' !== (string) $profile_id ? (string) $profile_id : null;
}
/**
- * Create a Hellotext profile or reuse existing one.
+ * Read a customer attribute from the user account, falling back to
+ * their billing details.
*
- * @return void
+ * @param string $name Attribute name.
+ * @return string|null
*/
- public function create_hellotext_profile(): void {
- $profile = get_user_meta($this->user_id ?? $this->session, Constants::META_PROFILE_ID, true);
-
- if ($profile) {
- if (isset($this->user)) {
- update_user_meta($this->user->ID, Constants::META_PROFILE_ID, $profile);
+ private function attribute(string $name): ?string {
+ if ($this->user_id) {
+ $user = get_user_by('id', $this->user_id);
+ $value = match ($name) {
+ 'first_name' => $user->first_name ?? '',
+ 'last_name' => $user->last_name ?? '',
+ 'email' => $user->user_email ?? '',
+ default => '',
+ };
+
+ if ('' === $value) {
+ $value = (string) get_user_meta($this->user_id, "billing_{$name}", true);
}
-
- $this->client::patch(Constants::API_ENDPOINT_SESSIONS . "/{$this->session}", [
- 'session' => $this->session,
- 'profile' => $profile,
- ]);
-
- return;
+ } else {
+ $value = (string) ($this->billing[$name] ?? '');
}
- $phone = get_user_meta($this->user_id, 'billing_phone', true);
-
- $response = $this->client::post(Constants::API_ENDPOINT_PROFILES, array_filter([
- 'session' => $this->session,
- 'reference' => isset($this->user) ? $this->user->ID : null,
- 'first_name' => $this->user->nickname ?? $this->billing['first_name'],
- 'last_name' => $this->user->last_name ?? $this->billing['last_name'],
- 'email' => $this->user->user_email ?? $this->billing['email'],
- 'phone' => empty($phone) ? $this->billing['phone'] : $phone,
- 'lists' => ['WooCommerce'],
- ]));
-
- add_user_meta($this->user_id ?? $this->session, Constants::META_PROFILE_ID, $response['body']['id'], true);
-
- $this->client::patch(Constants::API_ENDPOINT_SESSIONS . "/{$this->session}", [
- 'session' => $this->session,
- 'profile' => $response['body']['id'],
- ]);
+ return '' === $value ? null : $value;
}
/**
* Attach profile ID to current session.
*
+ * @param string|null $profile_id Hellotext profile ID.
* @return void
*/
- private function attach_profile_to_session(): void {
- $profile_id = get_user_meta($this->user_id ?? $this->session, Constants::META_PROFILE_ID, true);
+ private function attach_profile_to_session(?string $profile_id): void {
+ if (!$profile_id || !$this->session) {
+ return;
+ }
- $response = $this->client::patch(Constants::API_ENDPOINT_SESSIONS . "/{$this->session}", [
+ $this->client::patch(Constants::API_ENDPOINT_SESSIONS . '/' . rawurlencode($this->session), [
'session' => $this->session,
'profile' => $profile_id,
]);
}
-}
-
-/**
- * Handle Hellotext profile creation action.
- *
- * @param mixed $payload Billing payload data.
- * @return void
- */
-add_action('hellotext_create_profile', function (mixed $payload = null): void {
- if (is_array($payload)) {
- (new CreateProfile(null, $payload))->process();
- }
- if (!is_user_logged_in() && !isset($user_id)) {
- return;
- }
-
- $user = (null != $user_id)
- ? get_user_by('id', $user_id)
- : wp_get_current_user();
+ /**
+ * Whether the browser making this request belongs to the customer.
+ *
+ * The cookie of a shop manager creating a user, or of anyone other than
+ * the customer, must not be linked to the customer's profile.
+ *
+ * @return bool
+ */
+ private function visitor_is_customer(): bool {
+ $current_user_id = get_current_user_id();
- if (!$user) {
- return;
+ return 0 === $current_user_id || $current_user_id === $this->user_id;
}
-
- (new CreateProfile($user->ID))->process();
-}, 10, 1);
+}
diff --git a/src/Services/Session.php b/src/Services/Session.php
index da85a0d..8c61b5a 100644
--- a/src/Services/Session.php
+++ b/src/Services/Session.php
@@ -7,11 +7,37 @@
/**
* Session
*
- * Handles encryption and decryption for session identifiers.
+ * Reads the Hellotext.js session and handles encryption and decryption for
+ * the copy stored on orders.
*
* @package Hellotext\Services
*/
class Session {
+ /**
+ * The session Hellotext.js set in the current visitor's browser.
+ *
+ * @return string|null
+ */
+ public static function current(): ?string {
+ $session = isset($_COOKIE[Constants::SESSION_COOKIE_NAME])
+ ? sanitize_text_field(wp_unslash($_COOKIE[Constants::SESSION_COOKIE_NAME]))
+ : '';
+
+ return '' === $session ? null : $session;
+ }
+
+ /**
+ * The session stored on an order at checkout.
+ *
+ * @param \WC_Order $order Order instance.
+ * @return string|null
+ */
+ public static function for_order(\WC_Order $order): ?string {
+ $session = self::decrypt($order->get_meta(Constants::META_SESSION, true));
+
+ return is_string($session) && '' !== $session ? $session : null;
+ }
+
/**
* Encrypt a session identifier.
*
@@ -38,10 +64,14 @@ public static function encrypt(?string $session = null): string {
/**
* Decrypt an encrypted session identifier.
*
- * @param string|null $encrypted_data Encrypted session data.
+ * @param mixed $encrypted_data Encrypted session data.
* @return string|false
*/
- public static function decrypt(?string $encrypted_data = null): string|false {
+ public static function decrypt(mixed $encrypted_data = null): string|false {
+ if (!is_string($encrypted_data) || '' === $encrypted_data) {
+ return false;
+ }
+
$key = get_option(Constants::OPTION_BUSINESS_ID);
// Use empty string as fallback if key is not set
@@ -49,7 +79,17 @@ public static function decrypt(?string $encrypted_data = null): string|false {
$key = '';
}
- $parts = explode('::', base64_decode($encrypted_data ?? ''));
+ $parts = explode('::', (string) base64_decode($encrypted_data), 2);
+
+ if (2 !== count($parts)) {
+ return false;
+ }
+
+ $iv_length = openssl_cipher_iv_length(Constants::ENCRYPTION_METHOD);
+
+ if (strlen($parts[1]) !== $iv_length) {
+ return false;
+ }
return openssl_decrypt($parts[0], Constants::ENCRYPTION_METHOD, $key, 0, $parts[1]);
}
diff --git a/tests/Mocks.php b/tests/Mocks.php
index 2967101..e86be27 100644
--- a/tests/Mocks.php
+++ b/tests/Mocks.php
@@ -37,6 +37,17 @@ function do_action($hook_name, ...$args)
}
}
+function has_action($hook_name, $callback = false)
+{
+ foreach ($GLOBALS['test_actions'][$hook_name] ?? [] as $action) {
+ if ($action['callback'] === $callback) {
+ return 10;
+ }
+ }
+
+ return false;
+}
+
function register_activation_hook($file, $callback)
{
$GLOBALS['test_activation_hook'] = $callback;
@@ -57,6 +68,62 @@ function sanitize_text_field($value)
return is_scalar($value) ? trim((string) $value) : '';
}
+function wp_unslash($value)
+{
+ return is_string($value) ? stripslashes($value) : $value;
+}
+
+function wp_json_encode($data)
+{
+ return json_encode($data);
+}
+
+function esc_url($url)
+{
+ return $url;
+}
+
+function admin_url($path = '')
+{
+ return 'https://example.test/wp-admin/' . ltrim($path, '/');
+}
+
+function wc_get_cart_url()
+{
+ return 'https://example.test/cart';
+}
+
+function wp_doing_ajax()
+{
+ return $GLOBALS['test_doing_ajax'] ?? false;
+}
+
+function wp_get_referer()
+{
+ return $GLOBALS['test_referer'] ?? false;
+}
+
+function current_user_can($capability)
+{
+ return true;
+}
+
+function wp_enqueue_script($handle, $src = '', $deps = [], $ver = false, $args = [])
+{
+ $GLOBALS['test_scripts'][$handle] = [
+ 'src' => $src,
+ 'ver' => $ver,
+ 'args' => $args,
+ ];
+}
+
+function wp_add_inline_script($handle, $data, $position = 'after')
+{
+ $GLOBALS['test_inline_scripts'][$handle][] = $data;
+
+ return true;
+}
+
function site_url($path = '')
{
return 'https://example.test' . $path;
@@ -74,7 +141,7 @@ function add_query_arg($args = [], $url = '')
function wp_get_post_terms($post_id, $taxonomy, $args = [])
{
- return [];
+ return $GLOBALS['test_post_terms'][$post_id][$taxonomy] ?? [];
}
function wp_get_attachment_url($attachment_id)
@@ -193,7 +260,7 @@ function submit_button()
function get_current_user_id()
{
- return 1;
+ return $GLOBALS['test_current_user_id'] ?? 1;
}
function get_bloginfo($show = '')
@@ -324,12 +391,69 @@ function WC()
if (!isset($GLOBALS['test_woocommerce'])) {
$GLOBALS['test_woocommerce'] = (object) [
'cart' => new WC_Cart(),
+ 'session' => new WC_Session(),
];
}
return $GLOBALS['test_woocommerce'];
}
+class WC_Session
+{
+ public array $data = [];
+
+ public function get($key, $default = null)
+ {
+ return $this->data[$key] ?? $default;
+ }
+
+ public function set($key, $value)
+ {
+ $this->data[$key] = $value;
+ }
+}
+
+class Test_WPDB
+{
+ public string $prefix = 'wp_';
+ public array $tables = ['wp_woocommerce_api_keys' => []];
+
+ public function prepare($query, ...$args)
+ {
+ return vsprintf(str_replace('%s', "'%s'", $query), $args);
+ }
+
+ public function get_var($query)
+ {
+ foreach (array_keys($this->tables) as $table) {
+ if (str_contains($query, "'{$table}'")) {
+ return $table;
+ }
+ }
+
+ return null;
+ }
+
+ public function insert($table, $data)
+ {
+ $this->tables[$table][] = $data;
+
+ return 1;
+ }
+
+ public function delete($table, $where)
+ {
+ $this->tables[$table] = array_values(array_filter(
+ $this->tables[$table] ?? [],
+ fn ($row) => array_intersect_assoc($where, $row) !== $where
+ ));
+
+ return 1;
+ }
+}
+
+$GLOBALS['wpdb'] = new Test_WPDB();
+
// Mock WooCommerce wc_create_order function
function wc_create_order($args = array())
{
@@ -380,15 +504,6 @@ function get_woocommerce_currency()
class WC_Order
{
- public $data = [
- 'billing' => [
- 'first_name' => 'Jane',
- 'last_name' => 'Doe',
- 'email' => 'jane@example.test',
- 'phone' => '+15555550100',
- ],
- ];
-
private array $meta_data = [];
public function get_id()
@@ -398,7 +513,32 @@ public function get_id()
public function get_user_id()
{
- return 1;
+ return $GLOBALS['test_order_user_id'] ?? 1;
+ }
+
+ public function get_status()
+ {
+ return $GLOBALS['test_order_status'] ?? 'processing';
+ }
+
+ public function has_status($status)
+ {
+ return in_array($this->get_status(), (array) $status, true);
+ }
+
+ public function is_paid()
+ {
+ return $this->has_status(['processing', 'completed']);
+ }
+
+ public function get_address($type = 'billing')
+ {
+ return [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'email' => 'jane@example.test',
+ 'phone' => '+15555550100',
+ ];
}
public function get_total()
@@ -438,6 +578,11 @@ public function save()
return true;
}
+ public function save_meta_data()
+ {
+ $GLOBALS['test_saved_orders'][$this->get_id()] = true;
+ }
+
public function add_meta_data($key, $value, $unique = false)
{
$this->meta_data[$key] = $value;
@@ -511,9 +656,19 @@ public function get_quantity()
return $this->quantity;
}
+ public function get_variation_id()
+ {
+ return 0;
+ }
+
+ public function get_name()
+ {
+ return 'Deleted product';
+ }
+
public function get_product()
{
- return new WC_Product();
+ return $GLOBALS['test_order_item_product'] ?? new WC_Product();
}
}
@@ -539,6 +694,16 @@ public function get_id()
return 1;
}
+ public function get_parent_id()
+ {
+ return $GLOBALS['test_product_parent_id'] ?? 0;
+ }
+
+ public function get_permalink()
+ {
+ return 'https://example.test/product/' . $this->get_id();
+ }
+
// getters
public function get_name()
@@ -608,6 +773,7 @@ class User
{
public $ID;
public string $nickname = '';
+ public string $first_name = '';
public string $last_name = '';
public string $user_email = '';
diff --git a/tests/Unit/Api/ClientTest.php b/tests/Unit/Api/ClientTest.php
index 8dbe7e1..9db3b0f 100644
--- a/tests/Unit/Api/ClientTest.php
+++ b/tests/Unit/Api/ClientTest.php
@@ -7,37 +7,19 @@
if (!defined('ABSPATH')) {
define('ABSPATH', '/tmp/wordpress/');
}
-
- global $HELLOTEXT_API_URL;
- $HELLOTEXT_API_URL = 'https://api.hellotext.com';
-});
-
-test('Client has correct API version suffix by default', function () {
- expect(Client::$sufix)->toBe('/v1');
-});
-
-test('can set custom suffix', function () {
- Client::with_sufix('/v2');
- expect(Client::$sufix)->toBe('/v2');
-
- // Reset
- Client::with_sufix('/v1');
});
-test('custom suffix adds leading slash if missing', function () {
- Client::with_sufix('v3');
- expect(Client::$sufix)->toBe('/v3');
+test('sends requests to the Hellotext API', function () {
+ $response = Client::get(Constants::API_ENDPOINT_PROFILES);
- // Reset
- Client::with_sufix('/v1');
+ expect($response['request']['path'])->toBe('https://api.hellotext.com/v1/profiles');
});
-test('empty suffix works', function () {
- Client::with_sufix('');
- expect(Client::$sufix)->toBe('');
+test('keeps each endpoint on its own path', function () {
+ Client::post(Constants::API_ENDPOINT_INTEGRATIONS_WOO);
+ $response = Client::post(Constants::API_ENDPOINT_PROFILES);
- // Reset
- Client::with_sufix('/v1');
+ expect($response['request']['path'])->toBe('https://api.hellotext.com/v1/profiles');
});
test('Client::get() returns expected structure', function () {
@@ -94,12 +76,6 @@
expect($response['request']['data'])->toBe($data);
});
-test('with_sufix returns Client instance', function () {
- $client = Client::with_sufix('/custom');
-
- expect($client)->toBeInstanceOf(Client::class);
-});
-
test('all HTTP methods accept optional data parameter', function () {
// These should not throw errors
expect(fn() => Client::get('/test', null))->not->toThrow(Error::class);
@@ -128,10 +104,4 @@
expect(method_exists(Client::class, 'patch'))->toBeTrue();
expect(method_exists(Client::class, 'delete'))->toBeTrue();
expect(method_exists(Client::class, 'request'))->toBeTrue();
- expect(method_exists(Client::class, 'with_sufix'))->toBeTrue();
-});
-
-test('Client has API_VERSION constant reference', function () {
- expect(defined('Hellotext\Constants::API_VERSION'))->toBeTrue();
- expect(Constants::API_VERSION)->toBe('v1');
});
diff --git a/tests/Unit/Events/EventTrackingTest.php b/tests/Unit/Events/EventTrackingTest.php
index 18b30cf..eb8ee5e 100644
--- a/tests/Unit/Events/EventTrackingTest.php
+++ b/tests/Unit/Events/EventTrackingTest.php
@@ -9,11 +9,19 @@
$GLOBALS['test_saved_orders'] = [];
$GLOBALS['test_coupon_valid'] = true;
$GLOBALS['test_is_user_logged_in'] = true;
+ $GLOBALS['test_current_user_id'] = 1;
$GLOBALS['test_users'] = [];
$GLOBALS['test_user_meta'] = [];
+ $GLOBALS['test_options'] = [];
+ $GLOBALS['test_doing_ajax'] = false;
+ $GLOBALS['test_referer'] = false;
+ $GLOBALS['test_order_status'] = 'processing';
+ $GLOBALS['test_order_user_id'] = 1;
+ $GLOBALS['test_post_terms'] = [];
+ $GLOBALS['wpdb']->tables = ['wp_woocommerce_api_keys' => []];
+ unset($GLOBALS['test_actions']['shutdown'], $GLOBALS['test_woocommerce'], $GLOBALS['test_product_parent_id'], $GLOBALS['test_order_item_product']);
$GLOBALS['wp'] = (object) ['request' => 'cart'];
$_COOKIE = [];
- $_SESSION = [];
$GLOBALS['test_wp_remote_request'] = function ($url, $args = []) {
$GLOBALS['test_http_requests'][] = [
@@ -38,11 +46,56 @@ function tracked_request(int $index = -1): array
function tracked_event(int $index = -1): array
{
- $requests = $GLOBALS['test_http_requests'];
+ return tracked_request($index)['body'];
+}
+
+function tracked_actions(): array
+{
+ return array_values(array_filter(array_map(
+ fn ($request) => $request['body']['action'] ?? null,
+ $GLOBALS['test_http_requests']
+ )));
+}
+
+function a_registered_user(int $id): WP_User
+{
+ $user = new WP_User($id);
+ $user->nickname = 'janed';
+ $user->first_name = 'Jane';
+ $user->last_name = 'Doe';
+ $user->user_email = 'jane@example.test';
+ $GLOBALS['test_users'][$user->ID] = $user;
+
+ return $user;
+}
- return $requests[$index < 0 ? count($requests) + $index : $index]['body'];
+function a_cart_with(int $quantity): void
+{
+ WC()->cart->items = $quantity > 0
+ ? ['abc' => ['data' => new WC_Product(), 'quantity' => $quantity]]
+ : [];
}
+test('loads the latest unversioned Hellotext.js with only the business ID', function () {
+ $GLOBALS['test_scripts'] = [];
+ $GLOBALS['test_inline_scripts'] = [];
+
+ hellotext_enqueue_script();
+
+ expect($GLOBALS['test_scripts']['hellotext']['src'])->toBe('https://cdn.jsdelivr.net/npm/@hellotext/hellotext/dist/hellotext.min.js')
+ ->and($GLOBALS['test_scripts']['hellotext']['ver'])->toBeNull()
+ ->and($GLOBALS['test_inline_scripts']['hellotext'])->toBe(['Hellotext.initialize("test_business_123");']);
+});
+
+test('does not load Hellotext.js before the business ID is set', function () {
+ $GLOBALS['test_scripts'] = [];
+ $GLOBALS['test_options'][Constants::OPTION_BUSINESS_ID] = '';
+
+ hellotext_enqueue_script();
+
+ expect($GLOBALS['test_scripts'])->toBe([]);
+});
+
test('tracks product viewed events', function () {
global $product;
@@ -58,44 +111,53 @@ function tracked_event(int $index = -1): array
->and($event['session'])->toBe('browser-session')
->and($event['object_parameters']['reference'])->toBe(1)
->and($event['object_parameters']['source'])->toBe('woo')
- ->and($request['url'])->toEndWith(Constants::API_ENDPOINT_TRACK)
- ->and($request['args']['headers']['Content-Type'])->toBe('application/json')
+ ->and($request['url'])->toBe('https://api.hellotext.com/v1/track/events')
->and($request['args']['headers']['Authorization'])->toBe('Bearer test_business_123');
});
+test('reads variation categories from the parent product', function () {
+ global $product;
+
+ $GLOBALS['test_product_parent_id'] = 7;
+ $GLOBALS['test_post_terms'][7]['product_cat'] = ['Shoes'];
+ $product = new WC_Product();
+
+ do_action('woocommerce_after_single_product');
+
+ expect(tracked_event()['object_parameters']['categories'])->toBe(['Shoes']);
+});
+
test('tracks valid coupon redemption only', function () {
do_action('woocommerce_applied_coupon', 'SAVE10');
$event = tracked_event();
expect($event['action'])->toBe(Constants::EVENT_COUPON_REDEEMED)
- ->and($event['object_parameters']['type'])->toBe('coupon')
->and($event['object_parameters']['code'])->toBe('SAVE10')
->and($event['object_parameters']['destination_url'])->toBe('https://example.test/cart');
$GLOBALS['test_http_requests'] = [];
- $GLOBALS['test_coupon_valid'] = false;
+ $GLOBALS['test_coupon_valid'] = new WP_Error('Coupon expired');
do_action('woocommerce_applied_coupon', 'EXPIRED');
expect($GLOBALS['test_http_requests'])->toBe([]);
});
-test('tracks cart added and removed events from cart hooks', function () {
+test('tracks cart changes once at the end of the request', function () {
$_COOKIE[Constants::SESSION_COOKIE_NAME] = 'cart-session';
- $GLOBALS['test_is_user_logged_in'] = false;
- $_SESSION[Constants::SESSION_CART_ITEMS] = json_encode([]);
- WC()->cart->items = [
- 'abc' => [
- 'data' => new WC_Product(),
- 'quantity' => 2,
- ],
- ];
+ $GLOBALS['test_current_user_id'] = 0;
+ a_cart_with(2);
do_action('woocommerce_add_to_cart');
+ do_action('woocommerce_add_to_cart');
+
+ expect($GLOBALS['test_http_requests'])->toBe([]);
+
+ do_action('shutdown');
$added = tracked_event();
- expect($added['action'])->toBe(Constants::EVENT_CART_ADDED)
+ expect(tracked_actions())->toBe([Constants::EVENT_CART_ADDED])
->and($added['session'])->toBe('cart-session')
->and($added['amount'])->toBe(50)
->and($added['currency'])->toBe('USD')
@@ -104,76 +166,135 @@ function tracked_event(int $index = -1): array
->and($added['object_parameters']['items'][0]['product']['reference'])->toBe(1);
$GLOBALS['test_http_requests'] = [];
- WC()->cart->items = [];
+ a_cart_with(0);
do_action('woocommerce_cart_item_removed');
+ do_action('shutdown');
$removed = tracked_event();
expect($removed['action'])->toBe(Constants::EVENT_CART_REMOVED)
- ->and($removed['session'])->toBe('cart-session')
- ->and($removed['object_parameters']['items'][0]['quantity'])->toBe(2)
- ->and($removed['object_parameters']['items'][0]['product']['reference'])->toBe(1);
+ ->and($removed['object_parameters']['items'][0]['quantity'])->toBe(2);
});
-test('tracks cart quantity increases and ignores unchanged carts', function () {
- $_COOKIE[Constants::SESSION_COOKIE_NAME] = 'cart-session';
- $GLOBALS['test_is_user_logged_in'] = false;
- $_SESSION[Constants::SESSION_CART_ITEMS] = json_encode([
- [
- 'product' => [
- 'reference' => 1,
- 'source' => 'woo',
- ],
- 'quantity' => 1,
- ],
- ]);
- WC()->cart->items = [
- 'abc' => [
- 'data' => new WC_Product(),
- 'quantity' => 3,
- ],
- ];
-
- do_action('woocommerce_after_cart_item_quantity_update');
-
- expect(tracked_event()['action'])->toBe(Constants::EVENT_CART_ADDED);
+test('reports only the quantity that changed and ignores unchanged carts', function () {
+ $GLOBALS['test_current_user_id'] = 0;
+ a_cart_with(1);
+ hellotext_cart_updated();
+ $GLOBALS['test_http_requests'] = [];
+
+ a_cart_with(3);
+ hellotext_cart_updated();
+
+ expect(tracked_event()['action'])->toBe(Constants::EVENT_CART_ADDED)
+ ->and(tracked_event()['object_parameters']['items'][0]['quantity'])->toBe(2);
+
+ $GLOBALS['test_http_requests'] = [];
+
+ hellotext_cart_updated();
+
+ expect($GLOBALS['test_http_requests'])->toBe([]);
+});
+test('does not report purchased items as removed when the cart is emptied', function () {
+ $GLOBALS['test_current_user_id'] = 0;
+ a_cart_with(2);
+ hellotext_cart_updated();
$GLOBALS['test_http_requests'] = [];
+ a_cart_with(0);
+ do_action('woocommerce_cart_emptied');
hellotext_cart_updated();
expect($GLOBALS['test_http_requests'])->toBe([]);
});
-test('tracks order placed and stores encrypted session with order CRUD', function () {
+test('reports the page a background add to cart came from', function () {
+ $GLOBALS['test_current_user_id'] = 0;
+ $GLOBALS['test_doing_ajax'] = true;
+ $GLOBALS['test_referer'] = 'https://example.test/shop';
+ a_cart_with(1);
+
+ hellotext_cart_updated();
+
+ expect(tracked_event()['url'])->toBe('https://example.test/shop');
+});
+
+test('tracks order placed with the session captured at checkout', function () {
$_COOKIE[Constants::SESSION_COOKIE_NAME] = 'checkout-session';
$order = new WC_Order();
- hellotext_order_placed($order);
+ do_action('woocommerce_store_api_checkout_order_processed', $order);
+
+ expect(tracked_actions())->toBe([]);
+
+ $_COOKIE = [];
+ do_action('woocommerce_thankyou', $order->get_id());
$event = tracked_event();
- $encrypted_session = $order->get_meta(Constants::META_SESSION, true);
expect($event['action'])->toBe(Constants::EVENT_ORDER_PLACED)
->and($event['session'])->toBe('checkout-session')
->and($event['object_parameters']['reference'])->toBe(1)
- ->and(Session::decrypt($encrypted_session))->toBe('checkout-session')
- ->and($GLOBALS['test_saved_orders'][1])->toBeTrue();
+ ->and(Session::decrypt($order->get_meta(Constants::META_SESSION, true)))->toBe('checkout-session');
});
-test('does not track order placed twice for the same order', function () {
+test('tracks order placed once per order', function () {
+ $order = new WC_Order();
+ hellotext_capture_checkout($order);
+
+ do_action('woocommerce_thankyou', $order->get_id());
+ do_action('woocommerce_thankyou', $order->get_id());
+ do_action('woocommerce_order_status_changed', $order->get_id(), 'pending', 'completed', wc_get_order($order->get_id()));
+
+ expect(array_count_values(tracked_actions())[Constants::EVENT_ORDER_PLACED])->toBe(1);
+});
+
+test('tracks order placed when an unpaid order is paid later', function () {
$_COOKIE[Constants::SESSION_COOKIE_NAME] = 'checkout-session';
+ $GLOBALS['test_order_status'] = 'pending';
$order = new WC_Order();
- hellotext_order_placed($order);
- hellotext_order_placed($order);
+ do_action('woocommerce_checkout_order_processed', $order->get_id(), [], $order);
+ do_action('woocommerce_thankyou', $order->get_id());
- expect($GLOBALS['test_http_requests'])->toHaveCount(1)
- ->and(tracked_event()['action'])->toBe(Constants::EVENT_ORDER_PLACED)
- ->and(Session::decrypt($order->get_meta(Constants::META_SESSION, true)))->toBe('checkout-session');
+ expect(tracked_actions())->toBe([]);
+
+ $_COOKIE[Constants::SESSION_COOKIE_NAME] = 'shop-manager-session';
+ $GLOBALS['test_order_status'] = 'processing';
+ do_action('woocommerce_order_status_changed', $order->get_id(), 'pending', 'processing', $order);
+
+ expect(tracked_actions())->toBe([Constants::EVENT_ORDER_PLACED, Constants::EVENT_ORDER_CONFIRMED])
+ ->and(tracked_event(-2)['session'])->toBe('checkout-session')
+ ->and(tracked_event(-1)['session'])->toBe('checkout-session');
+});
+
+test('does not track order placed again for orders tracked by earlier versions', function () {
+ $order = new WC_Order();
+ $order->update_meta_data(Constants::META_SESSION, Session::encrypt('legacy-session'));
+
+ do_action('woocommerce_thankyou', $order->get_id());
+ do_action('woocommerce_order_status_changed', $order->get_id(), 'processing', 'completed', $order);
+
+ expect(tracked_actions())->toBe([Constants::EVENT_ORDER_DELIVERED]);
+});
+
+test('creates a guest profile from the billing details at checkout', function () {
+ $_COOKIE[Constants::SESSION_COOKIE_NAME] = 'guest-session';
+ $GLOBALS['test_current_user_id'] = 0;
+ $GLOBALS['test_order_user_id'] = 0;
+
+ hellotext_capture_checkout(new WC_Order());
+
+ expect(tracked_request(0)['url'])->toBe('https://api.hellotext.com/v1/profiles')
+ ->and(tracked_request(0)['body']['email'])->toBe('jane@example.test')
+ ->and(tracked_request(0)['body']['phone'])->toBe('+15555550100')
+ ->and(tracked_request(1)['url'])->toBe('https://api.hellotext.com/v1/sessions/guest-session')
+ ->and(tracked_request(1)['body']['profile'])->toBe('profile_123');
});
-test('tracks supported order status transitions with stored session', function (string $status, string $action) {
+test('tracks supported order status transitions with the stored session', function (string $status, string $action) {
+ $_COOKIE[Constants::SESSION_COOKIE_NAME] = 'shop-manager-session';
+ $GLOBALS['test_order_status'] = $status;
$order = new WC_Order();
$order->update_meta_data(Constants::META_SESSION, Session::encrypt('stored-session'));
@@ -181,24 +302,43 @@ function tracked_event(int $index = -1): array
$event = tracked_event();
- expect($event['action'])->toBe($action)
+ expect($GLOBALS['test_http_requests'])->toHaveCount(1)
+ ->and($event['action'])->toBe($action)
->and($event['session'])->toBe('stored-session')
->and($event['object_parameters']['reference'])->toBe(1);
})->with([
- 'processing' => ['processing', Constants::EVENT_ORDER_CONFIRMED],
- 'cancelled' => ['cancelled', Constants::EVENT_ORDER_CANCELLED],
- 'completed' => ['completed', Constants::EVENT_ORDER_DELIVERED],
- ]);
+ 'processing' => ['processing', Constants::EVENT_ORDER_CONFIRMED],
+ 'cancelled' => ['cancelled', Constants::EVENT_ORDER_CANCELLED],
+ 'completed' => ['completed', Constants::EVENT_ORDER_DELIVERED],
+]);
+
+test('never sends the shop manager session for orders without one', function () {
+ $_COOKIE[Constants::SESSION_COOKIE_NAME] = 'shop-manager-session';
+ $order = new WC_Order();
+
+ do_action('woocommerce_order_status_changed', $order->get_id(), 'pending', 'completed', $order);
+
+ expect(tracked_event()['session'])->toBeNull();
+});
test('does not track unsupported order status transitions', function () {
+ $GLOBALS['test_order_status'] = 'on-hold';
$order = new WC_Order();
- $order->update_meta_data(Constants::META_SESSION, Session::encrypt('stored-session'));
do_action('woocommerce_order_status_changed', $order->get_id(), 'pending', 'on-hold', $order);
expect($GLOBALS['test_http_requests'])->toBe([]);
});
+test('tracks orders whose products were deleted', function () {
+ $GLOBALS['test_order_item_product'] = false;
+ $order = new WC_Order();
+
+ do_action('woocommerce_order_status_changed', $order->get_id(), 'processing', 'completed', $order);
+
+ expect(tracked_event()['object_parameters']['items'][0]['product']['name'])->toBe('Deleted product');
+});
+
test('tracks refunds with the order session', function () {
$order = new WC_Order();
$order->update_meta_data(Constants::META_SESSION, Session::encrypt('refund-session'));
@@ -207,19 +347,16 @@ function tracked_event(int $index = -1): array
$event = tracked_event();
- expect($event['action'])->toBe(Constants::EVENT_REFUND_RECEIVED)
+ expect($GLOBALS['test_http_requests'])->toHaveCount(1)
+ ->and($event['action'])->toBe(Constants::EVENT_REFUND_RECEIVED)
->and($event['session'])->toBe('refund-session')
- ->and($event['object_parameters']['reference'])->toBe(1)
->and($event['object_parameters']['source'])->toBe('woo');
});
-test('creates a profile when a user registration hook fires', function () {
+test('creates a profile when a visitor registers', function () {
$_COOKIE[Constants::SESSION_COOKIE_NAME] = 'registration-session';
- $user = new WP_User(42);
- $user->nickname = 'Jane';
- $user->last_name = 'Doe';
- $user->user_email = 'jane@example.test';
- $GLOBALS['test_users'][$user->ID] = $user;
+ $GLOBALS['test_current_user_id'] = 0;
+ $user = a_registered_user(42);
$GLOBALS['test_user_meta'][$user->ID]['billing_phone'] = '+15555550100';
do_action('user_register', $user->ID);
@@ -227,29 +364,100 @@ function tracked_event(int $index = -1): array
$profile_request = tracked_request(0);
$session_request = tracked_request(1);
- expect($profile_request['url'])->toEndWith('/profiles')
+ expect($profile_request['url'])->toBe('https://api.hellotext.com/v1/profiles')
->and($profile_request['args']['method'])->toBe('POST')
->and($profile_request['args']['headers']['Authorization'])->toBe('Bearer test_token_123')
- ->and($profile_request['args']['headers']['Content-Type'])->toBe('application/json')
->and($profile_request['args']['headers']['X-Plugin-Version'])->toBe('1.3.1')
->and($profile_request['body']['session'])->toBe('registration-session')
->and($profile_request['body']['reference'])->toBe(42)
+ ->and($profile_request['body']['first_name'])->toBe('Jane')
->and($profile_request['body']['email'])->toBe('jane@example.test')
+ ->and($profile_request['body']['phone'])->toBe('+15555550100')
->and($GLOBALS['test_user_meta'][$user->ID][Constants::META_PROFILE_ID])->toBe('profile_123')
- ->and($session_request['url'])->toEndWith('/sessions/registration-session')
+ ->and($session_request['url'])->toBe('https://api.hellotext.com/v1/sessions/registration-session')
->and($session_request['args']['method'])->toBe('PATCH')
->and($session_request['body']['profile'])->toBe('profile_123');
});
-test('deactivation removes the Hellotext WooCommerce integration', function () {
- hellotext_deactivate('business_456');
+test('does not link a shop manager session to users they create', function () {
+ $_COOKIE[Constants::SESSION_COOKIE_NAME] = 'shop-manager-session';
+ $GLOBALS['test_current_user_id'] = 1;
+ $user = a_registered_user(42);
+
+ do_action('user_register', $user->ID);
+
+ expect($GLOBALS['test_http_requests'])->toHaveCount(1)
+ ->and(tracked_request(0)['body'])->not->toHaveKey('session');
+});
+
+test('links a signed-in customer session once', function () {
+ global $product;
+
+ $_COOKIE[Constants::SESSION_COOKIE_NAME] = 'customer-session';
+ $user = a_registered_user(1);
+ $GLOBALS['test_user_meta'][$user->ID][Constants::META_PROFILE_ID] = 'profile_123';
+ $product = new WC_Product();
+
+ do_action('woocommerce_after_single_product');
+ do_action('woocommerce_after_single_product');
+
+ $session_requests = array_filter(
+ $GLOBALS['test_http_requests'],
+ fn ($request) => str_ends_with($request['url'], '/v1/sessions/customer-session')
+ );
+
+ expect($session_requests)->toHaveCount(1);
+});
+
+test('connects once both settings are saved and replaces the API key', function () {
+ $GLOBALS['wpdb']->tables['wp_woocommerce_api_keys'][] = ['description' => 'Hellotext', 'consumer_key' => 'old'];
+
+ do_action('update_option_' . Constants::OPTION_BUSINESS_ID, '', 'test_business_123', Constants::OPTION_BUSINESS_ID);
+ do_action('update_option_' . Constants::OPTION_ACCESS_TOKEN, 'old_token', 'test_token_123', Constants::OPTION_ACCESS_TOKEN);
+
+ expect($GLOBALS['test_http_requests'])->toBe([]);
+ do_action('shutdown');
+
+ $keys = $GLOBALS['wpdb']->tables['wp_woocommerce_api_keys'];
$request = tracked_request();
- expect($request['url'])->toEndWith('/integrations/woo')
+ expect($GLOBALS['test_http_requests'])->toHaveCount(1)
+ ->and($request['url'])->toBe('https://api.hellotext.com/integrations/woo')
+ ->and($request['args']['method'])->toBe('POST')
+ ->and($request['body']['shop']['consumer_key'])->toBe('ck_testhash')
+ ->and($keys)->toHaveCount(1)
+ ->and($keys[0]['consumer_key'])->toBe('hashed_ck_testhash')
+ ->and($keys[0]['permissions'])->toBe('read_write');
+});
+
+test('removes the previous business integration when the business ID changes', function () {
+ do_action('update_option_' . Constants::OPTION_BUSINESS_ID, 'business_456', 'test_business_123', Constants::OPTION_BUSINESS_ID);
+ do_action('shutdown');
+
+ expect(array_map(fn ($request) => $request['args']['method'], $GLOBALS['test_http_requests']))->toBe(['DELETE', 'POST'])
+ ->and(tracked_request(0)['body']['shop']['business_id'])->toBe('business_456');
+});
+
+test('deactivation removes the integration and revokes its API key', function () {
+ $GLOBALS['wpdb']->tables['wp_woocommerce_api_keys'][] = ['description' => 'Hellotext'];
+ $GLOBALS['wpdb']->tables['wp_woocommerce_api_keys'][] = ['description' => 'Other app'];
+
+ hellotext_deactivate();
+
+ $request = tracked_request();
+
+ expect($request['url'])->toBe('https://api.hellotext.com/integrations/woo')
->and($request['args']['method'])->toBe('DELETE')
->and($request['args']['headers']['Authorization'])->toBe('Bearer test_token_123')
- ->and($request['args']['headers']['Content-Type'])->toBe('application/json')
- ->and($request['args']['headers']['X-Plugin-Version'])->toBe('1.3.1')
- ->and($request['body']['shop']['business_id'])->toBe('business_456');
+ ->and($request['body']['shop']['business_id'])->toBe('test_business_123')
+ ->and($GLOBALS['wpdb']->tables['wp_woocommerce_api_keys'])->toBe([['description' => 'Other app']]);
+});
+
+test('uninstall removes every option, including the old webchat settings', function () {
+ $GLOBALS['test_options']['hellotext_webchat_id'] = 'webchat_1';
+
+ hellotext_uninstall();
+
+ expect($GLOBALS['test_options'])->toBe([]);
});
diff --git a/tests/Unit/Services/CreateProfileTest.php b/tests/Unit/Services/CreateProfileTest.php
index d00120b..27d297e 100644
--- a/tests/Unit/Services/CreateProfileTest.php
+++ b/tests/Unit/Services/CreateProfileTest.php
@@ -23,9 +23,11 @@
$this->user = TestHelper::find_or_create_user();
});
-test('throws an exception if the user does not exist', function () {
- $this->expectException(\Exception::class);
- (new CreateProfile(999))->process();
+test('does nothing if the user does not exist', function () {
+ $service = Mockery::mock('Hellotext\Services\CreateProfile[create_hellotext_profile]', array( 999 ))->makePartial();
+ $service->shouldNotReceive('create_hellotext_profile');
+
+ $service->process();
});
test('does call the create_hellotext_profile function if the user id is not present but billing is', function () use ($billingInfo) {
From 05107a4283681468064304ce54d2c29ac77db53a Mon Sep 17 00:00:00 2001
From: rockwellll
Date: Fri, 6 Nov 2026 19:19:58 -0300
Subject: [PATCH 2/4] feat: report page sections and show the Smart Alert
After Hellotext.initialize(), set Hellotext.page.section from the
WooCommerce page (front page: homepage; shop and product taxonomies:
product_collection; product: product_details), so every event and
Webchat message reports it, and call Hellotext.alert.show() for that
section, as the Shopify and VTEX integrations do.
The Smart Alert subscribes visitors to Push, which needs a service worker
on the store's origin. Ship the worker from the Hellotext.js Push guide
in assets/ and pass its path as push.serviceWorkerUrl. It registers under
the plugin's folder, so it never replaces a worker the store runs at the
site root. CI checks the release package includes it.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/php.yml | 1 +
API.md | 15 ++++++-
DEVELOPMENT.md | 2 +
README.md | 2 +-
assets/hellotext-service-worker.js | 59 +++++++++++++++++++++++++
changelog.txt | 2 +
docs/WOOCOMMERCE-AUDIT.md | 2 +-
src/Misc/Scripts.php | 53 ++++++++++++++++++++--
tests/Mocks.php | 30 +++++++++++++
tests/Unit/Events/EventTrackingTest.php | 28 ++++++++++--
10 files changed, 185 insertions(+), 9 deletions(-)
create mode 100644 assets/hellotext-service-worker.js
diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
index 578255e..2fb8cc1 100644
--- a/.github/workflows/php.yml
+++ b/.github/workflows/php.yml
@@ -95,6 +95,7 @@ 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
for path in \
release/hellotext-wordpress/tests \
diff --git a/API.md b/API.md
index b91f34c..a82b829 100644
--- a/API.md
+++ b/API.md
@@ -407,7 +407,7 @@ The plugin registers these WooCommerce and WordPress hooks:
| `user_register` | `hellotext_user_registered` | Track customer registration/profile flow |
| `add_option_*` / `update_option_*` (Business ID, Access Token) | `hellotext_schedule_connect`, `hellotext_business_id_updated` | Connect the store once both settings are saved; remove the previous business's integration first when the Business ID changes |
| `admin_init` | `hellotext_settings_init` | Register plugin settings |
-| `wp_enqueue_scripts` | `hellotext_enqueue_script` | Load the latest Hellotext.js on the storefront |
+| `wp_enqueue_scripts` | `hellotext_enqueue_script` | Load the latest Hellotext.js on the storefront with the bundled Push worker, set `Hellotext.page.section`, and show the Smart Alert for that section |
## WooCommerce Compatibility
@@ -461,6 +461,19 @@ The plugin automatically hooks into these WooCommerce actions:
- `woocommerce_applied_coupon` - Track coupon usage
- `wp_enqueue_scripts` - Load Hellotext.js. Webchat, popups and other widgets are configured from Hellotext
+### Page sections
+
+After `Hellotext.initialize()`, the plugin sets `Hellotext.page.section`, so every event of the page reports it, Webchat messages included, and calls `Hellotext.alert.show(section)`. Hellotext shows the alert only for the sections enabled in the Smart Alert playbook.
+
+| 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 |
+
+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.
+
## Testing
For testing information, see [DEVELOPMENT.md](DEVELOPMENT.md).
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 2fad14a..eb5f179 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -340,6 +340,8 @@ add_action('all', function($hook) {
- [ ] Smoke test the plugin zip in a clean WordPress/WooCommerce site.
- [ ] 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`.
- [ ] 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.
- [ ] 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.
diff --git a/README.md b/README.md
index 13c93e1..ea07afa 100644
--- a/README.md
+++ b/README.md
@@ -91,7 +91,7 @@ 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 and other tools you set up in Hellotext appear without further changes in WordPress.
+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
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/changelog.txt b/changelog.txt
index 91d05fb..a2b9e88 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -4,6 +4,8 @@ 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.
* 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.
diff --git a/docs/WOOCOMMERCE-AUDIT.md b/docs/WOOCOMMERCE-AUDIT.md
index d0d0321..b4be371 100644
--- a/docs/WOOCOMMERCE-AUDIT.md
+++ b/docs/WOOCOMMERCE-AUDIT.md
@@ -41,7 +41,7 @@ Do not declare HPOS compatibility yet. Add the declaration only after a manual r
| 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. Verify rendered script manually in a real WordPress page during release smoke testing. |
+| 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. 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
diff --git a/src/Misc/Scripts.php b/src/Misc/Scripts.php
index 38f2f98..019bd05 100644
--- a/src/Misc/Scripts.php
+++ b/src/Misc/Scripts.php
@@ -7,8 +7,10 @@
/**
* Load Hellotext.js on the storefront.
*
- * Webchat, popups and every other widget are configured from Hellotext,
- * so the store only passes its business ID.
+ * Webchat, popups and every other widget are configured from Hellotext. The
+ * store passes its business ID and the Push service worker, and tells
+ * Hellotext.js which section the page is, so events and Webchat messages
+ * report it and the Smart Alert shows the matching section.
*
* @return void
*/
@@ -19,7 +21,52 @@ function hellotext_enqueue_script(): void {
return;
}
+ $business_id = wp_json_encode($business_id);
+ $config = wp_json_encode([
+ 'push' => [
+ // A same-origin path, as Push requires. Registered under the plugin's
+ // folder, the worker never replaces one the store runs at the root.
+ 'serviceWorkerUrl' => wp_make_link_relative(
+ plugins_url('assets/hellotext-service-worker.js', dirname(__DIR__, 2) . '/hellotext.php')
+ ),
+ ],
+ ]);
+ $section = wp_json_encode(hellotext_page_section());
+
// A null version keeps the URL unversioned, so jsDelivr serves the latest release.
wp_enqueue_script('hellotext', Constants::SDK_URL, [], null, true);
- wp_add_inline_script('hellotext', sprintf('Hellotext.initialize(%s);', wp_json_encode($business_id)));
+ wp_add_inline_script('hellotext', <<tables = ['wp_woocommerce_api_keys' => []];
- unset($GLOBALS['test_actions']['shutdown'], $GLOBALS['test_woocommerce'], $GLOBALS['test_product_parent_id'], $GLOBALS['test_order_item_product']);
+ unset($GLOBALS['test_actions']['shutdown'], $GLOBALS['test_woocommerce'], $GLOBALS['test_product_parent_id'], $GLOBALS['test_order_item_product'], $GLOBALS['test_page']);
$GLOBALS['wp'] = (object) ['request' => 'cart'];
$_COOKIE = [];
@@ -76,17 +76,39 @@ function a_cart_with(int $quantity): void
: [];
}
-test('loads the latest unversioned Hellotext.js with only the business ID', function () {
+test('loads the latest unversioned Hellotext.js with the bundled Push worker', function () {
$GLOBALS['test_scripts'] = [];
$GLOBALS['test_inline_scripts'] = [];
hellotext_enqueue_script();
+ $inline = $GLOBALS['test_inline_scripts']['hellotext'][0];
+ $worker = basename(dirname(__DIR__, 3)) . '/assets/hellotext-service-worker.js';
+
expect($GLOBALS['test_scripts']['hellotext']['src'])->toBe('https://cdn.jsdelivr.net/npm/@hellotext/hellotext/dist/hellotext.min.js')
->and($GLOBALS['test_scripts']['hellotext']['ver'])->toBeNull()
- ->and($GLOBALS['test_inline_scripts']['hellotext'])->toBe(['Hellotext.initialize("test_business_123");']);
+ ->and($inline)->toContain('Hellotext.initialize("test_business_123", ' . json_encode(['push' => ['serviceWorkerUrl' => "/wp-content/plugins/{$worker}"]]) . ')')
+ ->and(is_file(dirname(__DIR__, 3) . '/assets/hellotext-service-worker.js'))->toBeTrue();
});
+test('tells Hellotext.js the page section and shows its Smart Alert', function (?string $page, ?string $section) {
+ $GLOBALS['test_inline_scripts'] = [];
+ $GLOBALS['test_page'] = $page;
+
+ hellotext_enqueue_script();
+
+ expect(hellotext_page_section())->toBe($section)
+ ->and($GLOBALS['test_inline_scripts']['hellotext'][0])->toContain('var section = ' . json_encode($section) . ';')
+ ->and($GLOBALS['test_inline_scripts']['hellotext'][0])->toContain('Hellotext.page.section = section')
+ ->and($GLOBALS['test_inline_scripts']['hellotext'][0])->toContain('if (section && Hellotext.alert) Hellotext.alert.show(section)');
+})->with([
+ 'front page, even when it is the shop' => ['front', 'homepage'],
+ 'shop' => ['shop', 'product_collection'],
+ 'product category' => ['category', 'product_collection'],
+ 'product' => ['product', 'product_details'],
+ 'cart and other pages' => [null, null],
+]);
+
test('does not load Hellotext.js before the business ID is set', function () {
$GLOBALS['test_scripts'] = [];
$GLOBALS['test_options'][Constants::OPTION_BUSINESS_ID] = '';
From 0b1f4242186aef43aa3a8e31251ef23c25e0abe6 Mon Sep 17 00:00:00 2001
From: rockwellll
Date: Fri, 6 Nov 2026 19:34:15 -0300
Subject: [PATCH 3/4] feat: add Webchat products to the cart and track page,
cart and checkout views
Move the storefront startup into assets/storefront.js, with its settings in
window.hellotextWoo, and close the gaps with the other integrations:
- Add the product of a Webchat card to the WooCommerce cart when Hellotext.js
dispatches cart.added, through ?wc-ajax=add_to_cart like WooCommerce's own
buttons, refreshing the mini cart. The card's message is posted along and
sent as the source of the resulting cart.added.
- Track page.viewed after setting the page section, except on product pages
and the checkout, which report their own events.
- Track cart.viewed on the cart page and checkout.started on the checkout,
once per cart and not on the thank-you or payment pages.
- Send the product's whole quantity in cart.added again: Hellotext sets the
cart line to it.
- Skip the storefront while WooCommerce is inactive.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/php.yml | 1 +
API.md | 21 ++-
DEVELOPMENT.md | 3 +-
README.md | 5 +-
assets/storefront.js | 82 ++++++++++++
changelog.txt | 4 +-
docs/WOOCOMMERCE-AUDIT.md | 8 +-
src/Constants.php | 3 +
src/Events/CartUpdates.php | 124 +++++++++++++----
src/Events/CheckoutStarted.php | 41 ++++++
src/Misc/Scripts.php | 71 ++++++----
tests/Mocks.php | 43 ++++++
tests/Unit/Events/EventTrackingTest.php | 168 +++++++++++++++++++++---
13 files changed, 493 insertions(+), 81 deletions(-)
create mode 100644 assets/storefront.js
create mode 100644 src/Events/CheckoutStarted.php
diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
index 2fb8cc1..6cffa59 100644
--- a/.github/workflows/php.yml
+++ b/.github/workflows/php.yml
@@ -96,6 +96,7 @@ jobs:
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 a82b829..87dab6d 100644
--- a/API.md
+++ b/API.md
@@ -396,8 +396,10 @@ The plugin registers these WooCommerce and WordPress hooks:
| `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` and `cart.removed` with the quantity that changed, after WooCommerce recalculates totals |
-| `woocommerce_cart_emptied` | `hellotext_reset_cart_snapshot` | Forget the cart snapshot so purchased items are not reported as removed |
+| `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 |
| `woocommerce_applied_coupon` | `hellotext_coupon_redeemed` | Track valid `coupon.redeemed` events |
| `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 |
@@ -407,7 +409,7 @@ The plugin registers these WooCommerce and WordPress hooks:
| `user_register` | `hellotext_user_registered` | Track customer registration/profile flow |
| `add_option_*` / `update_option_*` (Business ID, Access Token) | `hellotext_schedule_connect`, `hellotext_business_id_updated` | Connect the store once both settings are saved; remove the previous business's integration first when the Business ID changes |
| `admin_init` | `hellotext_settings_init` | Register plugin settings |
-| `wp_enqueue_scripts` | `hellotext_enqueue_script` | Load the latest Hellotext.js on the storefront with the bundled Push worker, set `Hellotext.page.section`, and show the Smart Alert for that section |
+| `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
@@ -459,11 +461,20 @@ The plugin automatically hooks into these WooCommerce actions:
- `woocommerce_add_to_cart` - Track cart additions
- `woocommerce_cart_item_removed` - Track cart removals
- `woocommerce_applied_coupon` - Track coupon usage
+- `template_redirect` - Track the cart page and the checkout
- `wp_enqueue_scripts` - Load Hellotext.js. Webchat, popups and other widgets are configured from Hellotext
-### Page sections
+### Storefront script
+
+`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.
-After `Hellotext.initialize()`, the plugin sets `Hellotext.page.section`, so every event of the page reports it, Webchat messages included, and calls `Hellotext.alert.show(section)`. Hellotext shows the alert only for the sections enabled in the Smart Alert playbook.
+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.
+
+### Page sections
| Page | Section |
| ------------------------------------------------- | -------------------- |
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index eb5f179..c2d49f0 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -342,7 +342,8 @@ add_action('all', function($hook) {
- [ ] 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`.
-- [ ] 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.
+- [ ] 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.
diff --git a/README.md b/README.md
index ea07afa..abe26b9 100644
--- a/README.md
+++ b/README.md
@@ -105,8 +105,11 @@ 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 paid, or put on hold for offline payment
- **Order Status Updates** - Order confirmation, cancellation, delivery
diff --git a/assets/storefront.js b/assets/storefront.js
new file mode 100644
index 0000000..0b39cab
--- /dev/null
+++ b/assets/storefront.js
@@ -0,0 +1,82 @@
+/**
+ * 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 && result.product_url) window.location.href = result.product_url
+ return
+ }
+
+ added = true
+ refreshCart(result)
+ }
+
+ if (added && config.redirectToCart) window.location.href = config.cartUrl
+ }
+
+ // 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 a2b9e88..c6e683c 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -6,10 +6,12 @@ Unreleased
* 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 the quantity that changed in cart events, with totals after WooCommerce recalculates them, and stop reporting purchased items as removed.
+* 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.
* 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.
diff --git a/docs/WOOCOMMERCE-AUDIT.md b/docs/WOOCOMMERCE-AUDIT.md
index b4be371..77d5d09 100644
--- a/docs/WOOCOMMERCE-AUDIT.md
+++ b/docs/WOOCOMMERCE-AUDIT.md
@@ -29,19 +29,21 @@ Do not declare HPOS compatibility yet. Add the declaration only after a manual r
| 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. 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 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. |
| 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. |
+| 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. 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. |
+| 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
diff --git a/src/Constants.php b/src/Constants.php
index b1106f4..1495233 100644
--- a/src/Constants.php
+++ b/src/Constants.php
@@ -52,6 +52,7 @@ class Constants {
// 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';
@@ -62,6 +63,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/CartUpdates.php b/src/Events/CartUpdates.php
index ee3fd7b..31dcf79 100644
--- a/src/Events/CartUpdates.php
+++ b/src/Events/CartUpdates.php
@@ -1,5 +1,6 @@
session) {
WC()->session->set(Constants::SESSION_CART_ITEMS, []);
+ WC()->session->set(Constants::SESSION_CHECKOUT_STARTED, null);
}
}
@@ -44,6 +48,10 @@ function hellotext_trigger_cart_updated(): void {
/**
* 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 {
@@ -55,23 +63,7 @@ function hellotext_cart_updated(): void {
$previous_cart_items = $woocommerce->session->get(Constants::SESSION_CART_ITEMS);
$previous_cart_items = is_array($previous_cart_items) ? $previous_cart_items : [];
-
- $cart_items = [];
-
- foreach ($woocommerce->cart->get_cart() as $cart_item) {
- $product = (new ProductAdapter($cart_item['data']))->get();
- $reference = (string) $product['reference'];
-
- if (isset($cart_items[$reference])) {
- $cart_items[$reference]['quantity'] += $cart_item['quantity'];
- continue;
- }
-
- $cart_items[$reference] = [
- 'product' => $product,
- 'quantity' => $cart_item['quantity'],
- ];
- }
+ $cart_items = hellotext_cart_items();
$woocommerce->session->set(Constants::SESSION_CART_ITEMS, $cart_items);
@@ -81,10 +73,8 @@ function hellotext_cart_updated(): void {
];
foreach ($cart_items as $reference => $cart_item) {
- $added = $cart_item['quantity'] - ($previous_cart_items[$reference]['quantity'] ?? 0);
-
- if ($added > 0) {
- $changes[Constants::EVENT_CART_ADDED][] = ['product' => $cart_item['product'], 'quantity' => $added];
+ if ($cart_item['quantity'] > ($previous_cart_items[$reference]['quantity'] ?? 0)) {
+ $changes[Constants::EVENT_CART_ADDED][] = $cart_item;
}
}
@@ -109,15 +99,99 @@ function hellotext_cart_updated(): void {
continue;
}
- $event->track($action, [
+ $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));
+ }
+}
+
+/**
+ * Track the cart page.
+ *
+ * @return void
+ */
+function hellotext_cart_viewed(): void {
+ if (!function_exists('WC') || !is_cart() || !WC()->cart || WC()->cart->is_empty()) {
+ return;
+ }
+
+ do_action('hellotext_create_profile');
+
+ (new Event(Session::current()))->track(Constants::EVENT_CART_VIEWED, [
+ 'object_parameters' => hellotext_cart_object_parameters() + ['url' => hellotext_current_url()],
+ ]);
+}
+
+/**
+ * The cart's lines, one per product or variation, keyed by reference.
+ *
+ * @return array
+ */
+function hellotext_cart_items(): array {
+ $cart_items = [];
+
+ foreach (WC()->cart->get_cart() as $cart_item) {
+ $product = (new ProductAdapter($cart_item['data']))->get();
+ $reference = (string) $product['reference'];
+
+ if (isset($cart_items[$reference])) {
+ $cart_items[$reference]['quantity'] += $cart_item['quantity'];
+ continue;
+ }
+
+ $cart_items[$reference] = [
+ 'product' => $product,
+ 'quantity' => $cart_item['quantity'],
+ ];
+ }
+
+ 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;
}
/**
diff --git a/src/Events/CheckoutStarted.php b/src/Events/CheckoutStarted.php
new file mode 100644
index 0000000..57ca8b1
--- /dev/null
+++ b/src/Events/CheckoutStarted.php
@@ -0,0 +1,41 @@
+session || !$woocommerce->cart || $woocommerce->cart->is_empty()) {
+ return;
+ }
+
+ $cart_hash = $woocommerce->cart->get_cart_hash();
+
+ if ($woocommerce->session->get(Constants::SESSION_CHECKOUT_STARTED) === $cart_hash) {
+ return;
+ }
+
+ $woocommerce->session->set(Constants::SESSION_CHECKOUT_STARTED, $cart_hash);
+
+ do_action('hellotext_create_profile');
+
+ (new Event(Session::current()))->track(Constants::EVENT_CHECKOUT_STARTED, [
+ 'object_parameters' => hellotext_cart_object_parameters(),
+ ]);
+}
diff --git a/src/Misc/Scripts.php b/src/Misc/Scripts.php
index 019bd05..dec75e4 100644
--- a/src/Misc/Scripts.php
+++ b/src/Misc/Scripts.php
@@ -8,44 +8,63 @@
* Load Hellotext.js on the storefront.
*
* Webchat, popups and every other widget are configured from Hellotext. The
- * store passes its business ID and the Push service worker, and tells
- * Hellotext.js which section the page is, so events and Webchat messages
- * report it and the Smart Alert shows the matching section.
+ * plugin's storefront script starts it with the business ID and the Push
+ * service worker, reports the page section and page view, shows the Smart
+ * Alert for the section, and adds the Webchat's products to the cart.
*
* @return void
*/
function hellotext_enqueue_script(): void {
- $business_id = get_option(Constants::OPTION_BUSINESS_ID);
+ $business_id = (string) get_option(Constants::OPTION_BUSINESS_ID);
- if (!$business_id) {
+ // The page checks below are WooCommerce's, missing while it is inactive.
+ if (!$business_id || !function_exists('WC')) {
return;
}
- $business_id = wp_json_encode($business_id);
- $config = wp_json_encode([
- 'push' => [
- // A same-origin path, as Push requires. Registered under the plugin's
- // folder, the worker never replaces one the store runs at the root.
- 'serviceWorkerUrl' => wp_make_link_relative(
- plugins_url('assets/hellotext-service-worker.js', dirname(__DIR__, 2) . '/hellotext.php')
- ),
- ],
- ]);
- $section = wp_json_encode(hellotext_page_section());
+ $plugin_file = dirname(__DIR__, 2) . '/hellotext.php';
+ $storefront = dirname(__DIR__, 2) . '/assets/storefront.js';
// A null version keeps the URL unversioned, so jsDelivr serves the latest release.
wp_enqueue_script('hellotext', Constants::SDK_URL, [], null, true);
- wp_add_inline_script('hellotext', << $business_id,
+ 'options' => [
+ 'push' => [
+ // A same-origin path, as Push requires. Registered under the plugin's
+ // folder, the worker never replaces one the store runs at the root.
+ 'serviceWorkerUrl' => wp_make_link_relative(plugins_url('assets/hellotext-service-worker.js', $plugin_file)),
+ ],
+ ],
+ 'section' => hellotext_page_section(),
+ // Product pages report product.viewed and the checkout checkout.started.
+ 'trackPageView' => !is_product() && !is_checkout(),
+ 'addToCartUrl' => WC_AJAX::get_endpoint('add_to_cart'),
+ 'cartUrl' => wc_get_cart_url(),
+ 'redirectToCart' => 'yes' === get_option('woocommerce_cart_redirect_after_add'),
+ ];
}
/**
diff --git a/tests/Mocks.php b/tests/Mocks.php
index 5b0360d..240ef53 100644
--- a/tests/Mocks.php
+++ b/tests/Mocks.php
@@ -118,6 +118,29 @@ function is_product_taxonomy()
return ($GLOBALS['test_page'] ?? null) === 'category';
}
+function is_cart()
+{
+ return ($GLOBALS['test_page'] ?? null) === 'cart';
+}
+
+function is_checkout()
+{
+ return ($GLOBALS['test_page'] ?? null) === 'checkout';
+}
+
+function is_wc_endpoint_url($endpoint = false)
+{
+ return ($GLOBALS['test_endpoint'] ?? null) === $endpoint;
+}
+
+class WC_AJAX
+{
+ public static function get_endpoint($request = '')
+ {
+ return '/?wc-ajax=' . $request;
+ }
+}
+
function wc_get_cart_url()
{
return 'https://example.test/cart';
@@ -797,6 +820,26 @@ public function get_cart_contents_total()
{
return 50;
}
+
+ public function get_total($context = 'view')
+ {
+ return 55;
+ }
+
+ public function is_empty()
+ {
+ return 0 === count($this->items);
+ }
+
+ public function get_cart_contents_count()
+ {
+ return array_sum(array_column($this->items, 'quantity'));
+ }
+
+ public function get_cart_hash()
+ {
+ return md5(json_encode(array_map(fn ($item) => $item['quantity'], $this->items)));
+ }
}
class User
diff --git a/tests/Unit/Events/EventTrackingTest.php b/tests/Unit/Events/EventTrackingTest.php
index fa3ac3f..aab13db 100644
--- a/tests/Unit/Events/EventTrackingTest.php
+++ b/tests/Unit/Events/EventTrackingTest.php
@@ -19,9 +19,10 @@
$GLOBALS['test_order_user_id'] = 1;
$GLOBALS['test_post_terms'] = [];
$GLOBALS['wpdb']->tables = ['wp_woocommerce_api_keys' => []];
- unset($GLOBALS['test_actions']['shutdown'], $GLOBALS['test_woocommerce'], $GLOBALS['test_product_parent_id'], $GLOBALS['test_order_item_product'], $GLOBALS['test_page']);
+ unset($GLOBALS['test_actions']['shutdown'], $GLOBALS['test_woocommerce'], $GLOBALS['test_product_parent_id'], $GLOBALS['test_order_item_product'], $GLOBALS['test_page'], $GLOBALS['test_endpoint']);
$GLOBALS['wp'] = (object) ['request' => 'cart'];
$_COOKIE = [];
+ $_POST = [];
$GLOBALS['test_wp_remote_request'] = function ($url, $args = []) {
$GLOBALS['test_http_requests'][] = [
@@ -76,39 +77,56 @@ function a_cart_with(int $quantity): void
: [];
}
-test('loads the latest unversioned Hellotext.js with the bundled Push worker', function () {
+function storefront_config(): array
+{
$GLOBALS['test_scripts'] = [];
$GLOBALS['test_inline_scripts'] = [];
hellotext_enqueue_script();
- $inline = $GLOBALS['test_inline_scripts']['hellotext'][0];
- $worker = basename(dirname(__DIR__, 3)) . '/assets/hellotext-service-worker.js';
+ $inline = $GLOBALS['test_inline_scripts']['hellotext-storefront'][0];
+
+ return json_decode(substr($inline, strlen('window.hellotextWoo = '), -1), true);
+}
+
+test('loads the latest unversioned Hellotext.js and the storefront script', function () {
+ $plugin = basename(dirname(__DIR__, 3));
+ $config = storefront_config();
expect($GLOBALS['test_scripts']['hellotext']['src'])->toBe('https://cdn.jsdelivr.net/npm/@hellotext/hellotext/dist/hellotext.min.js')
->and($GLOBALS['test_scripts']['hellotext']['ver'])->toBeNull()
- ->and($inline)->toContain('Hellotext.initialize("test_business_123", ' . json_encode(['push' => ['serviceWorkerUrl' => "/wp-content/plugins/{$worker}"]]) . ')')
+ ->and($GLOBALS['test_scripts']['hellotext-storefront']['src'])->toBe("https://example.test/wp-content/plugins/{$plugin}/assets/storefront.js")
+ ->and($config['businessId'])->toBe('test_business_123')
+ ->and($config['options'])->toBe(['push' => ['serviceWorkerUrl' => "/wp-content/plugins/{$plugin}/assets/hellotext-service-worker.js"]])
+ ->and($config['addToCartUrl'])->toBe('/?wc-ajax=add_to_cart')
+ ->and($config['cartUrl'])->toBe('https://example.test/cart')
+ ->and($config['redirectToCart'])->toBeFalse()
->and(is_file(dirname(__DIR__, 3) . '/assets/hellotext-service-worker.js'))->toBeTrue();
});
-test('tells Hellotext.js the page section and shows its Smart Alert', function (?string $page, ?string $section) {
- $GLOBALS['test_inline_scripts'] = [];
+test('tells the storefront script the page section and whether to track the page view', function (?string $page, ?string $section, bool $track_page_view) {
$GLOBALS['test_page'] = $page;
- hellotext_enqueue_script();
+ $config = storefront_config();
- expect(hellotext_page_section())->toBe($section)
- ->and($GLOBALS['test_inline_scripts']['hellotext'][0])->toContain('var section = ' . json_encode($section) . ';')
- ->and($GLOBALS['test_inline_scripts']['hellotext'][0])->toContain('Hellotext.page.section = section')
- ->and($GLOBALS['test_inline_scripts']['hellotext'][0])->toContain('if (section && Hellotext.alert) Hellotext.alert.show(section)');
+ expect($config['section'])->toBe($section)
+ ->and($config['trackPageView'])->toBe($track_page_view);
})->with([
- 'front page, even when it is the shop' => ['front', 'homepage'],
- 'shop' => ['shop', 'product_collection'],
- 'product category' => ['category', 'product_collection'],
- 'product' => ['product', 'product_details'],
- 'cart and other pages' => [null, null],
+ 'front page, even when it is the shop' => ['front', 'homepage', true],
+ 'shop' => ['shop', 'product_collection', true],
+ 'product category' => ['category', 'product_collection', true],
+ 'product, which reports product.viewed' => ['product', 'product_details', false],
+ 'cart' => ['cart', null, true],
+ 'checkout, which reports checkout.started' => ['checkout', null, false],
+ 'other pages' => [null, null, true],
]);
+test('follows the store setting to open the cart after adding a product', function () {
+ $GLOBALS['test_options']['woocommerce_cart_redirect_after_add'] = 'yes';
+
+ expect(storefront_config()['redirectToCart'])->toBeTrue();
+});
+
test('does not load Hellotext.js before the business ID is set', function () {
$GLOBALS['test_scripts'] = [];
$GLOBALS['test_options'][Constants::OPTION_BUSINESS_ID] = '';
@@ -198,7 +216,7 @@ function a_cart_with(int $quantity): void
->and($removed['object_parameters']['items'][0]['quantity'])->toBe(2);
});
-test('reports only the quantity that changed and ignores unchanged carts', function () {
+test('reports the whole quantity on additions, the removed quantity on removals, and ignores unchanged carts', function () {
$GLOBALS['test_current_user_id'] = 0;
a_cart_with(1);
hellotext_cart_updated();
@@ -208,7 +226,14 @@ function a_cart_with(int $quantity): void
hellotext_cart_updated();
expect(tracked_event()['action'])->toBe(Constants::EVENT_CART_ADDED)
- ->and(tracked_event()['object_parameters']['items'][0]['quantity'])->toBe(2);
+ ->and(tracked_event()['object_parameters']['items'][0]['quantity'])->toBe(3);
+
+ $GLOBALS['test_http_requests'] = [];
+ a_cart_with(2);
+ hellotext_cart_updated();
+
+ expect(tracked_event()['action'])->toBe(Constants::EVENT_CART_REMOVED)
+ ->and(tracked_event()['object_parameters']['items'][0]['quantity'])->toBe(1);
$GLOBALS['test_http_requests'] = [];
@@ -241,6 +266,111 @@ function a_cart_with(int $quantity): void
expect(tracked_event()['url'])->toBe('https://example.test/shop');
});
+test('credits a cart addition to the Webchat message whose card added it', function () {
+ $GLOBALS['test_current_user_id'] = 0;
+ $_POST['hellotext_source'] = json_encode(['kind' => 'webchat', 'message_id' => 'msg_1', 'button_id' => 'btn_1', 'extra' => 'dropped']);
+ a_cart_with(1);
+ hellotext_cart_updated();
+
+ expect(tracked_event()['source'])->toBe(['kind' => 'webchat', 'message_id' => 'msg_1', 'button_id' => 'btn_1']);
+
+ $GLOBALS['test_http_requests'] = [];
+ a_cart_with(0);
+ hellotext_cart_updated();
+
+ expect(tracked_event()['action'])->toBe(Constants::EVENT_CART_REMOVED)
+ ->and(tracked_event())->not->toHaveKey('source');
+});
+
+test('ignores a malformed Webchat source', function (mixed $source) {
+ $GLOBALS['test_current_user_id'] = 0;
+ $_POST['hellotext_source'] = $source;
+ a_cart_with(1);
+
+ hellotext_cart_updated();
+
+ expect(tracked_event())->not->toHaveKey('source');
+})->with([
+ 'not JSON' => ['{oops'],
+ 'empty object' => ['{}'],
+ 'nested values' => [json_encode(['kind' => ['webchat']])],
+ 'not a string' => [['kind' => 'webchat']],
+]);
+
+test('tracks the cart page with the whole cart', function () {
+ $_COOKIE[Constants::SESSION_COOKIE_NAME] = 'cart-session';
+ $GLOBALS['test_current_user_id'] = 0;
+ $GLOBALS['test_page'] = 'cart';
+ a_cart_with(2);
+
+ do_action('template_redirect');
+
+ $event = tracked_event();
+
+ expect($event['action'])->toBe(Constants::EVENT_CART_VIEWED)
+ ->and($event['session'])->toBe('cart-session')
+ ->and($event['object_parameters']['source'])->toBe('woo')
+ ->and($event['object_parameters']['items'][0]['quantity'])->toBe(2)
+ ->and($event['object_parameters']['items'][0]['product']['reference'])->toBe(1)
+ ->and($event['object_parameters']['total']['amount'])->toBe(55)
+ ->and($event['object_parameters']['total_quantity'])->toBe(2)
+ ->and($event['object_parameters']['url'])->toBe('https://example.test/cart');
+});
+
+test('does not track an empty cart page', function () {
+ $GLOBALS['test_page'] = 'cart';
+ a_cart_with(0);
+
+ do_action('template_redirect');
+
+ expect($GLOBALS['test_http_requests'])->toBe([]);
+});
+
+test('tracks checkout started once per cart', function () {
+ $_COOKIE[Constants::SESSION_COOKIE_NAME] = 'checkout-session';
+ $GLOBALS['test_current_user_id'] = 0;
+ $GLOBALS['test_page'] = 'checkout';
+ a_cart_with(2);
+
+ do_action('template_redirect');
+ do_action('template_redirect');
+
+ $event = tracked_event();
+
+ expect(tracked_actions())->toBe([Constants::EVENT_CHECKOUT_STARTED])
+ ->and($event['session'])->toBe('checkout-session')
+ ->and($event['object_parameters']['source'])->toBe('woo')
+ ->and($event['object_parameters']['items'][0]['quantity'])->toBe(2)
+ ->and($event['object_parameters']['total']['amount'])->toBe(55);
+
+ a_cart_with(3);
+ do_action('template_redirect');
+
+ expect(tracked_actions())->toBe([Constants::EVENT_CHECKOUT_STARTED, Constants::EVENT_CHECKOUT_STARTED]);
+});
+
+test('tracks checkout started again for the next purchase of the same cart', function () {
+ $GLOBALS['test_current_user_id'] = 0;
+ $GLOBALS['test_page'] = 'checkout';
+ a_cart_with(2);
+ do_action('template_redirect');
+
+ do_action('woocommerce_cart_emptied');
+ do_action('template_redirect');
+
+ expect(tracked_actions())->toBe([Constants::EVENT_CHECKOUT_STARTED, Constants::EVENT_CHECKOUT_STARTED]);
+});
+
+test('does not track checkout started on the thank-you and payment pages', function (string $endpoint) {
+ $GLOBALS['test_page'] = 'checkout';
+ $GLOBALS['test_endpoint'] = $endpoint;
+ a_cart_with(2);
+
+ do_action('template_redirect');
+
+ expect($GLOBALS['test_http_requests'])->toBe([]);
+})->with(['order-received', 'order-pay']);
+
test('tracks order placed with the session captured at checkout', function () {
$_COOKIE[Constants::SESSION_COOKIE_NAME] = 'checkout-session';
$order = new WC_Order();
From 098eb6428944fecbe4719a6f85caf43c1e188866 Mon Sep 17 00:00:00 2001
From: rockwellll
Date: Fri, 6 Nov 2026 20:19:45 -0300
Subject: [PATCH 4/4] fix: address review findings, open checkout links and
send the WooCommerce version
Review findings:
- The Webchat add to cart opens only http(s) pages on the store's origin
(Aikido: unvalidated window.location navigation).
- A connection keeps the store's working API key until Hellotext accepts
the new one. A failed connection removes only the new key, shows on the
settings page, and is retried when the settings are saved again, even
unchanged. A key a cut-short connection left pending is cleared first.
- A customer's session is remembered as linked only after Hellotext
linked it, so a failed attempt is retried on the next visit.
Checkout links: ?hellotext_checkout=1&products=id:quantity,..., the link
Hellotext sends stores older than WooCommerce 10.0, replaces the cart with
the products and opens the checkout with the link's other parameters, as
WooCommerce's /checkout-link/ does. A product that needs options opens its
page; when nothing can be added, the cart opens.
The WooCommerce version is sent when connecting, so Hellotext can choose
between WooCommerce's checkout link and the plugin's.
Co-Authored-By: Claude Opus 5.5
---
API.md | 4 +-
assets/storefront.js | 19 ++++-
changelog.txt | 3 +
languages/hellotext-en_US.mo | Bin 1067 -> 1230 bytes
languages/hellotext-en_US.po | 3 +
languages/hellotext-es_ES.mo | Bin 1227 -> 1409 bytes
languages/hellotext-es_ES.po | 3 +
languages/hellotext.pot | 3 +
src/Constants.php | 7 ++
src/Events/AppInstalled.php | 65 +++++++++++++---
src/Events/CheckoutLink.php | 98 ++++++++++++++++++++++++
src/Misc/Settings.php | 4 +
src/Services/CreateProfile.php | 14 ++--
tests/Mocks.php | 79 ++++++++++++++++++-
tests/Unit/Events/CheckoutLinkTest.php | 63 +++++++++++++++
tests/Unit/Events/EventTrackingTest.php | 66 ++++++++++++++++
16 files changed, 409 insertions(+), 22 deletions(-)
create mode 100644 src/Events/CheckoutLink.php
create mode 100644 tests/Unit/Events/CheckoutLinkTest.php
diff --git a/API.md b/API.md
index 87dab6d..1f85f03 100644
--- a/API.md
+++ b/API.md
@@ -400,6 +400,7 @@ The plugin registers these WooCommerce and WordPress hooks:
| `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_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 |
@@ -407,7 +408,8 @@ The plugin registers these WooCommerce and WordPress hooks:
| `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 |
-| `add_option_*` / `update_option_*` (Business ID, Access Token) | `hellotext_schedule_connect`, `hellotext_business_id_updated` | Connect the store once both settings are saved; remove the previous business's integration first when the 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 |
| `wp_enqueue_scripts` | `hellotext_enqueue_script` | Load the latest Hellotext.js and `assets/storefront.js` on the storefront, with its settings in `window.hellotextWoo` |
diff --git a/assets/storefront.js b/assets/storefront.js
index 0b39cab..30e9102 100644
--- a/assets/storefront.js
+++ b/assets/storefront.js
@@ -58,7 +58,7 @@
if (!result || result.error) {
// Like WooCommerce's own buttons: products that need options picked,
// or are out of stock, open their page.
- if (result && result.product_url) window.location.href = result.product_url
+ if (result) navigate(result.product_url)
return
}
@@ -66,7 +66,22 @@
refreshCart(result)
}
- if (added && config.redirectToCart) window.location.href = config.cartUrl
+ 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
diff --git a/changelog.txt b/changelog.txt
index c6e683c..b536202 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -14,6 +14,9 @@ Unreleased
* 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.
diff --git a/languages/hellotext-en_US.mo b/languages/hellotext-en_US.mo
index 6aa1434ebe3514bdcaaf2d4c4dad041fa6247fab..a26bf5b2415eb25cf6a9c2a3ce122c70ff9dda68 100644
GIT binary patch
delta 337
zcmXYrze+>p;deRZik!N+)URnworYH4o{qWHX^qG95JD3VTnd
xN2DcBGi^+q1UF&XjT0fSFFAFl)KYLPDb!Qag|;$Oqgb1=n>sb(5Udnl{Q{^TPM!b&
delta 183
zcmX@dxtgQ?o)F7a1|VPuVi_O~0b*_-?g3&D*a5^+K)e%5UjfphK>QwvMSz%F*r5{Tyj`D>XN7&ZcFFJ=Y?9w7Y(NQ2DhWC8QJ7({^#4xoV2#F^@>
nK$?Ml;?ca#evF@(#DWvcQWcUj67$kiiy4AbOG+~H(u)}YdbJu9
diff --git a/languages/hellotext-en_US.po b/languages/hellotext-en_US.po
index bee299a..00f794f 100644
--- a/languages/hellotext-en_US.po
+++ b/languages/hellotext-en_US.po
@@ -47,6 +47,9 @@ msgstr "Access Token"
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 db3c607574017ed3b50fe2621629bf908065e044..de876b861651e6c24f6554e7f3e6640e4ae274da 100644
GIT binary patch
delta 388
zcmXYry-EW?6on`5`V&MWqK$~$S~QKXprCDvG=egjy@tVY?y@@@v
&4;MYWkp%CfEcSSOU)=<_GM8U$6mMjoRDrI{E>;
z1|Lu7Iov|Og_q$GT!JUChrX34HHJIzn^Gt04>hQppn%L3NK4nav`(munRU8F5XCJ597%pW8(|)CkOp}w6jK1huIM;jQh-
Pb~3qAaWath{Wzt6qSQ>h
delta 215
zcmZqVKFwKwPl#nI0}yZku?!H$05LZZ&jDf(I03}EKztI2rGWT5lon!SU=Rheb%C@9
zkah;r{6IPaNb>>d3?N+$#N|M24CKFNVqjPeq~)0z7!CmG(?A-i-i@J$1)^~dlwJd*
sK?dvr(m=foK#BpV77o~eETCBotWXhlAk8uH?wrk27`>S%C$Y){055hGYXATM
diff --git a/languages/hellotext-es_ES.po b/languages/hellotext-es_ES.po
index 40cbbb2..8a4fb8e 100644
--- a/languages/hellotext-es_ES.po
+++ b/languages/hellotext-es_ES.po
@@ -47,6 +47,9 @@ msgstr "Token de acceso"
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 f08d7b6..a7c2308 100644
--- a/languages/hellotext.pot
+++ b/languages/hellotext.pot
@@ -47,6 +47,9 @@ msgstr ""
msgid "settings.submit"
msgstr ""
+msgid "settings.connection_failed"
+msgstr ""
+
msgid "description.paragraphs.one"
msgstr ""
diff --git a/src/Constants.php b/src/Constants.php
index 1495233..4c0b6a2 100644
--- a/src/Constants.php
+++ b/src/Constants.php
@@ -17,6 +17,9 @@ class Constants {
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';
@@ -27,12 +30,14 @@ class Constants {
// WordPress Options
public const OPTION_BUSINESS_ID = 'hellotext_business_id';
public const OPTION_ACCESS_TOKEN = 'hellotext_access_token';
+ 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',
@@ -40,6 +45,8 @@ class Constants {
// 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';
diff --git a/src/Events/AppInstalled.php b/src/Events/AppInstalled.php
index 5dd29a4..87ececc 100644
--- a/src/Events/AppInstalled.php
+++ b/src/Events/AppInstalled.php
@@ -15,10 +15,14 @@ function hellotext_activate(): void {
/**
* Connect the store to Hellotext.
*
- * Every connection issues a new WooCommerce API key for Hellotext and
- * replaces the previous one, 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.
+ * 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.
*
* @return void
*/
@@ -34,21 +38,23 @@ function hellotext_connect(): void {
return;
}
+ $api_keys_table = $wpdb->prefix . 'woocommerce_api_keys';
$consumer_key = 'ck_' . wc_rand_hash();
$consumer_secret = 'cs_' . wc_rand_hash();
- hellotext_delete_api_keys();
+ // A connection cut short leaves its pending key behind.
+ hellotext_delete_api_keys([Constants::API_KEY_PENDING_DESCRIPTION]);
- $wpdb->insert($wpdb->prefix . 'woocommerce_api_keys', [
+ $wpdb->insert($api_keys_table, [
'user_id' => $user_id,
- 'description' => Constants::API_KEY_DESCRIPTION,
+ '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),
]);
- Client::post(Constants::API_ENDPOINT_INTEGRATIONS_WOO, [
+ $response = Client::post(Constants::API_ENDPOINT_INTEGRATIONS_WOO, [
'shop' => [
'name' => get_bloginfo('name'),
'url' => get_bloginfo('url'),
@@ -56,22 +62,56 @@ function hellotext_connect(): void {
'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);
+ }
}
/**
- * Delete the WooCommerce API keys issued to Hellotext.
+ * Retry a failed connection when the settings are saved again.
*
+ * 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 hellotext_retry_failed_connection(mixed $value): mixed {
+ if (get_option(Constants::OPTION_CONNECTION_FAILED)) {
+ hellotext_schedule_connect();
+ }
+
+ return $value;
+}
+
+/**
+ * Delete the WooCommerce API keys issued to Hellotext, connected and pending
+ * ones by default.
+ *
+ * @param array $descriptions Descriptions of the keys to delete.
* @return void
*/
-function hellotext_delete_api_keys(): void {
+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) {
- $wpdb->delete($api_keys_table, ['description' => Constants::API_KEY_DESCRIPTION]);
+ 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]);
}
}
@@ -111,3 +151,4 @@ function hellotext_business_id_updated(mixed $old_value, mixed $new_value): void
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/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 Quantities by product ID.
+ */
+function hellotext_checkout_link_lines(string $value): array {
+ $lines = [];
+
+ foreach (explode(',', $value) as $line) {
+ [$id, $quantity] = array_pad(explode(':', trim($line), 2), 2, '1');
+ $id = absint($id);
+
+ if ($id > 0) {
+ $lines[$id] = ($lines[$id] ?? 0) + max(1, absint($quantity));
+ }
+ }
+
+ return $lines;
+}
diff --git a/src/Misc/Settings.php b/src/Misc/Settings.php
index 745a5fd..b2ab723 100644
--- a/src/Misc/Settings.php
+++ b/src/Misc/Settings.php
@@ -51,6 +51,10 @@ function hellotext_description_section_callback(): void {
$business_id = get_option(Constants::OPTION_BUSINESS_ID, null);
$access_token = get_option(Constants::OPTION_ACCESS_TOKEN, null);
+ if (get_option(Constants::OPTION_CONNECTION_FAILED)) {
+ echo '