From 4505f58c7bc3b1c566f7c2843d769afcb6450145 Mon Sep 17 00:00:00 2001 From: Jeffrey van Rossum Date: Fri, 28 Aug 2026 12:58:40 +0900 Subject: [PATCH 1/6] Add a WooCommerce add-to-cart host action for chat. Confirmed add_to_cart events write to the visitor cart. Variable products send nested colour then size choices in one reply, and product pages pass product_id as chat context. Co-authored-by: Cursor --- datalumo.php | 8 +- readme.txt | 74 ++- resources/js/add-to-cart.js | 140 +++++ src/Embed/Embed.php | 53 +- src/Integration/AddToCart.php | 891 ++++++++++++++++++++++++++++++++ src/Integration/WooCommerce.php | 17 +- tests/Unit/AddToCartTest.php | 466 +++++++++++++++++ tests/Unit/EmbedTest.php | 29 ++ 8 files changed, 1664 insertions(+), 14 deletions(-) create mode 100644 resources/js/add-to-cart.js create mode 100644 src/Integration/AddToCart.php create mode 100644 tests/Unit/AddToCartTest.php create mode 100644 tests/Unit/EmbedTest.php diff --git a/datalumo.php b/datalumo.php index 1ac81ff..0c3c671 100644 --- a/datalumo.php +++ b/datalumo.php @@ -7,11 +7,11 @@ * Plugin Name: Datalumo * Plugin URI: https://github.com/datalumo/wordpress * Description: Sync your WordPress content to Datalumo and add AI-powered search and chat to your site. - * Version: 0.0.7 + * Version: 0.0.14 * Requires at least: 6.0 * Requires PHP: 8.1 - * Author: Jeffrey van Rossum - * Author URI: https://vanrossum.dev + * Author: Datalumo + * Author URI: https://datalumo.app * Text Domain: datalumo * Domain Path: /languages * License: GPL-2.0-or-later @@ -25,7 +25,7 @@ } if (! defined('DATALUMO_VERSION')) { - define('DATALUMO_VERSION', '0.0.7'); + define('DATALUMO_VERSION', '0.0.14'); define('DATALUMO_FILE', __FILE__); define('DATALUMO_DIR', __DIR__); define('DATALUMO_URL', plugin_dir_url(__FILE__)); diff --git a/readme.txt b/readme.txt index 5d80067..fb98601 100644 --- a/readme.txt +++ b/readme.txt @@ -1,10 +1,10 @@ === Datalumo === -Contributors: jeffreyvanrossum +Contributors: datalumo, jeffreyvr Tags: search, ai, chatbot, rag Requires at least: 6.0 -Tested up to: 6.9 +Tested up to: 7.1 Requires PHP: 8.1 -Stable tag: 0.0.7 +Stable tag: 0.0.14 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -19,6 +19,32 @@ Datalumo keeps a searchable, AI-ready copy of your WordPress content and gives y * **Search box** — drop-in search via the `[datalumo_search]` shortcode. * **Enhanced search** — serve WordPress' native search results from Datalumo's ranking, with an optional streamed AI summary above the results. Falls back to native search automatically. * **Visitor identity** — logged-in users can continue their chat conversations across visits, verified with a server-side signature. +* **WooCommerce add to cart**: when WooCommerce is active, a confirmed chat action named `add_to_cart` adds the product to the visitor's cart. + +This plugin is an interface to a [Datalumo](https://datalumo.app) instance. You need a Datalumo account (or a self-hosted instance), an API token, and — for chat or search widgets — a widget key. Nothing is sent until an administrator connects the site and turns a feature on. + += External services = + +The plugin talks to the Datalumo instance you configure. The default host is `https://datalumo.app`; you can point it at your own instance instead. + +* Service: [https://datalumo.app](https://datalumo.app) +* Documentation: [https://datalumo.app/docs](https://datalumo.app/docs) +* Terms of Service: [https://datalumo.app/terms](https://datalumo.app/terms) +* Privacy Policy: [https://datalumo.app/privacy](https://datalumo.app/privacy) + +When a feature is enabled, the plugin may: + +* Load the widget script from `{your Datalumo URL}/widget/v1/datalumo.js` (chat, search box, AI summary, and click tracking). +* Send published post content to the Datalumo API so it can be indexed (title, HTML, permalink, author display name, categories, tags, dates, and any custom field mappings you add). +* Send visitor search queries to Datalumo when enhanced search is on. +* Send result-click events (URL, rank, search session) when enhanced search is on. +* Send a signed visitor id (`wp-user-{id}` HMAC) when visitor identity is enabled on the Chatbot tab. + += Development = + +Source code: [https://github.com/datalumo/wordpress](https://github.com/datalumo/wordpress) + +Production dependencies are installed with `composer install --no-dev`. == Installation == @@ -28,8 +54,48 @@ Datalumo keeps a searchable, AI-ready copy of your WordPress content and gives y 4. Pick which post types sync to which source, and run the first full sync. 5. Add a widget key to enable the chat, search box, or enhanced search. +== Frequently Asked Questions == + += Do I need a Datalumo account? = + +Yes. The plugin does not search or chat on its own. It connects WordPress to a Datalumo instance — the hosted service at datalumo.app or one you run yourself. + += What data is sent to Datalumo? = + +Only after you connect and enable a feature. Content sync sends the published posts you choose. Enhanced search sends visitor queries and optional result-click analytics. The chat and search widgets load Datalumo's script and talk to that instance. Visitor identity is off unless you turn it on. + += Can I use a self-hosted Datalumo instance? = + +Yes. Set the Datalumo URL on the Connection tab before you connect. + += Can the chatbot add products to a WooCommerce cart? = + +Yes, if WooCommerce is active. In Datalumo, add an Act on the page action named add_to_cart with a product_id field (the WordPress product id). The visitor confirms in chat. Variable products send colour, then that colour's sizes, as nested choice steps (up to 8 options per step); more than that opens the product page. + + == Changelog == += 0.0.14 = +* WooCommerce: colour and size are nested choice steps in one reply, so chat does not hop back to the server after the first pick. + += 0.0.13 = +* WooCommerce: variable products ask for colour, then size, then add the matching variation. + += 0.0.12 = +* WooCommerce: add-to-cart sends the variation's size and colour attributes, not only the variation id. + += 0.0.11 = +* Chat on a product page now sends that product id as page context, and add-to-cart uses the current product when the payload has no id. + += 0.0.10 = +* WooCommerce: a variable product asks the visitor to pick a variation in chat (up to 8). More than that links to the product page. + += 0.0.9 = +* WooCommerce: settle an add-to-cart chip through respond() only, so the success line is not stamped on an older message. + += 0.0.8 = +* WooCommerce: a confirmed chat action named add_to_cart adds the product to the visitor's cart. + = 0.0.7 = * Full sync: when the push completes, the plugin now tells Datalumo to start indexing right away instead of waiting for its next background sweep. Best-effort — older Datalumo versions without the endpoint are unaffected. @@ -57,5 +123,5 @@ Datalumo keeps a searchable, AI-ready copy of your WordPress content and gives y = 0.0.1 = * Initial public release. * Content sync, chat/search widgets, enhanced search, and visitor identity. -* GitHub auto-updates via Plugin Update Checker. +* GitHub auto-updates via Plugin Update Checker (removed ahead of the WordPress.org release). * Dutch and Chinese translations. diff --git a/resources/js/add-to-cart.js b/resources/js/add-to-cart.js new file mode 100644 index 0000000..7259383 --- /dev/null +++ b/resources/js/add-to-cart.js @@ -0,0 +1,140 @@ +/** + * Handles Datalumo host actions named add_to_cart: POST to WordPress so + * WooCommerce can add the item to this visitor's cart, then settle the chip. + */ +(function () { + 'use strict'; + + var config = window.datalumoAddToCart; + + if (! config) { + return; + } + + var eventName = config.event || 'add_to_cart'; + + window.addEventListener('datalumo:action', function (event) { + var detail = event.detail || {}; + + if (detail.name !== eventName) { + return; + } + + event.preventDefault(); + + var payload = withPageProduct( + detail.payload && typeof detail.payload === 'object' && ! Array.isArray(detail.payload) + ? detail.payload + : {}, + ); + + var body = new FormData(); + body.append('action', 'datalumo_add_to_cart'); + body.append('_ajax_nonce', config.nonce); + body.append('payload', JSON.stringify(payload)); + + fetch(config.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + body: body, + }) + .then(function (response) { + return response.json(); + }) + .then(function (json) { + var ok = Boolean(json && json.success); + var data = (json && json.data) || {}; + var message = typeof data.message === 'string' && data.message + ? data.message + : ((config.i18n && (ok ? '' : config.i18n.failed)) || ''); + + finish(detail, { + ok: ok, + message: message, + status: data.status, + layout: data.layout, + choices: data.choices, + url: data.url, + }); + + if (ok) { + refreshCart(data.fragments || {}, data.cart_hash || ''); + } + }) + .catch(function () { + finish(detail, { + ok: false, + message: (config.i18n && config.i18n.failed) || '', + }); + }); + }); + + function withPageProduct(payload) { + var next = {}; + var key; + + for (key in payload) { + if (Object.prototype.hasOwnProperty.call(payload, key)) { + next[key] = payload[key]; + } + } + + if (! hasProductId(next) && config.productId) { + next.product_id = String(config.productId); + } + + if (! next.page_url && typeof window.location === 'object' && window.location.href) { + next.page_url = window.location.href; + } + + return next; + } + + function hasProductId(payload) { + var keys = ['product_id', 'product', 'id', 'external_id']; + var i; + var value; + + for (i = 0; i < keys.length; i++) { + value = parseInt(payload[keys[i]], 10); + + if (value > 0) { + return true; + } + } + + return Boolean(payload.sku && String(payload.sku).trim()); + } + + function finish(detail, result) { + if (typeof detail.respond === 'function') { + detail.respond(result); + + return; + } + + window.dispatchEvent(new CustomEvent('datalumo:action-result', { + detail: { + answer_id: detail.answer_id, + tool_id: detail.tool_id, + ok: result.ok !== false, + message: result.message || '', + }, + })); + } + + function refreshCart(fragments, cartHash) { + if (typeof window.jQuery === 'undefined') { + return; + } + + var $ = window.jQuery; + + $.each(fragments, function (selector, html) { + $(selector).replaceWith(html); + }); + + $(document.body).trigger('added_to_cart', [fragments, cartHash, null]); + $(document.body).trigger('wc_fragment_refresh'); + } +})(); diff --git a/src/Embed/Embed.php b/src/Embed/Embed.php index 1a71620..cbf37f9 100644 --- a/src/Embed/Embed.php +++ b/src/Embed/Embed.php @@ -33,7 +33,7 @@ public function registerScript(): void self::SCRIPT_HANDLE, Options::baseUrl() . '/widget/v1/datalumo.js', [], - null, + DATALUMO_VERSION, ['in_footer' => true], ); } @@ -105,9 +105,58 @@ public function chatShortcode(array|string $attributes = []): string */ private function overrides(): array { + $overrides = []; $user = $this->identity(); + $context = $this->pageContext(); - return $user !== null ? ['user' => $user] : []; + if ($user !== null) { + $overrides['user'] = $user; + } + + if ($context !== []) { + $overrides['context'] = $context; + } + + return $overrides; + } + + /** + * Product page hints for chat. Merged with the widget's automatic + * page_url / page_title so "this product" has a real id to copy. + * + * @return array + */ + public function pageContext(): array + { + $context = []; + + if (function_exists('is_product') && is_product()) { + $id = (int) get_the_ID(); + + if ($id > 0) { + $context['product_id'] = (string) $id; + } + + if ($id > 0 && function_exists('wc_get_product')) { + $product = wc_get_product($id); + + if ($product && method_exists($product, 'get_sku')) { + $sku = trim((string) $product->get_sku()); + + if ($sku !== '') { + $context['sku'] = $sku; + } + } + } + } + + if (function_exists('apply_filters')) { + $filtered = apply_filters('datalumo_chat_context', $context); + + return is_array($filtered) ? $filtered : $context; + } + + return $context; } /** diff --git a/src/Integration/AddToCart.php b/src/Integration/AddToCart.php new file mode 100644 index 0000000..3e12f14 --- /dev/null +++ b/src/Integration/AddToCart.php @@ -0,0 +1,891 @@ + true], + ); + + wp_localize_script('datalumo-add-to-cart', 'datalumoAddToCart', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce(self::NONCE), + 'event' => $this->eventName(), + 'productId' => self::currentProductId(), + 'i18n' => [ + 'missingProduct' => __('No product was specified.', 'datalumo'), + 'failed' => __('Could not add that to the cart.', 'datalumo'), + ], + ]); + } + + public function eventName(): string + { + $event = apply_filters('datalumo_add_to_cart_event', self::DEFAULT_EVENT); + + return is_string($event) && $event !== '' ? $event : self::DEFAULT_EVENT; + } + + public function handle(): void + { + check_ajax_referer(self::NONCE); + + $result = $this->addFromPayload($this->requestPayload()); + + if ($result['ok']) { + wp_send_json_success($result); + } + + wp_send_json_error(array_filter([ + 'message' => $result['message'] ?? '', + 'status' => $result['status'] ?? null, + 'layout' => $result['layout'] ?? null, + 'choices' => $result['choices'] ?? null, + 'url' => $result['url'] ?? null, + ], fn ($value) => $value !== null && $value !== '' && $value !== [])); + } + + /** + * @param array $payload + * @return array{ok: bool, message: string, fragments?: array, cart_hash?: string} + */ + public function addFromPayload(array $payload): array + { + $payload = apply_filters('datalumo_add_to_cart_payload', $payload); + $payload = is_array($payload) ? $payload : []; + + $resolved = self::resolvePayload($payload); + $productId = $resolved['product_id']; + + if ($productId <= 0 && $resolved['sku'] !== '' && function_exists('wc_get_product_id_by_sku')) { + $productId = (int) wc_get_product_id_by_sku($resolved['sku']); + } + + if ($productId <= 0) { + $productId = self::productIdFromPageUrl($payload); + } + + if ($productId <= 0) { + return [ + 'ok' => false, + 'message' => __('No product was specified.', 'datalumo'), + ]; + } + + $variation = self::applyChoices($resolved['variation'], $payload); + + return $this->add($productId, $resolved['quantity'], $resolved['variation_id'], $variation); + } + + /** + * @param array $payload + * @return array{product_id: int, quantity: int, variation_id: int, sku: string, variation: array} + */ + public static function resolvePayload(array $payload): array + { + $quantity = self::firstPositiveInt($payload, ['quantity', 'qty']); + + return [ + 'product_id' => self::firstPositiveInt($payload, ['product_id', 'product', 'id', 'external_id']), + 'quantity' => $quantity > 0 ? min($quantity, 99) : 1, + 'variation_id' => self::firstPositiveInt($payload, ['choice', 'variation_id', 'variation']), + 'sku' => self::firstString($payload, ['sku']), + 'variation' => self::variationAttributes($payload), + ]; + } + + public static function currentProductId(): int + { + if (! function_exists('is_product') || ! is_product()) { + return 0; + } + + return (int) get_the_ID(); + } + + /** + * Merge attributes stored on a variation product into the payload map. + * + * @param array $variation + * @return array + */ + public static function attributesForVariation(int $variationId, array $variation): array + { + if ($variationId <= 0 || ! function_exists('wc_get_product')) { + return $variation; + } + + $product = wc_get_product($variationId); + + if (! $product || ! method_exists($product, 'get_variation_attributes')) { + return $variation; + } + + $fromProduct = $product->get_variation_attributes(); + $merged = []; + + foreach (is_array($fromProduct) ? $fromProduct : [] as $key => $value) { + if (! is_string($key) || ! is_scalar($value)) { + continue; + } + + $value = trim((string) $value); + + if ($value !== '') { + $merged[$key] = $value; + } + } + + foreach ($variation as $key => $value) { + if (! is_string($key) || ! is_scalar($value)) { + continue; + } + + $value = trim((string) $value); + + if ($value !== '') { + $merged[$key] = $value; + } + } + + return $merged; + } + + /** + * @param array $payload + */ + public static function productIdFromPageUrl(array $payload): int + { + $url = self::firstString($payload, ['page_url', 'url']); + + if ($url === '' || ! function_exists('url_to_postid')) { + return 0; + } + + $id = (int) url_to_postid($url); + + if ($id <= 0 || ! function_exists('wc_get_product')) { + return 0; + } + + return wc_get_product($id) ? $id : 0; + } + + /** + * @param array $variation + * @return array{ok: bool, message: string, fragments?: array, cart_hash?: string} + */ + public function add(int $productId, int $quantity, int $variationId = 0, array $variation = []): array + { + if (! function_exists('wc_get_product') || ! function_exists('WC')) { + return [ + 'ok' => false, + 'message' => __('WooCommerce is not available.', 'datalumo'), + ]; + } + + $parent = wc_get_product($productId); + + if (! $parent) { + return [ + 'ok' => false, + 'message' => __('That product could not be found.', 'datalumo'), + ]; + } + + if (method_exists($parent, 'is_type') && $parent->is_type('variable')) { + $resolved = $this->resolveVariableSelection($parent, $variationId, $variation); + + if (array_key_exists('ok', $resolved)) { + return $resolved; + } + + $variationId = $resolved['variation_id']; + $variation = $resolved['variation']; + } + + $product = wc_get_product($variationId > 0 ? $variationId : $productId); + + if (! $product) { + return [ + 'ok' => false, + 'message' => __('That product could not be found.', 'datalumo'), + ]; + } + + if (method_exists($product, 'is_purchasable') && ! $product->is_purchasable()) { + return [ + 'ok' => false, + 'message' => __('That product cannot be purchased.', 'datalumo'), + ]; + } + + $max = method_exists($product, 'get_max_purchase_quantity') + ? (int) $product->get_max_purchase_quantity() + : -1; + + if ($max > 0) { + $quantity = min($quantity, $max); + } + + if (function_exists('wc_load_cart') && WC()->cart === null) { + wc_load_cart(); + } + + if (! WC()->cart) { + return [ + 'ok' => false, + 'message' => __('Could not add that to the cart.', 'datalumo'), + ]; + } + + $variation = self::attributesForVariation($variationId, $variation); + + $added = WC()->cart->add_to_cart($productId, $quantity, $variationId, $variation); + + if (! $added) { + return [ + 'ok' => false, + 'message' => $this->cartErrorMessage(), + ]; + } + + $name = method_exists($product, 'get_name') + ? wp_strip_all_tags((string) $product->get_name()) + : ''; + + return [ + 'ok' => true, + 'message' => $name !== '' + ? sprintf(__('%s added to your cart.', 'datalumo'), $name) + : __('Added to your cart.', 'datalumo'), + 'fragments' => $this->cartFragments(), + 'cart_hash' => method_exists(WC()->cart, 'get_cart_hash') + ? (string) WC()->cart->get_cart_hash() + : '', + ]; + } + + /** + * Ask for one missing attribute at a time (colour, then size), then + * match the variation that has every picked value. + * + * @param array $variation + * @return array{variation_id: int, variation: array}|array{ok: false, message: string, status?: string, layout?: string, choices?: array>, url?: string} + */ + private function resolveVariableSelection(object $parent, int $variationId, array $variation): array + { + $rows = method_exists($parent, 'get_available_variations') + ? $parent->get_available_variations() + : []; + $rows = is_array($rows) ? $rows : []; + $variation = self::attributesForVariation($variationId, $variation); + $missing = self::missingAttributeKeys( + self::variationAttributeKeys($parent, $rows), + $variation, + ); + + if ($missing !== []) { + return $this->elicitAttributes($parent, $rows, $missing, $variation); + } + + if ($variationId <= 0) { + $variationId = self::matchVariationId($rows, $variation); + } + + if ($variationId <= 0) { + $url = method_exists($parent, 'get_permalink') + ? (string) $parent->get_permalink() + : ''; + + return [ + 'ok' => false, + 'message' => __('This product needs a variation.', 'datalumo'), + 'url' => $url, + ]; + } + + return [ + 'variation_id' => $variationId, + 'variation' => $variation, + ]; + } + + /** + * @param array $rows + * @param array $keys + * @param array $selected + * @return array{ok: false, message: string, status?: string, layout?: string, choices?: array>, url?: string} + */ + private function elicitAttributes(object $product, array $rows, array $keys, array $selected): array + { + $choices = self::choicesTree($rows, $keys, $selected, $product); + $url = method_exists($product, 'get_permalink') + ? (string) $product->get_permalink() + : ''; + + if ($choices === []) { + return [ + 'ok' => false, + 'message' => __('This product needs a variation.', 'datalumo'), + 'url' => $url, + ]; + } + + if (self::choicesExceedMax($choices)) { + return [ + 'ok' => false, + 'message' => __('This product has many options. Open the product to pick one.', 'datalumo'), + 'url' => $url, + ]; + } + + return [ + 'ok' => false, + 'status' => 'choices', + 'message' => sprintf(__('Which %s?', 'datalumo'), self::attributeLabel($keys[0])), + 'layout' => 'options', + 'choices' => $choices, + 'url' => $url, + ]; + } + + /** + * @param array $rows + * @return array + */ + public static function variationAttributeKeys(object $product, array $rows): array + { + if (method_exists($product, 'get_variation_attributes')) { + $attrs = $product->get_variation_attributes(); + + if (is_array($attrs) && $attrs !== []) { + $keys = []; + + foreach (array_keys($attrs) as $key) { + if (is_string($key) && $key !== '') { + $keys[] = self::attributeKey($key); + } + } + + if ($keys !== []) { + return $keys; + } + } + } + + $keys = []; + + foreach ($rows as $row) { + if (! is_array($row) || ! is_array($row['attributes'] ?? null)) { + continue; + } + + foreach (array_keys($row['attributes']) as $key) { + if (is_string($key) && $key !== '') { + $keys[$key] = true; + } + } + } + + return array_keys($keys); + } + + /** + * @param array $needed + * @param array $selected + * @return array + */ + public static function missingAttributeKeys(array $needed, array $selected): array + { + $missing = []; + + foreach ($needed as $key) { + if (trim((string) ($selected[$key] ?? '')) === '') { + $missing[] = $key; + } + } + + return $missing; + } + + /** + * @param array $selected + * @return array + */ + /** + * @param array $payload + * @return array + */ + public static function choicePath(array $payload): array + { + $choice = $payload['choice'] ?? null; + + if (is_array($choice)) { + $path = []; + + foreach ($choice as $item) { + if (is_string($item) && trim($item) !== '') { + $path[] = trim($item); + } + } + + return $path; + } + + if (is_string($choice) && trim($choice) !== '') { + return [trim($choice)]; + } + + return []; + } + + /** + * @param array $selected + * @param array $payload + * @return array + */ + public static function applyChoices(array $selected, array $payload): array + { + foreach (self::choicePath($payload) as $choice) { + $selected = self::applyChoice($selected, $choice); + } + + return $selected; + } + + public static function applyChoice(array $selected, string $choice): array + { + if ($choice === '' || ! str_contains($choice, ':')) { + return $selected; + } + + [$key, $value] = explode(':', $choice, 2); + $key = trim($key); + $value = trim($value); + + if (str_starts_with($key, 'attribute_') && $value !== '') { + $selected[$key] = $value; + } + + return $selected; + } + + /** + * @param array $rows + * @param array $keys + * @param array $selected + * @return array> + */ + public static function choicesTree(array $rows, array $keys, array $selected, object $product): array + { + if ($keys === []) { + return []; + } + + $key = $keys[0]; + $rest = array_slice($keys, 1); + $options = self::optionsForAttribute($rows, $key, $selected, $product); + + if ($rest === []) { + return $options; + } + + $nextMessage = sprintf(__('Which %s?', 'datalumo'), self::attributeLabel($rest[0])); + $tree = []; + + foreach ($options as $option) { + $id = (string) ($option['id'] ?? ''); + $value = str_contains($id, ':') ? trim(explode(':', $id, 2)[1]) : ''; + + if ($value === '') { + continue; + } + + $nested = self::choicesTree($rows, $rest, array_merge($selected, [$key => $value]), $product); + + if ($nested === []) { + continue; + } + + $option['message'] = $nextMessage; + $option['choices'] = $nested; + $tree[] = $option; + } + + return $tree; + } + + /** + * @param array> $choices + */ + public static function choicesExceedMax(array $choices): bool + { + if (count($choices) > self::MAX_CHOICES) { + return true; + } + + foreach ($choices as $choice) { + $nested = $choice['choices'] ?? null; + + if (is_array($nested) && $nested !== [] && self::choicesExceedMax($nested)) { + return true; + } + } + + return false; + } + + /** + * @param array $rows + * @param array $selected + * @return array + */ + public static function optionsForAttribute(array $rows, string $key, array $selected, object $product): array + { + $fromRows = []; + $any = false; + + foreach ($rows as $row) { + if (! is_array($row) || ! self::isAvailableVariation($row)) { + continue; + } + + if (! self::variationMatches($row, $selected)) { + continue; + } + + $value = trim((string) ($row['attributes'][$key] ?? '')); + + if ($value === '') { + $any = true; + + continue; + } + + $fromRows[$value] = self::optionLabel($value); + } + + if ($any || $fromRows === []) { + foreach (self::parentAttributeOptions($product, $key) as $slug => $label) { + $fromRows[$slug] = $label; + } + } + + $choices = []; + + foreach ($fromRows as $slug => $label) { + $choices[] = [ + 'id' => $key.':'.$slug, + 'label' => $label, + ]; + } + + return $choices; + } + + /** + * @param array $rows + * @param array $selected + */ + public static function matchVariationId(array $rows, array $selected): int + { + foreach ($rows as $row) { + if (! is_array($row) || ! self::isAvailableVariation($row)) { + continue; + } + + if (! self::variationMatches($row, $selected)) { + continue; + } + + return (int) $row['variation_id']; + } + + return 0; + } + + /** + * @param array $row + * @param array $selected + */ + public static function variationMatches(array $row, array $selected): bool + { + $attrs = is_array($row['attributes'] ?? null) ? $row['attributes'] : []; + + foreach ($selected as $key => $value) { + if (! is_string($key) || trim((string) $value) === '') { + continue; + } + + $rowValue = trim((string) ($attrs[$key] ?? '')); + + if ($rowValue !== '' && $rowValue !== trim((string) $value)) { + return false; + } + } + + return true; + } + + public static function attributeKey(string $name): string + { + return str_starts_with($name, 'attribute_') ? $name : 'attribute_'.$name; + } + + public static function attributeLabel(string $key): string + { + $name = str_starts_with($key, 'attribute_') ? substr($key, 10) : $key; + + if (function_exists('wc_attribute_label')) { + $label = wc_attribute_label($name); + + if (is_string($label) && trim($label) !== '') { + return trim($label); + } + } + + if (str_starts_with($name, 'pa_')) { + $name = substr($name, 3); + } + + return self::optionLabel($name); + } + + public static function optionLabel(string $value): string + { + return ucfirst(str_replace(['-', '_'], ' ', $value)); + } + + /** + * @param array $row + */ + private static function isAvailableVariation(array $row): bool + { + if ((int) ($row['variation_id'] ?? 0) <= 0) { + return false; + } + + if (array_key_exists('is_in_stock', $row) && ! $row['is_in_stock']) { + return false; + } + + if (array_key_exists('is_purchasable', $row) && ! $row['is_purchasable']) { + return false; + } + + return true; + } + + /** + * @return array + */ + private static function parentAttributeOptions(object $product, string $key): array + { + if (! method_exists($product, 'get_variation_attributes')) { + return []; + } + + $attrs = $product->get_variation_attributes(); + + if (! is_array($attrs)) { + return []; + } + + $raw = $attrs[$key] ?? $attrs[self::unprefixedAttributeKey($key)] ?? null; + + if (! is_array($raw)) { + return []; + } + + $options = []; + + foreach ($raw as $index => $value) { + if (is_int($index) || (is_string($index) && ctype_digit((string) $index))) { + $slug = trim((string) $value); + + if ($slug !== '') { + $options[$slug] = self::optionLabel($slug); + } + + continue; + } + + $slug = trim((string) $index); + $label = is_scalar($value) ? trim((string) $value) : ''; + + if ($slug !== '') { + $options[$slug] = $label !== '' ? $label : self::optionLabel($slug); + } + } + + return $options; + } + + private static function unprefixedAttributeKey(string $key): string + { + return str_starts_with($key, 'attribute_') ? substr($key, 10) : $key; + } + + /** + * @return array + */ + private function requestPayload(): array + { + // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified in handle(). + $raw = wp_unslash($_POST['payload'] ?? ''); + // phpcs:enable WordPress.Security.NonceVerification.Missing + + if (is_string($raw) && $raw !== '') { + $decoded = json_decode($raw, true); + + if (is_array($decoded)) { + return $decoded; + } + } + + return []; + } + + /** + * @return array + */ + private function cartFragments(): array + { + if (! function_exists('woocommerce_mini_cart')) { + return []; + } + + ob_start(); + woocommerce_mini_cart(); + $miniCart = (string) ob_get_clean(); + + $fragments = apply_filters('woocommerce_add_to_cart_fragments', [ + 'div.widget_shopping_cart_content' => '
' . $miniCart . '
', + ]); + + return is_array($fragments) ? $fragments : []; + } + + private function cartErrorMessage(): string + { + if (function_exists('wc_get_notices')) { + $notices = wc_get_notices('error'); + + if (function_exists('wc_clear_notices')) { + wc_clear_notices(); + } + + $first = is_array($notices) ? ($notices[0]['notice'] ?? '') : ''; + + if (is_string($first) && $first !== '') { + return wp_strip_all_tags($first); + } + } + + return __('Could not add that to the cart.', 'datalumo'); + } + + /** + * @param array $payload + * @param array $keys + */ + private static function firstPositiveInt(array $payload, array $keys): int + { + foreach ($keys as $key) { + if (! array_key_exists($key, $payload)) { + continue; + } + + $value = $payload[$key]; + + if (is_int($value) && $value > 0) { + return $value; + } + + if (is_float($value) && $value > 0 && $value === floor($value)) { + return (int) $value; + } + + if (is_string($value)) { + $value = trim($value); + + if ($value !== '' && ctype_digit($value)) { + return (int) $value; + } + } + } + + return 0; + } + + /** + * @param array $payload + * @param array $keys + */ + private static function firstString(array $payload, array $keys): string + { + foreach ($keys as $key) { + if (! array_key_exists($key, $payload)) { + continue; + } + + $value = $payload[$key]; + + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + + return ''; + } + + /** + * @param array $payload + * @return array + */ + private static function variationAttributes(array $payload): array + { + $attributes = []; + + foreach ($payload as $key => $value) { + if (! is_string($key) || ! str_starts_with($key, 'attribute_')) { + continue; + } + + if (! is_scalar($value)) { + continue; + } + + $attributes[$key] = (string) $value; + } + + return $attributes; + } +} diff --git a/src/Integration/WooCommerce.php b/src/Integration/WooCommerce.php index 1894c68..4767637 100644 --- a/src/Integration/WooCommerce.php +++ b/src/Integration/WooCommerce.php @@ -6,8 +6,9 @@ /** * WooCommerce search behaviour on the interceptor's hooks: catalog sorts, - * hidden-product exclusion, and the price-filter widget. Registered only - * when WooCommerce is active. + * hidden-product exclusion, and the price-filter widget. Also registers + * add-to-cart for Datalumo host actions. Registered only when WooCommerce + * is active. */ class WooCommerce { @@ -20,6 +21,8 @@ public function register(): void { add_filter('datalumo_sort_map', [$this, 'sortMap']); add_filter('datalumo_resolve_args', [$this, 'resolveArgs'], 10, 2); + + (new AddToCart())->register(); } /** @@ -90,8 +93,13 @@ private function excludeHidden(array $args): array */ private function applyPriceFilter(array $args): array { - $min = isset($_GET['min_price']) && is_numeric($_GET['min_price']) ? (float) $_GET['min_price'] : null; - $max = isset($_GET['max_price']) && is_numeric($_GET['max_price']) ? (float) $_GET['max_price'] : null; + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- public WooCommerce price-filter query args. + $minRaw = isset($_GET['min_price']) ? sanitize_text_field(wp_unslash($_GET['min_price'])) : ''; + $maxRaw = isset($_GET['max_price']) ? sanitize_text_field(wp_unslash($_GET['max_price'])) : ''; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + $min = $minRaw !== '' && is_numeric($minRaw) ? (float) $minRaw : null; + $max = $maxRaw !== '' && is_numeric($maxRaw) ? (float) $maxRaw : null; if ($min === null && $max === null) { return $args; @@ -106,6 +114,7 @@ private function applyPriceFilter(array $args): array } $existing = $args['meta_query'] ?? []; + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- WooCommerce price filter on the result pool. $args['meta_query'] = $existing ? ['relation' => 'AND', $existing, $clause] : [$clause]; return $args; diff --git a/tests/Unit/AddToCartTest.php b/tests/Unit/AddToCartTest.php new file mode 100644 index 0000000..5520135 --- /dev/null +++ b/tests/Unit/AddToCartTest.php @@ -0,0 +1,466 @@ +register(); + + expect(Actions\has('wp_enqueue_scripts'))->not->toBeFalse() + ->and(Actions\has('wp_ajax_datalumo_add_to_cart'))->not->toBeFalse() + ->and(Actions\has('wp_ajax_nopriv_datalumo_add_to_cart'))->not->toBeFalse(); +}); + +it('registers add-to-cart when the WooCommerce integration boots', function () { + (new WooCommerce())->register(); + + expect(Actions\has('wp_ajax_datalumo_add_to_cart'))->not->toBeFalse() + ->and(Actions\has('wp_ajax_nopriv_datalumo_add_to_cart'))->not->toBeFalse(); +}); + +it('localises the frontend script with the event name and nonce', function () { + Functions\when('apply_filters')->returnArg(2); + Functions\when('is_product')->justReturn(false); + Functions\when('admin_url')->justReturn('https://example.com/wp-admin/admin-ajax.php'); + Functions\when('wp_create_nonce')->justReturn('test-nonce'); + Functions\when('wp_enqueue_script')->justReturn(true); + + $localised = null; + Functions\when('wp_localize_script')->alias(function ($handle, $name, $data) use (&$localised) { + $localised = ['handle' => $handle, 'name' => $name, 'data' => $data]; + + return true; + }); + + (new AddToCart())->enqueue(); + + expect($localised['handle'])->toBe('datalumo-add-to-cart') + ->and($localised['name'])->toBe('datalumoAddToCart') + ->and($localised['data']['event'])->toBe('add_to_cart') + ->and($localised['data']['nonce'])->toBe('test-nonce') + ->and($localised['data']['productId'])->toBe(0) + ->and($localised['data']['ajaxUrl'])->toBe('https://example.com/wp-admin/admin-ajax.php'); +}); + +it('localises the current product id on a product page', function () { + Functions\when('apply_filters')->returnArg(2); + Functions\when('is_product')->justReturn(true); + Functions\when('get_the_ID')->justReturn(1842); + Functions\when('admin_url')->justReturn('https://example.com/wp-admin/admin-ajax.php'); + Functions\when('wp_create_nonce')->justReturn('test-nonce'); + Functions\when('wp_enqueue_script')->justReturn(true); + + $localised = null; + Functions\when('wp_localize_script')->alias(function ($handle, $name, $data) use (&$localised) { + $localised = $data; + + return true; + }); + + (new AddToCart())->enqueue(); + + expect($localised['productId'])->toBe(1842); +}); + +it('reads product_id aliases and defaults quantity to 1', function (array $payload, array $expected) { + expect(AddToCart::resolvePayload($payload))->toMatchArray($expected); +})->with([ + 'product_id string' => [ + ['product_id' => '1842'], + ['product_id' => 1842, 'quantity' => 1, 'variation_id' => 0, 'sku' => ''], + ], + 'external_id' => [ + ['external_id' => '99', 'qty' => '3'], + ['product_id' => 99, 'quantity' => 3, 'variation_id' => 0, 'sku' => ''], + ], + 'id + sku + variation' => [ + ['id' => 12, 'sku' => 'MUG-BLU', 'variation_id' => 44, 'quantity' => 2], + ['product_id' => 12, 'quantity' => 2, 'variation_id' => 44, 'sku' => 'MUG-BLU'], + ], + 'choice is the variation id' => [ + ['product_id' => 1842, 'choice' => '99'], + ['product_id' => 1842, 'quantity' => 1, 'variation_id' => 99, 'sku' => ''], + ], + 'encoded attribute choice is not a variation id' => [ + ['product_id' => 1842, 'choice' => 'attribute_pa_color:blue'], + ['product_id' => 1842, 'quantity' => 1, 'variation_id' => 0, 'sku' => ''], + ], + 'choice path is not a variation id' => [ + ['product_id' => 1842, 'choice' => ['attribute_pa_color:blue', 'attribute_pa_size:small']], + ['product_id' => 1842, 'quantity' => 1, 'variation_id' => 0, 'sku' => ''], + ], + 'caps quantity' => [ + ['product_id' => 1, 'quantity' => 500], + ['product_id' => 1, 'quantity' => 99], + ], + 'ignores non-numeric id' => [ + ['id' => 'abc', 'sku' => 'SKU-1'], + ['product_id' => 0, 'sku' => 'SKU-1'], + ], +]); + +it('collects variation attributes from the payload', function () { + $resolved = AddToCart::resolvePayload([ + 'product_id' => 10, + 'attribute_pa_color' => 'blue', + 'label' => 'ignored', + ]); + + expect($resolved['variation'])->toBe(['attribute_pa_color' => 'blue']); +}); + +it('fails when no product id or sku can be resolved', function () { + Functions\when('apply_filters')->returnArg(2); + + expect((new AddToCart())->addFromPayload(['label' => 'mug'])) + ->toMatchArray(['ok' => false, 'message' => 'No product was specified.']); +}); + +it('resolves the product from the page url when the payload has no id', function () { + Functions\when('apply_filters')->returnArg(2); + Functions\when('url_to_postid')->justReturn(1842); + Functions\when('wc_get_product')->alias(fn ($id) => (int) $id === 1842 ? purchasableProduct() : null); + stubCart(fn () => 'item-key'); + + $result = (new AddToCart())->addFromPayload([ + 'page_url' => 'https://store.test/product/v-neck-t-shirt', + ]); + + expect($result['ok'])->toBeTrue() + ->and($result['message'])->toBe('Blue mug added to your cart.'); +}); + +it('resolves a sku when product_id is missing', function () { + Functions\when('apply_filters')->returnArg(2); + Functions\when('wc_get_product_id_by_sku')->justReturn(77); + Functions\when('wc_get_product')->alias(fn ($id) => (int) $id === 77 ? purchasableProduct() : null); + stubCart(fn () => 'item-key'); + + $result = (new AddToCart())->addFromPayload(['sku' => 'MUG-BLU']); + + expect($result['ok'])->toBeTrue() + ->and($result['message'])->toBe('Blue mug added to your cart.'); +}); + +it('rejects a missing product', function () { + Functions\when('wc_get_product')->justReturn(false); + Functions\when('WC')->justReturn((object) ['cart' => (object) []]); + + expect((new AddToCart())->add(1842, 1)) + ->toMatchArray(['ok' => false, 'message' => 'That product could not be found.']); +}); + +it('rejects a variable product without a variation', function () { + Functions\when('wc_get_product')->justReturn(purchasableProduct(variable: true)); + Functions\when('WC')->justReturn((object) ['cart' => (object) []]); + + expect((new AddToCart())->add(1842, 1)) + ->toMatchArray(['ok' => false, 'message' => 'This product needs a variation.']); +}); + +it('asks for colour then size then adds the matching variation', function () { + $parent = purchasableProduct( + variable: true, + variations: [ + [ + 'variation_id' => 11, + 'attributes' => ['attribute_pa_color' => 'blue', 'attribute_pa_size' => 'small'], + 'is_in_stock' => true, + 'is_purchasable' => true, + ], + [ + 'variation_id' => 12, + 'attributes' => ['attribute_pa_color' => 'white', 'attribute_pa_size' => 'small'], + 'is_in_stock' => true, + 'is_purchasable' => true, + ], + ], + permalink: 'https://store.test/vneck', + attributeOptions: [ + 'pa_color' => ['blue', 'white'], + 'pa_size' => ['small', 'large'], + ], + ); + + Functions\when('apply_filters')->returnArg(2); + Functions\when('wc_get_product')->alias(fn ($id) => (int) $id === 1842 ? $parent : purchasableProduct()); + Functions\when('WC')->justReturn((object) ['cart' => (object) []]); + + $first = (new AddToCart())->addFromPayload(['product_id' => 1842]); + + expect($first)->toMatchArray([ + 'ok' => false, + 'status' => 'choices', + 'layout' => 'options', + 'message' => 'Which Color?', + 'url' => 'https://store.test/vneck', + 'choices' => [ + [ + 'id' => 'attribute_pa_color:blue', + 'label' => 'Blue', + 'message' => 'Which Size?', + 'choices' => [ + ['id' => 'attribute_pa_size:small', 'label' => 'Small'], + ], + ], + [ + 'id' => 'attribute_pa_color:white', + 'label' => 'White', + 'message' => 'Which Size?', + 'choices' => [ + ['id' => 'attribute_pa_size:small', 'label' => 'Small'], + ], + ], + ], + ]); + + $second = (new AddToCart())->addFromPayload([ + 'product_id' => 1842, + 'choice' => 'attribute_pa_color:blue', + ]); + + expect($second)->toMatchArray([ + 'ok' => false, + 'status' => 'choices', + 'message' => 'Which Size?', + 'choices' => [ + ['id' => 'attribute_pa_size:small', 'label' => 'Small'], + ], + ]); + + $seen = null; + stubCart(function ($productId, $quantity, $variationId, $variation) use (&$seen) { + $seen = [$productId, $quantity, $variationId, $variation]; + + return 'item-key'; + }); + + $added = (new AddToCart())->addFromPayload([ + 'product_id' => 1842, + 'choice' => ['attribute_pa_color:blue', 'attribute_pa_size:small'], + ]); + + expect($added['ok'])->toBeTrue() + ->and($seen)->toBe([ + 1842, + 1, + 11, + [ + 'attribute_pa_color' => 'blue', + 'attribute_pa_size' => 'small', + ], + ]); +}); + +it('turns a long variation list into a product link', function () { + $rows = []; + + for ($i = 1; $i <= 9; $i++) { + $rows[] = [ + 'variation_id' => $i, + 'attributes' => ['attribute_pa_size' => 's'.$i], + 'is_in_stock' => true, + 'is_purchasable' => true, + ]; + } + + Functions\when('wc_get_product')->justReturn(purchasableProduct( + variable: true, + variations: $rows, + permalink: 'https://store.test/vneck', + )); + Functions\when('WC')->justReturn((object) ['cart' => (object) []]); + + expect((new AddToCart())->add(1842, 1))->toMatchArray([ + 'ok' => false, + 'message' => 'This product has many options. Open the product to pick one.', + 'url' => 'https://store.test/vneck', + ]); +}); + +it('rejects a product that is not purchasable', function () { + Functions\when('wc_get_product')->justReturn(purchasableProduct(purchasable: false)); + Functions\when('WC')->justReturn((object) ['cart' => (object) []]); + + expect((new AddToCart())->add(1842, 1)) + ->toMatchArray(['ok' => false, 'message' => 'That product cannot be purchased.']); +}); + +it('fills variation attributes from the variation product', function () { + Functions\when('wc_get_product')->alias(function ($id) { + if ((int) $id === 99) { + return new class + { + public function is_purchasable(): bool + { + return true; + } + + public function is_type(string $type): bool + { + return $type === 'variation'; + } + + public function get_name(): string + { + return 'V-Neck T-Shirt - Blue, Large'; + } + + public function get_max_purchase_quantity(): int + { + return -1; + } + + public function get_variation_attributes(): array + { + return [ + 'attribute_pa_color' => 'blue', + 'attribute_pa_size' => 'large', + ]; + } + }; + } + + return purchasableProduct(variable: true); + }); + + $seen = null; + stubCart(function ($productId, $quantity, $variationId, $variation) use (&$seen) { + $seen = [$productId, $quantity, $variationId, $variation]; + + return 'item-key'; + }); + Functions\when('wp_strip_all_tags')->returnArg(1); + + expect((new AddToCart())->add(1842, 1, 99)['ok'])->toBeTrue() + ->and($seen)->toBe([ + 1842, + 1, + 99, + [ + 'attribute_pa_color' => 'blue', + 'attribute_pa_size' => 'large', + ], + ]); +}); + +it('adds a simple product to the cart', function () { + Functions\when('wc_get_product')->justReturn(purchasableProduct()); + stubCart(fn ($productId, $quantity, $variationId, $variation) => ( + $productId === 1842 && $quantity === 2 && $variationId === 0 && $variation === [] + ? 'item-key' + : false + )); + + $result = (new AddToCart())->add(1842, 2); + + expect($result['ok'])->toBeTrue() + ->and($result['message'])->toBe('Blue mug added to your cart.') + ->and($result['cart_hash'])->toBe('cart-hash') + ->and($result['fragments'])->toBe([]); +}); + +it('surfaces the WooCommerce notice when add_to_cart fails', function () { + Functions\when('wc_get_product')->justReturn(purchasableProduct()); + stubCart(fn () => false); + Functions\when('wc_get_notices')->justReturn([['notice' => 'Out of stock.']]); + Functions\when('wc_clear_notices')->justReturn(true); + + expect((new AddToCart())->add(1842, 1)) + ->toMatchArray(['ok' => false, 'message' => 'Out of stock.']); +}); + +function purchasableProduct( + bool $purchasable = true, + bool $variable = false, + array $variations = [], + string $permalink = '', + array $attributeOptions = [], +): object { + return new class($purchasable, $variable, $variations, $permalink, $attributeOptions) + { + /** + * @param array $variations + * @param array $attributeOptions + */ + public function __construct( + private bool $purchasable, + private bool $variable, + private array $variations, + private string $permalink, + private array $attributeOptions, + ) {} + + public function is_purchasable(): bool + { + return $this->purchasable; + } + + public function is_type(string $type): bool + { + return $this->variable && $type === 'variable'; + } + + public function get_name(): string + { + return 'Blue mug'; + } + + public function get_max_purchase_quantity(): int + { + return -1; + } + + /** + * @return array + */ + public function get_available_variations(): array + { + return $this->variations; + } + + public function get_permalink(): string + { + return $this->permalink; + } + + /** + * @return array + */ + public function get_variation_attributes(): array + { + return $this->attributeOptions; + } + }; +} + +function stubCart(callable $addToCart): void +{ + $cart = new class($addToCart) + { + public function __construct(private $addToCart) {} + + public function add_to_cart(int $productId, int $quantity = 1, int $variationId = 0, array $variation = []): string|false + { + return ($this->addToCart)($productId, $quantity, $variationId, $variation); + } + + public function get_cart_hash(): string + { + return 'cart-hash'; + } + }; + + Functions\when('WC')->justReturn((object) ['cart' => $cart]); + Functions\when('wp_strip_all_tags')->returnArg(1); +} diff --git a/tests/Unit/EmbedTest.php b/tests/Unit/EmbedTest.php new file mode 100644 index 0000000..e70c389 --- /dev/null +++ b/tests/Unit/EmbedTest.php @@ -0,0 +1,29 @@ +justReturn(false); + Functions\when('apply_filters')->returnArg(2); + + expect((new Embed())->pageContext())->toBe([]); +}); + +it('puts the current product id in chat context on a product page', function () { + Functions\when('is_product')->justReturn(true); + Functions\when('get_the_ID')->justReturn(1842); + Functions\when('wc_get_product')->justReturn(new class + { + public function get_sku(): string + { + return 'VNECK-BLU'; + } + }); + Functions\when('apply_filters')->returnArg(2); + + expect((new Embed())->pageContext())->toBe([ + 'product_id' => '1842', + 'sku' => 'VNECK-BLU', + ]); +}); From 8e2d746e16cf0c3a251276b8a23639492c607d93 Mon Sep 17 00:00:00 2001 From: Jeffrey van Rossum Date: Fri, 28 Aug 2026 13:27:55 +0900 Subject: [PATCH 2/6] Handle view cart, checkout, page, and form host actions. Confirmed chat events open the Woo cart or checkout, a published page, or a form. Product and other singular pages send page_id as context. Co-authored-by: Cursor --- datalumo.php | 4 +- readme.txt | 16 +- resources/js/host-navigation.js | 253 ++++++++++++++++++++++++++ src/Embed/Embed.php | 8 + src/Integration/HostNavigation.php | 278 +++++++++++++++++++++++++++++ src/Plugin.php | 1 + tests/Unit/EmbedTest.php | 16 +- tests/Unit/HostNavigationTest.php | 96 ++++++++++ 8 files changed, 666 insertions(+), 6 deletions(-) create mode 100644 resources/js/host-navigation.js create mode 100644 src/Integration/HostNavigation.php create mode 100644 tests/Unit/HostNavigationTest.php diff --git a/datalumo.php b/datalumo.php index 0c3c671..0a60f9f 100644 --- a/datalumo.php +++ b/datalumo.php @@ -7,7 +7,7 @@ * Plugin Name: Datalumo * Plugin URI: https://github.com/datalumo/wordpress * Description: Sync your WordPress content to Datalumo and add AI-powered search and chat to your site. - * Version: 0.0.14 + * Version: 0.0.15 * Requires at least: 6.0 * Requires PHP: 8.1 * Author: Datalumo @@ -25,7 +25,7 @@ } if (! defined('DATALUMO_VERSION')) { - define('DATALUMO_VERSION', '0.0.14'); + define('DATALUMO_VERSION', '0.0.15'); define('DATALUMO_FILE', __FILE__); define('DATALUMO_DIR', __DIR__); define('DATALUMO_URL', plugin_dir_url(__FILE__)); diff --git a/readme.txt b/readme.txt index fb98601..da94b5b 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: search, ai, chatbot, rag Requires at least: 6.0 Tested up to: 7.1 Requires PHP: 8.1 -Stable tag: 0.0.14 +Stable tag: 0.0.15 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -19,7 +19,7 @@ Datalumo keeps a searchable, AI-ready copy of your WordPress content and gives y * **Search box** — drop-in search via the `[datalumo_search]` shortcode. * **Enhanced search** — serve WordPress' native search results from Datalumo's ranking, with an optional streamed AI summary above the results. Falls back to native search automatically. * **Visitor identity** — logged-in users can continue their chat conversations across visits, verified with a server-side signature. -* **WooCommerce add to cart**: when WooCommerce is active, a confirmed chat action named `add_to_cart` adds the product to the visitor's cart. +* **Chat page actions**: the official plugin listens for confirmed chat events (`add_to_cart`, `view_cart`, `open_checkout`, `open_page`, `start_form`). Add those actions in Datalumo under Reply → What it can do. This plugin is an interface to a [Datalumo](https://datalumo.app) instance. You need a Datalumo account (or a self-hosted instance), an API token, and — for chat or search widgets — a widget key. Nothing is sent until an administrator connects the site and turns a feature on. @@ -70,11 +70,21 @@ Yes. Set the Datalumo URL on the Connection tab before you connect. = Can the chatbot add products to a WooCommerce cart? = -Yes, if WooCommerce is active. In Datalumo, add an Act on the page action named add_to_cart with a product_id field (the WordPress product id). The visitor confirms in chat. Variable products send colour, then that colour's sizes, as nested choice steps (up to 8 options per step); more than that opens the product page. +Yes, if WooCommerce is active. In Datalumo, add the WordPress Add to cart action (event add_to_cart, field product_id). The visitor confirms in chat. Variable products send colour, then that colour's sizes, as nested choice steps (up to 8 options per step); more than that opens the product page. + += What other chat actions does the plugin handle? = + +View cart and Open checkout (WooCommerce), Open this page (a post or page by id, slug, or same-site URL), and Start a form (scrolls to a form on this page, or opens the page that has it). Add them from Reply → Plugin actions → WordPress. == Changelog == += 0.0.15 = +* Chat: view_cart and open_checkout open the WooCommerce cart or checkout. +* Chat: open_page opens a published post or page (id, slug, or same-site URL). +* Chat: start_form scrolls to a form on this page, or opens the page that has it. +* Product and other singular pages send page_id as chat context. + = 0.0.14 = * WooCommerce: colour and size are nested choice steps in one reply, so chat does not hop back to the server after the first pick. diff --git a/resources/js/host-navigation.js b/resources/js/host-navigation.js new file mode 100644 index 0000000..857b5c7 --- /dev/null +++ b/resources/js/host-navigation.js @@ -0,0 +1,253 @@ +/** + * Handles Datalumo host actions that open a page: cart, checkout, a + * WordPress post, or a form on this page (or another page). + */ +(function () { + 'use strict'; + + var config = window.datalumoHostNavigation; + + if (! config) { + return; + } + + var events = config.events || ['view_cart', 'open_checkout', 'open_page', 'start_form']; + + window.addEventListener('datalumo:action', function (event) { + var detail = event.detail || {}; + + if (events.indexOf(detail.name) === -1) { + return; + } + + event.preventDefault(); + + var payload = isPlainObject(detail.payload) ? detail.payload : {}; + + if (detail.name === 'view_cart') { + go(detail, config.cartUrl, (config.i18n && config.i18n.no_cart) || ''); + + return; + } + + if (detail.name === 'open_checkout') { + go(detail, config.checkoutUrl, (config.i18n && config.i18n.no_checkout) || ''); + + return; + } + + if (detail.name === 'start_form') { + var form = findForm(payload); + + if (form) { + scrollToForm(form); + finish(detail, { ok: true, message: '' }); + + return; + } + } + + resolveThenGo(detail, payload); + }); + + bootPendingForm(); + + function resolveThenGo(detail, payload) { + var body = new FormData(); + var request = {}; + var key; + + for (key in payload) { + if (Object.prototype.hasOwnProperty.call(payload, key)) { + request[key] = payload[key]; + } + } + + request.event = detail.name; + body.append('action', 'datalumo_host_navigation'); + body.append('_ajax_nonce', config.nonce); + body.append('payload', JSON.stringify(request)); + + fetch(config.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + body: body, + }) + .then(function (response) { + return response.json(); + }) + .then(function (json) { + var data = (json && json.data) || {}; + var url = typeof data.url === 'string' ? data.url : ''; + + if (json && json.success && isSameSite(url)) { + finish(detail, { ok: true, message: '' }); + window.location.assign(url); + + return; + } + + finish(detail, { + ok: false, + message: (typeof data.message === 'string' && data.message) + || (config.i18n && config.i18n.failed) + || '', + }); + }) + .catch(function () { + finish(detail, { + ok: false, + message: (config.i18n && config.i18n.failed) || '', + }); + }); + } + + function go(detail, url, missingMessage) { + if (! isSameSite(url)) { + finish(detail, { ok: false, message: missingMessage || (config.i18n && config.i18n.failed) || '' }); + + return; + } + + finish(detail, { ok: true, message: '' }); + window.location.assign(url); + } + + function findForm(payload) { + var selector = sanitizeSelector(payload.selector); + var el; + + if (selector) { + el = document.querySelector(selector); + + if (el) { + return el; + } + } + + var id = String(payload.form_id || '').replace(/[^\w-]/g, ''); + + if (id) { + el = firstMatch([ + '#' + id, + '#wpcf7-f' + id, + '#wpforms-' + id, + '#gform_' + id, + '#gform_wrapper_' + id, + ]); + + if (el) { + return el; + } + } + + return document.querySelector( + 'form.wpcf7-form, form.wpforms-form, .gform_wrapper form, form:not([role="search"]):not(.search-form):not(.woocommerce-cart-form):not(.woocommerce-checkout)', + ); + } + + function firstMatch(selectors) { + var i; + var el; + + for (i = 0; i < selectors.length; i++) { + el = document.querySelector(selectors[i]); + + if (el) { + return el; + } + } + + return null; + } + + function sanitizeSelector(value) { + var selector = String(value || '').trim(); + + if (selector === '') { + return ''; + } + + if (/^#?[A-Za-z][\w-]*$/.test(selector)) { + return selector.charAt(0) === '#' ? selector : '#' + selector; + } + + if (/^\.[A-Za-z][\w-]*$/.test(selector)) { + return selector; + } + + if (/^[A-Za-z][\w-]*(\.[A-Za-z][\w-]*)?(#[A-Za-z][\w-]*)?$/.test(selector)) { + return selector; + } + + return ''; + } + + function scrollToForm(el) { + if (typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + + var focusable = el.matches && el.matches('input, textarea, select') + ? el + : el.querySelector('input:not([type="hidden"]), textarea, select'); + + if (focusable && typeof focusable.focus === 'function') { + focusable.focus({ preventScroll: true }); + } + } + + function bootPendingForm() { + var params = new URLSearchParams(window.location.search); + var pending = params.get('datalumo_form') || ''; + var hash = (window.location.hash || '').replace(/^#/, ''); + var form = findForm({ + form_id: pending || (hash && hash !== 'datalumo-form' ? hash : ''), + }); + + if (form && (pending || hash === 'datalumo-form' || hash)) { + scrollToForm(form); + } + } + + function isSameSite(url) { + if (! url || typeof url !== 'string') { + return false; + } + + try { + var parsed = new URL(url, window.location.href); + var home = String(config.homeHost || window.location.hostname).toLowerCase(); + var host = parsed.hostname.toLowerCase(); + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + + return host === home || host === 'www.' + home || 'www.' + host === home; + } catch (error) { + return false; + } + } + + function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && ! Array.isArray(value); + } + + function finish(detail, result) { + if (typeof detail.respond === 'function') { + detail.respond(result); + + return; + } + + window.dispatchEvent(new CustomEvent('datalumo:action-result', { + detail: { + answer_id: detail.answer_id, + tool_id: detail.tool_id, + ok: result.ok !== false, + message: result.message || '', + }, + })); + } +})(); diff --git a/src/Embed/Embed.php b/src/Embed/Embed.php index cbf37f9..8dc20db 100644 --- a/src/Embed/Embed.php +++ b/src/Embed/Embed.php @@ -130,6 +130,14 @@ public function pageContext(): array { $context = []; + if (function_exists('is_singular') && is_singular()) { + $pageId = (int) get_the_ID(); + + if ($pageId > 0) { + $context['page_id'] = (string) $pageId; + } + } + if (function_exists('is_product') && is_product()) { $id = (int) get_the_ID(); diff --git a/src/Integration/HostNavigation.php b/src/Integration/HostNavigation.php new file mode 100644 index 0000000..846815c --- /dev/null +++ b/src/Integration/HostNavigation.php @@ -0,0 +1,278 @@ + true], + ); + + wp_localize_script('datalumo-host-navigation', 'datalumoHostNavigation', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce(self::NONCE), + 'events' => self::EVENTS, + 'cartUrl' => $this->cartUrl(), + 'checkoutUrl' => $this->checkoutUrl(), + 'homeHost' => $this->homeHost(), + 'i18n' => [ + 'failed' => __('Could not open that page.', 'datalumo'), + 'no_page' => __('No page was specified.', 'datalumo'), + 'no_form' => __('No form was found on this page.', 'datalumo'), + 'no_cart' => __('The cart is not available.', 'datalumo'), + 'no_checkout' => __('Checkout is not available.', 'datalumo'), + ], + ]); + } + + public function handle(): void + { + check_ajax_referer(self::NONCE); + + $payload = $this->requestPayload(); + $event = sanitize_key((string) ($payload['event'] ?? '')); + + if (! in_array($event, ['open_page', 'start_form'], true)) { + wp_send_json_error(['message' => __('Unknown action.', 'datalumo')], 400); + } + + $url = $this->resolveUrl($event, $payload); + + if ($url === null) { + wp_send_json_error([ + 'message' => $event === 'start_form' + ? __('No form was found on this page.', 'datalumo') + : __('No page was specified.', 'datalumo'), + ], 422); + } + + wp_send_json_success(['url' => $url]); + } + + /** + * @param array $payload + */ + public function resolveUrl(string $event, array $payload): ?string + { + if ($event === 'view_cart') { + return $this->cartUrl() ?: null; + } + + if ($event === 'open_checkout') { + return $this->checkoutUrl() ?: null; + } + + $url = $this->publishedUrlFromPayload($payload); + + if ($url === null) { + return null; + } + + if ($event === 'start_form') { + return $this->withFormHint($url, $payload); + } + + return $url; + } + + /** + * @param array $payload + */ + public function publishedUrlFromPayload(array $payload): ?string + { + $url = trim((string) ($payload['url'] ?? '')); + + if ($url !== '' && $this->isSameSiteUrl($url)) { + return $url; + } + + $id = $this->positiveId( + $payload['page_id'] ?? $payload['id'] ?? $payload['external_id'] ?? null, + ); + + if ($id !== null) { + $permalink = $this->publishedPermalink($id); + + if ($permalink !== null) { + return $permalink; + } + } + + $slug = trim((string) ($payload['slug'] ?? '')); + + if ($slug !== '' && function_exists('get_page_by_path')) { + $page = get_page_by_path($slug); + + if (is_object($page) && isset($page->ID)) { + return $this->publishedPermalink((int) $page->ID); + } + } + + return null; + } + + public function isSameSiteUrl(string $url): bool + { + $parts = parse_url($url); + + if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) { + return false; + } + + if (! in_array(strtolower((string) $parts['scheme']), ['http', 'https'], true)) { + return false; + } + + $host = strtolower((string) $parts['host']); + $home = $this->homeHost(); + + if ($home === '' || $host === '') { + return false; + } + + return $host === $home || $host === 'www.' . $home || 'www.' . $host === $home; + } + + public function sanitizeSelector(string $selector): string + { + $selector = trim($selector); + + if ($selector === '') { + return ''; + } + + if (preg_match('/^#?[A-Za-z][\w-]*$/', $selector) === 1) { + return $selector; + } + + if (preg_match('/^\.[A-Za-z][\w-]*$/', $selector) === 1) { + return $selector; + } + + if (preg_match('/^[A-Za-z][\w-]*(\.[A-Za-z][\w-]*)?(#[A-Za-z][\w-]*)?$/', $selector) === 1) { + return $selector; + } + + return ''; + } + + public function cartUrl(): string + { + if (! function_exists('wc_get_cart_url')) { + return ''; + } + + $url = (string) wc_get_cart_url(); + + return $this->isSameSiteUrl($url) ? $url : ''; + } + + public function checkoutUrl(): string + { + if (! function_exists('wc_get_checkout_url')) { + return ''; + } + + $url = (string) wc_get_checkout_url(); + + return $this->isSameSiteUrl($url) ? $url : ''; + } + + private function homeHost(): string + { + $home = function_exists('home_url') ? home_url('/') : ''; + $host = parse_url($home, PHP_URL_HOST); + + return is_string($host) ? strtolower($host) : ''; + } + + private function publishedPermalink(int $id): ?string + { + if ($id <= 0 || ! function_exists('get_post') || ! function_exists('get_permalink')) { + return null; + } + + $post = get_post($id); + + if (! is_object($post) || ($post->post_status ?? '') !== 'publish') { + return null; + } + + $permalink = (string) get_permalink($post); + + return $this->isSameSiteUrl($permalink) ? $permalink : null; + } + + private function positiveId(mixed $value): ?int + { + if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) { + $id = (int) $value; + + return $id > 0 ? $id : null; + } + + return null; + } + + /** + * @param array $payload + */ + private function withFormHint(string $url, array $payload): string + { + $formId = preg_replace('/[^\w-]/', '', (string) ($payload['form_id'] ?? '')) ?? ''; + + if ($formId === '') { + return $this->appendHash($url, 'datalumo-form'); + } + + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url . $separator . 'datalumo_form=' . rawurlencode($formId); + } + + private function appendHash(string $url, string $hash): string + { + $base = preg_replace('/#.*$/', '', $url) ?? $url; + + return $base . '#' . $hash; + } + + /** + * @return array + */ + private function requestPayload(): array + { + $raw = isset($_POST['payload']) ? wp_unslash($_POST['payload']) : ''; + + if (! is_string($raw) || $raw === '') { + return []; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : []; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 16fab40..f2585db 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -54,6 +54,7 @@ public function boot(): void (new Summary())->register(); (new ClickTracking())->register(); (new Embed())->register(); + (new Integration\HostNavigation())->register(); add_action('admin_notices', [$this, 'configurationNotice']); } diff --git a/tests/Unit/EmbedTest.php b/tests/Unit/EmbedTest.php index e70c389..9440927 100644 --- a/tests/Unit/EmbedTest.php +++ b/tests/Unit/EmbedTest.php @@ -3,15 +3,28 @@ use Brain\Monkey\Functions; use Datalumo\Wp\Embed\Embed; -it('sends no product context off a product page', function () { +it('sends no page context off a singular view', function () { Functions\when('is_product')->justReturn(false); + Functions\when('is_singular')->justReturn(false); Functions\when('apply_filters')->returnArg(2); expect((new Embed())->pageContext())->toBe([]); }); +it('puts the current page id in chat context on a singular view', function () { + Functions\when('is_product')->justReturn(false); + Functions\when('is_singular')->justReturn(true); + Functions\when('get_the_ID')->justReturn(42); + Functions\when('apply_filters')->returnArg(2); + + expect((new Embed())->pageContext())->toBe([ + 'page_id' => '42', + ]); +}); + it('puts the current product id in chat context on a product page', function () { Functions\when('is_product')->justReturn(true); + Functions\when('is_singular')->justReturn(true); Functions\when('get_the_ID')->justReturn(1842); Functions\when('wc_get_product')->justReturn(new class { @@ -23,6 +36,7 @@ public function get_sku(): string Functions\when('apply_filters')->returnArg(2); expect((new Embed())->pageContext())->toBe([ + 'page_id' => '1842', 'product_id' => '1842', 'sku' => 'VNECK-BLU', ]); diff --git a/tests/Unit/HostNavigationTest.php b/tests/Unit/HostNavigationTest.php new file mode 100644 index 0000000..63bec2d --- /dev/null +++ b/tests/Unit/HostNavigationTest.php @@ -0,0 +1,96 @@ +justReturn('https://shop.test/'); + + return new HostNavigation(); +} + +it('registers the ajax handlers and script hook', function () { + (new HostNavigation())->register(); + + expect(Actions\has('wp_enqueue_scripts'))->not->toBeFalse() + ->and(Actions\has('wp_ajax_datalumo_host_navigation'))->not->toBeFalse() + ->and(Actions\has('wp_ajax_nopriv_datalumo_host_navigation'))->not->toBeFalse(); +}); + +it('resolves the WooCommerce cart and checkout urls', function () { + Functions\when('wc_get_cart_url')->justReturn('https://shop.test/cart/'); + Functions\when('wc_get_checkout_url')->justReturn('https://shop.test/checkout/'); + + $nav = hostNav(); + + expect($nav->resolveUrl('view_cart', []))->toBe('https://shop.test/cart/') + ->and($nav->resolveUrl('open_checkout', []))->toBe('https://shop.test/checkout/'); +}); + +it('rejects an off-site cart url', function () { + Functions\when('wc_get_cart_url')->justReturn('https://evil.test/cart/'); + + expect(hostNav()->resolveUrl('view_cart', []))->toBeNull(); +}); + +it('opens a published page by id', function () { + Functions\when('get_post')->justReturn((object) ['ID' => 42, 'post_status' => 'publish']); + Functions\when('get_permalink')->justReturn('https://shop.test/about/'); + + expect(hostNav()->publishedUrlFromPayload(['page_id' => '42']))->toBe('https://shop.test/about/'); +}); + +it('does not open a draft page', function () { + Functions\when('get_post')->justReturn((object) ['ID' => 42, 'post_status' => 'draft']); + Functions\when('get_permalink')->justReturn('https://shop.test/?p=42&preview=true'); + + expect(hostNav()->publishedUrlFromPayload(['page_id' => '42']))->toBeNull(); +}); + +it('opens a same-site url and rejects javascript or off-site urls', function () { + $nav = hostNav(); + + expect($nav->publishedUrlFromPayload(['url' => 'https://shop.test/contact/']))->toBe('https://shop.test/contact/') + ->and($nav->isSameSiteUrl('javascript:alert(1)'))->toBeFalse() + ->and($nav->isSameSiteUrl('https://evil.test/contact/'))->toBeFalse(); +}); + +it('opens a page by slug', function () { + Functions\when('get_page_by_path')->justReturn((object) ['ID' => 9]); + Functions\when('get_post')->justReturn((object) ['ID' => 9, 'post_status' => 'publish']); + Functions\when('get_permalink')->justReturn('https://shop.test/contact/'); + + expect(hostNav()->publishedUrlFromPayload(['slug' => 'contact']))->toBe('https://shop.test/contact/'); +}); + +it('appends a form hint when opening another page for start_form', function () { + Functions\when('get_post')->justReturn((object) ['ID' => 9, 'post_status' => 'publish']); + Functions\when('get_permalink')->justReturn('https://shop.test/contact/'); + + expect(hostNav()->resolveUrl('start_form', [ + 'page_id' => '9', + 'form_id' => 'contact', + ]))->toBe('https://shop.test/contact/?datalumo_form=contact'); +}); + +it('only allows simple selectors', function (string $selector, string $expected) { + expect(hostNav()->sanitizeSelector($selector))->toBe($expected); +})->with([ + 'id' => ['#contact', '#contact'], + 'bare id' => ['contact', 'contact'], + 'class' => ['.wpcf7-form', '.wpcf7-form'], + 'tag and class' => ['form.wpcf7-form', 'form.wpcf7-form'], + 'attribute' => ['form[action*="evil"]', ''], + 'wildcard' => ['*', ''], + 'js proto' => ['javascript:alert(1)', ''], +]); From f40e26a599f9f076cd503e2ea91963bbdaf68390 Mon Sep 17 00:00:00 2001 From: Jeffrey van Rossum Date: Fri, 28 Aug 2026 13:29:29 +0900 Subject: [PATCH 3/6] Send the host-action run token with late results. The embed only applies datalumo:action-result to the in-flight confirm, so the fallback event must include run. Co-authored-by: Cursor --- resources/js/add-to-cart.js | 1 + resources/js/host-navigation.js | 1 + 2 files changed, 2 insertions(+) diff --git a/resources/js/add-to-cart.js b/resources/js/add-to-cart.js index 7259383..51730fb 100644 --- a/resources/js/add-to-cart.js +++ b/resources/js/add-to-cart.js @@ -117,6 +117,7 @@ detail: { answer_id: detail.answer_id, tool_id: detail.tool_id, + run: detail.run, ok: result.ok !== false, message: result.message || '', }, diff --git a/resources/js/host-navigation.js b/resources/js/host-navigation.js index 857b5c7..906c638 100644 --- a/resources/js/host-navigation.js +++ b/resources/js/host-navigation.js @@ -245,6 +245,7 @@ detail: { answer_id: detail.answer_id, tool_id: detail.tool_id, + run: detail.run, ok: result.ok !== false, message: result.message || '', }, From f88cc07295fdbe4c45f8f9e3547048a4d66b117d Mon Sep 17 00:00:00 2001 From: Jeffrey van Rossum Date: Fri, 28 Aug 2026 14:00:21 +0900 Subject: [PATCH 4/6] Drop start_form and bust local assets with a timestamp. Guessing a form on the current page was not useful; Open this page covers contact flows. Local WP_ENVIRONMENT_TYPE uses time() for script cache busting instead of bumping the plugin version. Co-authored-by: Cursor --- datalumo.php | 4 +- readme.txt | 33 ++------ resources/js/host-navigation.js | 116 +---------------------------- src/Embed/Embed.php | 3 +- src/Integration/AddToCart.php | 4 +- src/Integration/HostNavigation.php | 75 +++---------------- src/Search/ClickTracking.php | 3 +- src/Search/Summary.php | 5 +- src/Support/Assets.php | 19 +++++ tests/Unit/AddToCartTest.php | 2 +- tests/Unit/AssetsTest.php | 28 +++++++ tests/Unit/HostNavigationTest.php | 24 +----- 12 files changed, 81 insertions(+), 235 deletions(-) create mode 100644 src/Support/Assets.php create mode 100644 tests/Unit/AssetsTest.php diff --git a/datalumo.php b/datalumo.php index 0a60f9f..b1f5641 100644 --- a/datalumo.php +++ b/datalumo.php @@ -7,7 +7,7 @@ * Plugin Name: Datalumo * Plugin URI: https://github.com/datalumo/wordpress * Description: Sync your WordPress content to Datalumo and add AI-powered search and chat to your site. - * Version: 0.0.15 + * Version: 0.0.8 * Requires at least: 6.0 * Requires PHP: 8.1 * Author: Datalumo @@ -25,7 +25,7 @@ } if (! defined('DATALUMO_VERSION')) { - define('DATALUMO_VERSION', '0.0.15'); + define('DATALUMO_VERSION', '0.0.8'); define('DATALUMO_FILE', __FILE__); define('DATALUMO_DIR', __DIR__); define('DATALUMO_URL', plugin_dir_url(__FILE__)); diff --git a/readme.txt b/readme.txt index da94b5b..9ab6579 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: search, ai, chatbot, rag Requires at least: 6.0 Tested up to: 7.1 Requires PHP: 8.1 -Stable tag: 0.0.15 +Stable tag: 0.0.8 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -19,7 +19,7 @@ Datalumo keeps a searchable, AI-ready copy of your WordPress content and gives y * **Search box** — drop-in search via the `[datalumo_search]` shortcode. * **Enhanced search** — serve WordPress' native search results from Datalumo's ranking, with an optional streamed AI summary above the results. Falls back to native search automatically. * **Visitor identity** — logged-in users can continue their chat conversations across visits, verified with a server-side signature. -* **Chat page actions**: the official plugin listens for confirmed chat events (`add_to_cart`, `view_cart`, `open_checkout`, `open_page`, `start_form`). Add those actions in Datalumo under Reply → What it can do. +* **Chat page actions**: the official plugin listens for confirmed chat events (`add_to_cart`, `view_cart`, `open_checkout`, `open_page`). Add those actions in Datalumo under Reply → What it can do. This plugin is an interface to a [Datalumo](https://datalumo.app) instance. You need a Datalumo account (or a self-hosted instance), an API token, and — for chat or search widgets — a widget key. Nothing is sent until an administrator connects the site and turns a feature on. @@ -74,37 +74,14 @@ Yes, if WooCommerce is active. In Datalumo, add the WordPress Add to cart action = What other chat actions does the plugin handle? = -View cart and Open checkout (WooCommerce), Open this page (a post or page by id, slug, or same-site URL), and Start a form (scrolls to a form on this page, or opens the page that has it). Add them from Reply → Plugin actions → WordPress. +View cart and Open checkout (WooCommerce), and Open this page (a post or page by id, slug, or same-site URL). Add them from Reply → Plugin actions → WordPress. == Changelog == -= 0.0.15 = -* Chat: view_cart and open_checkout open the WooCommerce cart or checkout. -* Chat: open_page opens a published post or page (id, slug, or same-site URL). -* Chat: start_form scrolls to a form on this page, or opens the page that has it. -* Product and other singular pages send page_id as chat context. - -= 0.0.14 = -* WooCommerce: colour and size are nested choice steps in one reply, so chat does not hop back to the server after the first pick. - -= 0.0.13 = -* WooCommerce: variable products ask for colour, then size, then add the matching variation. - -= 0.0.12 = -* WooCommerce: add-to-cart sends the variation's size and colour attributes, not only the variation id. - -= 0.0.11 = -* Chat on a product page now sends that product id as page context, and add-to-cart uses the current product when the payload has no id. - -= 0.0.10 = -* WooCommerce: a variable product asks the visitor to pick a variation in chat (up to 8). More than that links to the product page. - -= 0.0.9 = -* WooCommerce: settle an add-to-cart chip through respond() only, so the success line is not stamped on an older message. - = 0.0.8 = -* WooCommerce: a confirmed chat action named add_to_cart adds the product to the visitor's cart. +* Chat page actions: add_to_cart (WooCommerce, including variable products with nested colour/size choices), view_cart, open_checkout, and open_page. +* Product and other singular pages send page_id (and product_id on products) as chat context. = 0.0.7 = * Full sync: when the push completes, the plugin now tells Datalumo to start indexing right away instead of waiting for its next background sweep. Best-effort — older Datalumo versions without the endpoint are unaffected. diff --git a/resources/js/host-navigation.js b/resources/js/host-navigation.js index 906c638..5fac3ec 100644 --- a/resources/js/host-navigation.js +++ b/resources/js/host-navigation.js @@ -1,6 +1,6 @@ /** - * Handles Datalumo host actions that open a page: cart, checkout, a - * WordPress post, or a form on this page (or another page). + * Handles Datalumo host actions that open a page: cart, checkout, or a + * WordPress post. */ (function () { 'use strict'; @@ -11,7 +11,7 @@ return; } - var events = config.events || ['view_cart', 'open_checkout', 'open_page', 'start_form']; + var events = config.events || ['view_cart', 'open_checkout', 'open_page']; window.addEventListener('datalumo:action', function (event) { var detail = event.detail || {}; @@ -36,22 +36,9 @@ return; } - if (detail.name === 'start_form') { - var form = findForm(payload); - - if (form) { - scrollToForm(form); - finish(detail, { ok: true, message: '' }); - - return; - } - } - resolveThenGo(detail, payload); }); - bootPendingForm(); - function resolveThenGo(detail, payload) { var body = new FormData(); var request = {}; @@ -113,103 +100,6 @@ window.location.assign(url); } - function findForm(payload) { - var selector = sanitizeSelector(payload.selector); - var el; - - if (selector) { - el = document.querySelector(selector); - - if (el) { - return el; - } - } - - var id = String(payload.form_id || '').replace(/[^\w-]/g, ''); - - if (id) { - el = firstMatch([ - '#' + id, - '#wpcf7-f' + id, - '#wpforms-' + id, - '#gform_' + id, - '#gform_wrapper_' + id, - ]); - - if (el) { - return el; - } - } - - return document.querySelector( - 'form.wpcf7-form, form.wpforms-form, .gform_wrapper form, form:not([role="search"]):not(.search-form):not(.woocommerce-cart-form):not(.woocommerce-checkout)', - ); - } - - function firstMatch(selectors) { - var i; - var el; - - for (i = 0; i < selectors.length; i++) { - el = document.querySelector(selectors[i]); - - if (el) { - return el; - } - } - - return null; - } - - function sanitizeSelector(value) { - var selector = String(value || '').trim(); - - if (selector === '') { - return ''; - } - - if (/^#?[A-Za-z][\w-]*$/.test(selector)) { - return selector.charAt(0) === '#' ? selector : '#' + selector; - } - - if (/^\.[A-Za-z][\w-]*$/.test(selector)) { - return selector; - } - - if (/^[A-Za-z][\w-]*(\.[A-Za-z][\w-]*)?(#[A-Za-z][\w-]*)?$/.test(selector)) { - return selector; - } - - return ''; - } - - function scrollToForm(el) { - if (typeof el.scrollIntoView === 'function') { - el.scrollIntoView({ block: 'center', behavior: 'smooth' }); - } - - var focusable = el.matches && el.matches('input, textarea, select') - ? el - : el.querySelector('input:not([type="hidden"]), textarea, select'); - - if (focusable && typeof focusable.focus === 'function') { - focusable.focus({ preventScroll: true }); - } - } - - function bootPendingForm() { - var params = new URLSearchParams(window.location.search); - var pending = params.get('datalumo_form') || ''; - var hash = (window.location.hash || '').replace(/^#/, ''); - var form = findForm({ - form_id: pending || (hash && hash !== 'datalumo-form' ? hash : ''), - }); - - if (form && (pending || hash === 'datalumo-form' || hash)) { - scrollToForm(form); - } - } - function isSameSite(url) { if (! url || typeof url !== 'string') { return false; diff --git a/src/Embed/Embed.php b/src/Embed/Embed.php index 8dc20db..08a2976 100644 --- a/src/Embed/Embed.php +++ b/src/Embed/Embed.php @@ -2,6 +2,7 @@ namespace Datalumo\Wp\Embed; +use Datalumo\Wp\Support\Assets; use Datalumo\Wp\Support\Options; /** @@ -33,7 +34,7 @@ public function registerScript(): void self::SCRIPT_HANDLE, Options::baseUrl() . '/widget/v1/datalumo.js', [], - DATALUMO_VERSION, + Assets::version(), ['in_footer' => true], ); } diff --git a/src/Integration/AddToCart.php b/src/Integration/AddToCart.php index 3e12f14..12471a7 100644 --- a/src/Integration/AddToCart.php +++ b/src/Integration/AddToCart.php @@ -2,6 +2,8 @@ namespace Datalumo\Wp\Integration; +use Datalumo\Wp\Support\Assets; + /** * Listens for a Datalumo host action named add_to_cart and adds the * product to the visitor's WooCommerce cart (their browser session). @@ -29,7 +31,7 @@ public function enqueue(): void 'datalumo-add-to-cart', DATALUMO_URL . 'resources/js/add-to-cart.js', [], - DATALUMO_VERSION, + Assets::version(), ['in_footer' => true], ); diff --git a/src/Integration/HostNavigation.php b/src/Integration/HostNavigation.php index 846815c..8f918b8 100644 --- a/src/Integration/HostNavigation.php +++ b/src/Integration/HostNavigation.php @@ -2,9 +2,11 @@ namespace Datalumo\Wp\Integration; +use Datalumo\Wp\Support\Assets; + /** * Listens for Datalumo host actions that open a page: the cart, checkout, - * a WordPress post, or a form on the current (or another) page. + * or a WordPress post. */ class HostNavigation { @@ -12,7 +14,7 @@ class HostNavigation public const NONCE = 'datalumo_host_navigation'; - public const EVENTS = ['view_cart', 'open_checkout', 'open_page', 'start_form']; + public const EVENTS = ['view_cart', 'open_checkout', 'open_page']; public function register(): void { @@ -27,7 +29,7 @@ public function enqueue(): void 'datalumo-host-navigation', DATALUMO_URL . 'resources/js/host-navigation.js', [], - DATALUMO_VERSION, + Assets::version(), ['in_footer' => true], ); @@ -41,7 +43,6 @@ public function enqueue(): void 'i18n' => [ 'failed' => __('Could not open that page.', 'datalumo'), 'no_page' => __('No page was specified.', 'datalumo'), - 'no_form' => __('No form was found on this page.', 'datalumo'), 'no_cart' => __('The cart is not available.', 'datalumo'), 'no_checkout' => __('Checkout is not available.', 'datalumo'), ], @@ -55,18 +56,14 @@ public function handle(): void $payload = $this->requestPayload(); $event = sanitize_key((string) ($payload['event'] ?? '')); - if (! in_array($event, ['open_page', 'start_form'], true)) { + if ($event !== 'open_page') { wp_send_json_error(['message' => __('Unknown action.', 'datalumo')], 400); } $url = $this->resolveUrl($event, $payload); if ($url === null) { - wp_send_json_error([ - 'message' => $event === 'start_form' - ? __('No form was found on this page.', 'datalumo') - : __('No page was specified.', 'datalumo'), - ], 422); + wp_send_json_error(['message' => __('No page was specified.', 'datalumo')], 422); } wp_send_json_success(['url' => $url]); @@ -85,17 +82,11 @@ public function resolveUrl(string $event, array $payload): ?string return $this->checkoutUrl() ?: null; } - $url = $this->publishedUrlFromPayload($payload); - - if ($url === null) { - return null; + if ($event === 'open_page') { + return $this->publishedUrlFromPayload($payload); } - if ($event === 'start_form') { - return $this->withFormHint($url, $payload); - } - - return $url; + return null; } /** @@ -156,29 +147,6 @@ public function isSameSiteUrl(string $url): bool return $host === $home || $host === 'www.' . $home || 'www.' . $host === $home; } - public function sanitizeSelector(string $selector): string - { - $selector = trim($selector); - - if ($selector === '') { - return ''; - } - - if (preg_match('/^#?[A-Za-z][\w-]*$/', $selector) === 1) { - return $selector; - } - - if (preg_match('/^\.[A-Za-z][\w-]*$/', $selector) === 1) { - return $selector; - } - - if (preg_match('/^[A-Za-z][\w-]*(\.[A-Za-z][\w-]*)?(#[A-Za-z][\w-]*)?$/', $selector) === 1) { - return $selector; - } - - return ''; - } - public function cartUrl(): string { if (! function_exists('wc_get_cart_url')) { @@ -237,29 +205,6 @@ private function positiveId(mixed $value): ?int return null; } - /** - * @param array $payload - */ - private function withFormHint(string $url, array $payload): string - { - $formId = preg_replace('/[^\w-]/', '', (string) ($payload['form_id'] ?? '')) ?? ''; - - if ($formId === '') { - return $this->appendHash($url, 'datalumo-form'); - } - - $separator = str_contains($url, '?') ? '&' : '?'; - - return $url . $separator . 'datalumo_form=' . rawurlencode($formId); - } - - private function appendHash(string $url, string $hash): string - { - $base = preg_replace('/#.*$/', '', $url) ?? $url; - - return $base . '#' . $hash; - } - /** * @return array */ diff --git a/src/Search/ClickTracking.php b/src/Search/ClickTracking.php index a7949da..a8129eb 100644 --- a/src/Search/ClickTracking.php +++ b/src/Search/ClickTracking.php @@ -3,6 +3,7 @@ namespace Datalumo\Wp\Search; use Datalumo\Wp\Embed\Embed; +use Datalumo\Wp\Support\Assets; use Datalumo\Wp\Support\Options; /** @@ -51,7 +52,7 @@ public function enqueue(): void 'datalumo-clicks', DATALUMO_URL . 'resources/js/click-tracking.js', [Embed::SCRIPT_HANDLE], - DATALUMO_VERSION, + Assets::version(), ['in_footer' => true], ); diff --git a/src/Search/Summary.php b/src/Search/Summary.php index dc61f70..7aac9a3 100644 --- a/src/Search/Summary.php +++ b/src/Search/Summary.php @@ -3,6 +3,7 @@ namespace Datalumo\Wp\Search; use Datalumo\Wp\Embed\Embed; +use Datalumo\Wp\Support\Assets; use Datalumo\Wp\Support\Options; /** @@ -50,14 +51,14 @@ public function enqueue(): void 'datalumo-summary', DATALUMO_URL . 'resources/css/summary.css', [], - DATALUMO_VERSION, + Assets::version(), ); wp_enqueue_script( 'datalumo-summary', DATALUMO_URL . 'resources/js/summary.js', [Embed::SCRIPT_HANDLE], - DATALUMO_VERSION, + Assets::version(), ['in_footer' => true], ); diff --git a/src/Support/Assets.php b/src/Support/Assets.php new file mode 100644 index 0000000..abb00fe --- /dev/null +++ b/src/Support/Assets.php @@ -0,0 +1,19 @@ +toMatch('/^\d+$/') + ->and((int) Assets::version())->toBeGreaterThanOrEqual(time() - 1); + } finally { + putenv('WP_ENVIRONMENT_TYPE'); + } +}); + +it('uses the plugin version outside local', function () { + putenv('WP_ENVIRONMENT_TYPE=production'); + + try { + expect(Assets::version())->toBe(DATALUMO_VERSION); + } finally { + putenv('WP_ENVIRONMENT_TYPE'); + } +}); diff --git a/tests/Unit/HostNavigationTest.php b/tests/Unit/HostNavigationTest.php index 63bec2d..2daedb0 100644 --- a/tests/Unit/HostNavigationTest.php +++ b/tests/Unit/HostNavigationTest.php @@ -9,7 +9,7 @@ } if (! defined('DATALUMO_VERSION')) { - define('DATALUMO_VERSION', '0.0.15'); + define('DATALUMO_VERSION', '0.0.8'); } function hostNav(): HostNavigation @@ -73,24 +73,6 @@ function hostNav(): HostNavigation expect(hostNav()->publishedUrlFromPayload(['slug' => 'contact']))->toBe('https://shop.test/contact/'); }); -it('appends a form hint when opening another page for start_form', function () { - Functions\when('get_post')->justReturn((object) ['ID' => 9, 'post_status' => 'publish']); - Functions\when('get_permalink')->justReturn('https://shop.test/contact/'); - - expect(hostNav()->resolveUrl('start_form', [ - 'page_id' => '9', - 'form_id' => 'contact', - ]))->toBe('https://shop.test/contact/?datalumo_form=contact'); +it('ignores start_form', function () { + expect(hostNav()->resolveUrl('start_form', ['page_id' => '9']))->toBeNull(); }); - -it('only allows simple selectors', function (string $selector, string $expected) { - expect(hostNav()->sanitizeSelector($selector))->toBe($expected); -})->with([ - 'id' => ['#contact', '#contact'], - 'bare id' => ['contact', 'contact'], - 'class' => ['.wpcf7-form', '.wpcf7-form'], - 'tag and class' => ['form.wpcf7-form', 'form.wpcf7-form'], - 'attribute' => ['form[action*="evil"]', ''], - 'wildcard' => ['*', ''], - 'js proto' => ['javascript:alert(1)', ''], -]); From 17a0dbebf12465b157c57aebcc9172b02b2eb4cb Mon Sep 17 00:00:00 2001 From: Jeffrey van Rossum Date: Sat, 29 Aug 2026 00:30:10 +0900 Subject: [PATCH 5/6] Add Connect with Datalumo and richer Woo product sync. Owners can grant access from WordPress instead of pasting keys. Products now send short description, categories, tags, visible attributes, and SKU. Co-authored-by: Cursor --- bin/ensure-index-php | 49 ++++ bin/fresh-site | 139 +++++++++++ bin/fresh-site-seed.php | 185 ++++++++++++++ bin/index.php | 2 + composer.json | 4 +- index.php | 2 + languages/index.php | 2 + readme.txt | 16 +- resources/css/admin.css | 260 ++++++++++++++++++- resources/css/index.php | 2 + resources/index.php | 2 + resources/js/admin.js | 47 +++- resources/js/index.php | 2 + resources/views/index.php | 2 + resources/views/settings.php | 399 +++++++++++++++++++++++------- src/Admin/Ajax.php | 36 +-- src/Admin/SettingsPage.php | 132 +++++++++- src/Admin/index.php | 2 + src/Api/Client.php | 51 +++- src/Api/index.php | 2 + src/Connect/Grant.php | 284 +++++++++++++++++++++ src/Connect/index.php | 2 + src/Embed/index.php | 2 + src/Integration/WooCommerce.php | 265 ++++++++++++++++++++ src/Integration/index.php | 2 + src/Plugin.php | 2 + src/Search/index.php | 2 + src/Support/Credentials.php | 24 ++ src/Support/index.php | 2 + src/Sync/PagePreparer.php | 47 +++- src/Sync/index.php | 2 + src/index.php | 2 + tests/Unit/ClientConnectTest.php | 52 ++++ tests/Unit/CredentialsTest.php | 20 ++ tests/Unit/GrantTest.php | 104 ++++++++ tests/Unit/IndexPhpTest.php | 35 +++ tests/Unit/PagePreparerTest.php | 28 +++ tests/Unit/SettingsPageTest.php | 73 ++++++ tests/Unit/WooCommerceSkuTest.php | 230 +++++++++++++++++ tests/Unit/index.php | 2 + tests/index.php | 2 + 41 files changed, 2351 insertions(+), 167 deletions(-) create mode 100755 bin/ensure-index-php create mode 100755 bin/fresh-site create mode 100644 bin/fresh-site-seed.php create mode 100644 bin/index.php create mode 100644 index.php create mode 100644 languages/index.php create mode 100644 resources/css/index.php create mode 100644 resources/index.php create mode 100644 resources/js/index.php create mode 100644 resources/views/index.php create mode 100644 src/Admin/index.php create mode 100644 src/Api/index.php create mode 100644 src/Connect/Grant.php create mode 100644 src/Connect/index.php create mode 100644 src/Embed/index.php create mode 100644 src/Integration/index.php create mode 100644 src/Search/index.php create mode 100644 src/Support/Credentials.php create mode 100644 src/Support/index.php create mode 100644 src/Sync/index.php create mode 100644 src/index.php create mode 100644 tests/Unit/ClientConnectTest.php create mode 100644 tests/Unit/CredentialsTest.php create mode 100644 tests/Unit/GrantTest.php create mode 100644 tests/Unit/IndexPhpTest.php create mode 100644 tests/Unit/SettingsPageTest.php create mode 100644 tests/Unit/WooCommerceSkuTest.php create mode 100644 tests/Unit/index.php create mode 100644 tests/index.php diff --git a/bin/ensure-index-php b/bin/ensure-index-php new file mode 100755 index 0000000..cfd748b --- /dev/null +++ b/bin/ensure-index-php @@ -0,0 +1,49 @@ +#!/usr/bin/env php +isDir()) { + return true; + } + + $name = $current->getFilename(); + + if (in_array($name, $skip, true)) { + return false; + } + + return ! str_contains($current->getPathname(), DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'bin'); + }, + ), + RecursiveIteratorIterator::SELF_FIRST, +); + +$dirs = [$root]; + +foreach ($iterator as $file) { + if ($file->isDir()) { + $dirs[] = $file->getPathname(); + } +} + +foreach ($dirs as $dir) { + $index = $dir.DIRECTORY_SEPARATOR.'index.php'; + + if (is_file($index)) { + continue; + } + + file_put_contents($index, $contents); +} diff --git a/bin/fresh-site b/bin/fresh-site new file mode 100755 index 0000000..9d0dc1d --- /dev/null +++ b/bin/fresh-site @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Spin up a clean WordPress + WooCommerce site on SQLite, with this plugin +# linked in, dummy posts/products, and admin / password. +# +# bin/fresh-site +# bin/fresh-site --force +# SITE_DIR=~/Sites/datalumo-fresh bin/fresh-site +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SITE_DIR="${SITE_DIR:-$HOME/Sites/datalumo-fresh}" +SITE_SLUG="$(basename "$SITE_DIR")" +SITE_URL="${SITE_URL:-http://${SITE_SLUG}.test}" +FORCE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --force) FORCE=1; shift ;; + --url) SITE_URL="$2"; shift 2 ;; + --dir) SITE_DIR="$2"; SITE_SLUG="$(basename "$SITE_DIR")"; shift 2 ;; + -h|--help) + echo "Usage: bin/fresh-site [--force] [--dir PATH] [--url URL]" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +WP=(wp --path="$SITE_DIR" --allow-root) +export WP_CLI_PHP_ARGS="${WP_CLI_PHP_ARGS:--d memory_limit=512M}" + +need() { + command -v "$1" >/dev/null 2>&1 || { + echo "Missing $1." >&2 + exit 1 + } +} + +need wp +need php +need curl +need unzip + +if ! php -r 'exit(extension_loaded("pdo_sqlite") ? 0 : 1);'; then + echo "PHP is missing pdo_sqlite." >&2 + exit 1 +fi + +case "$SITE_DIR" in + /|"$HOME"|"$HOME/"|/) + echo "Refusing to use $SITE_DIR as the site path." >&2 + exit 1 + ;; +esac + +if [[ -e "$SITE_DIR" ]]; then + if [[ "$FORCE" -ne 1 ]]; then + echo "$SITE_DIR already exists. Re-run with --force to wipe it." >&2 + exit 1 + fi + echo "Wiping $SITE_DIR" + rm -rf "$SITE_DIR" +fi + +echo "Installing WordPress in $SITE_DIR" +mkdir -p "$SITE_DIR" +wp core download --path="$SITE_DIR" + +wp config create \ + --path="$SITE_DIR" \ + --dbname=wordpress \ + --dbuser=wordpress \ + --dbpass=wordpress \ + --dbhost=127.0.0.1 \ + --skip-check \ + --extra-php <<'PHP' +define( 'DB_ENGINE', 'sqlite' ); +define( 'WP_ENVIRONMENT_TYPE', 'local' ); +define( 'WP_DEBUG', true ); +define( 'WP_DEBUG_DISPLAY', false ); +define( 'WP_DEBUG_LOG', true ); +define( 'DATALUMO_SSL_VERIFY', false ); +define( 'DATALUMO_API_URL', 'https://dl.test' ); +PHP + +echo "Adding SQLite drop-in" +SQLITE_ZIP="$(mktemp -t sqlite-wp).zip" +curl -fsSL "https://downloads.wordpress.org/plugin/sqlite-database-integration.latest-stable.zip" -o "$SQLITE_ZIP" +unzip -qo "$SQLITE_ZIP" -d "$SITE_DIR/wp-content/plugins" +rm -f "$SQLITE_ZIP" +cp "$SITE_DIR/wp-content/plugins/sqlite-database-integration/db.copy" "$SITE_DIR/wp-content/db.php" + +"${WP[@]}" core install \ + --url="$SITE_URL" \ + --title="Datalumo Fresh" \ + --admin_user=admin \ + --admin_password=password \ + --admin_email=admin@example.com \ + --skip-email + +"${WP[@]}" plugin activate sqlite-database-integration +"${WP[@]}" theme install twentytwentyfive --activate + +echo "Linking Datalumo plugin" +if [[ ! -d "$PLUGIN_DIR/vendor" ]]; then + composer install --working-dir="$PLUGIN_DIR" --no-interaction +fi +ln -sfn "$PLUGIN_DIR" "$SITE_DIR/wp-content/plugins/datalumo" +"${WP[@]}" plugin activate datalumo + +echo "Installing WooCommerce" +"${WP[@]}" plugin install woocommerce --activate +"${WP[@]}" eval 'if (class_exists("WC_Install")) { WC_Install::create_pages(); }' +"${WP[@]}" option update woocommerce_currency EUR +"${WP[@]}" option update woocommerce_default_country NL +"${WP[@]}" option update woocommerce_coming_soon no || true +"${WP[@]}" option update woocommerce_store_pages_only no || true +"${WP[@]}" option update woocommerce_onboarding_profile '{"skipped":true,"completed":true}' --format=json || true +"${WP[@]}" option update woocommerce_task_list_hidden yes || true +"${WP[@]}" option update woocommerce_task_list_complete yes || true + +"${WP[@]}" rewrite structure '/%postname%/' --hard +"${WP[@]}" rewrite flush --hard + +echo "Seeding posts and products" +"${WP[@]}" eval-file "$PLUGIN_DIR/bin/fresh-site-seed.php" + +echo +echo "Ready." +echo " Site $SITE_URL" +echo " Admin $SITE_URL/wp-admin/" +echo " User admin" +echo " Password password" +echo " Settings $SITE_URL/wp-admin/options-general.php?page=datalumo" +echo " Datalumo https://dl.test (DATALUMO_API_URL)" +echo " Database $SITE_DIR/wp-content/database/.ht.sqlite" diff --git a/bin/fresh-site-seed.php b/bin/fresh-site-seed.php new file mode 100644 index 0000000..fab5f15 --- /dev/null +++ b/bin/fresh-site-seed.php @@ -0,0 +1,185 @@ + 'Visitor guide', + 'post_name' => 'visitor-guide', + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_content' => <<<'HTML' +

This is a clean demo shop for trying the Datalumo WordPress plugin.

+

Sign in at /wp-admin/ with admin / password, then open Settings → Datalumo and press Connect with Datalumo.

+HTML, +], true); + +wp_insert_post([ + 'post_title' => 'About', + 'post_name' => 'about', + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_content' => '

Datalumo Fresh is a disposable WordPress site. Wipe it with bin/fresh-site --force.

', +], true); + +$posts = [ + [ + 'Opening hours and pickup', + 'We are open Tuesday to Saturday, 10:00 to 18:00. Weekend pickup is at the side door on Kerkstraat. Closed on Mondays.', + ], + [ + 'Shipping and returns', + 'Orders placed before 15:00 ship the same day inside the Netherlands. You have 30 days to return unused items. Start a return from your account or reply to the order email.', + ], + [ + 'Care for wool', + 'Hand wash wool in cold water and lay it flat to dry. A wool beanie can go in a mesh bag on a gentle cycle if you skip the dryer.', + ], + [ + 'How we roast coffee', + 'Beans are roasted in small batches every Thursday. Light roasts land on Friday. Dark roasts are bagged the same afternoon.', + ], + [ + 'Gift wrapping', + 'Add a note at checkout and we wrap the order in recycled paper. Gift receipts hide prices. We do not print prices on the packing slip when you ask.', + ], +]; + +foreach ($posts as [$title, $content]) { + wp_insert_post([ + 'post_title' => $title, + 'post_status' => 'publish', + 'post_type' => 'post', + 'post_content' => '

'.$content.'

', + ], true); +} + +if ($guide && ! is_wp_error($guide)) { + update_option('show_on_front', 'page'); + update_option('page_on_front', $guide); +} + +if (! class_exists('WooCommerce')) { + WP_CLI::warning('WooCommerce is not active; skipped products.'); + + return; +} + +$simple = [ + ['Canvas Tote', '29.00', 'A heavy canvas tote that stands on its own. Natural colour, one size.', 'tote'], + ['Ceramic Mug', '14.00', 'A 300ml stoneware mug. Dishwasher safe. Speckled cream glaze.', 'mug'], + ['Wool Beanie', '22.00', 'Merino beanie, unisex. Hand wash or a gentle cycle in a mesh bag.', 'beanie'], + ['Drip Coffee 250g', '11.50', 'Thursday roast. Light and chocolatey. Ground for filter on request.', 'coffee'], +]; + +foreach ($simple as [$name, $price, $description, $sku]) { + $product = new WC_Product_Simple(); + $product->set_name($name); + $product->set_regular_price($price); + $product->set_short_description($description); + $product->set_description($description); + $product->set_sku($sku); + $product->set_manage_stock(true); + $product->set_stock_quantity(25); + $product->set_catalog_visibility('visible'); + $product->set_status('publish'); + $product->save(); +} + +foreach (['color' => 'Color', 'size' => 'Size'] as $slug => $label) { + if (wc_attribute_taxonomy_id_by_name($slug)) { + continue; + } + + wc_create_attribute([ + 'name' => $label, + 'slug' => $slug, + 'type' => 'select', + 'order_by' => 'menu_order', + 'has_archives' => false, + ]); +} + +delete_transient('wc_attribute_taxonomies'); +WC_Cache_Helper::invalidate_cache_group('woocommerce-attributes'); +WC()->attributes = null; + +if (! taxonomy_exists('pa_color') || ! taxonomy_exists('pa_size')) { + foreach (wc_get_attribute_taxonomies() as $taxonomy) { + $name = wc_attribute_taxonomy_name($taxonomy->attribute_name); + register_taxonomy($name, ['product'], []); + } +} + +foreach (['Blue', 'Red'] as $color) { + wp_insert_term($color, 'pa_color'); +} + +foreach (['Small', 'Large'] as $size) { + wp_insert_term($size, 'pa_size'); +} + +$colorAttribute = new WC_Product_Attribute(); +$colorAttribute->set_id(wc_attribute_taxonomy_id_by_name('color')); +$colorAttribute->set_name('pa_color'); +$colorAttribute->set_options(array_values(array_filter([ + get_term_by('name', 'Blue', 'pa_color')->term_id ?? null, + get_term_by('name', 'Red', 'pa_color')->term_id ?? null, +]))); +$colorAttribute->set_visible(true); +$colorAttribute->set_variation(true); + +$sizeAttribute = new WC_Product_Attribute(); +$sizeAttribute->set_id(wc_attribute_taxonomy_id_by_name('size')); +$sizeAttribute->set_name('pa_size'); +$sizeAttribute->set_options(array_values(array_filter([ + get_term_by('name', 'Small', 'pa_size')->term_id ?? null, + get_term_by('name', 'Large', 'pa_size')->term_id ?? null, +]))); +$sizeAttribute->set_visible(true); +$sizeAttribute->set_variation(true); + +$jacket = new WC_Product_Variable(); +$jacket->set_name('Trail Jacket'); +$jacket->set_sku('jacket'); +$jacket->set_short_description('A packable shell. Pick a colour, then a size.'); +$jacket->set_description('Two-way zip, stuffs into its own pocket. Colour and size are variations so chat add-to-cart can walk those steps.'); +$jacket->set_attributes([$colorAttribute, $sizeAttribute]); +$jacket->set_catalog_visibility('visible'); +$jacket->set_status('publish'); +$jacketId = $jacket->save(); + +$prices = [ + 'Blue' => ['Small' => '89.00', 'Large' => '89.00'], + 'Red' => ['Small' => '92.00', 'Large' => '92.00'], +]; + +foreach ($prices as $color => $sizes) { + foreach ($sizes as $size => $price) { + $variation = new WC_Product_Variation(); + $variation->set_parent_id($jacketId); + $variation->set_attributes([ + 'pa_color' => sanitize_title($color), + 'pa_size' => sanitize_title($size), + ]); + $variation->set_regular_price($price); + $variation->set_sku('jacket-'.sanitize_title($color).'-'.sanitize_title($size)); + $variation->set_manage_stock(true); + $variation->set_stock_quantity(8); + $variation->set_status('publish'); + $variation->save(); + } +} + +WC_Product_Variable::sync($jacketId); + +WP_CLI::success('Seeded pages, posts, simple products, and a variable Trail Jacket.'); diff --git a/bin/index.php b/bin/index.php new file mode 100644 index 0000000..6220032 --- /dev/null +++ b/bin/index.php @@ -0,0 +1,2 @@ + form, +.datalumo-settings-main .form-table { + margin-top: 0; +} + +.datalumo-settings-main .form-table tr:first-child th, +.datalumo-settings-main .form-table tr:first-child td { + padding-top: 0; +} + +.datalumo-settings-aside { + display: flex; + flex: 0 1 18rem; + flex-direction: column; + gap: 0.75rem; + width: 18rem; + position: sticky; + top: 2.75rem; +} + +.datalumo-help-card, +.datalumo-docs-card { + margin: 0; + padding: 1rem 1.1rem; + border: 1px solid #dcdcde; + border-radius: 8px; + background: #fff; +} + +.datalumo-help-card h2 { + margin: 0 0 0.65rem; + font-size: 13px; + font-weight: 600; +} + +.datalumo-help-fields { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.datalumo-help-fields textarea { + display: block; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-height: 5.5rem; + margin: 0; + resize: vertical; +} + +.datalumo-settings-aside .datalumo-help-submit.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + width: 100%; + margin: 0; + padding: 0 12px; + line-height: 1; +} + +.datalumo-help-icon { + display: block; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.datalumo-docs-card p { + margin: 0; +} + +.datalumo-docs-card p + p { + margin-top: 0.25rem; + color: #646970; +} diff --git a/resources/css/index.php b/resources/css/index.php new file mode 100644 index 0000000..6220032 --- /dev/null +++ b/resources/css/index.php @@ -0,0 +1,2 @@ + true], 'objects'); -unset($postTypes['attachment']); +/** @var string $datalumo_tab (set by SettingsPage::render) */ +$datalumo_organisation = Options::get('organisation', []); +$datalumo_sources = (array) Options::get('sources', []); +$datalumo_syncs = (array) Options::get('syncs', []); +$datalumo_post_types = get_post_types(['public' => true], 'objects'); +unset($datalumo_post_types['attachment']); -$tabs = [ +$datalumo_tabs = [ 'connection' => __('Connection', 'datalumo'), 'content-sync' => __('Content sync', 'datalumo'), 'chatbot' => __('Chatbot', 'datalumo'), 'search-box' => __('Search box', 'datalumo'), 'enhanced-search' => __('Enhanced search', 'datalumo'), ]; + +$datalumo_nonce_ok = isset($_GET['_wpnonce']) + && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'datalumo_settings_updated'); +$datalumo_notice = $datalumo_nonce_ok && isset($_GET['datalumo_notice']) + ? sanitize_key(wp_unslash($_GET['datalumo_notice'])) + : ''; +$datalumo_notices = [ + 'connected' => [__('Connected to Datalumo.', 'datalumo'), 'success'], + 'grant_failed' => [__('Connect did not finish. Start again from this page.', 'datalumo'), 'error'], + 'disconnected' => [__('Disconnected. The API key remains in Datalumo under API keys until you revoke it there.', 'datalumo'), 'success'], + 'setup_saved' => [__('Setup saved.', 'datalumo'), 'success'], + 'invalid_widget_key' => [__('Widget keys look like org-id/widget-id. An API token or secret will not work there.', 'datalumo'), 'error'], +]; +$datalumo_connected = Options::isConnected(); +$datalumo_setup_pending = $datalumo_connected && (bool) Options::get('setup_pending'); +$datalumo_chat_key = (string) Options::get('chatbot.widget_key', ''); +$datalumo_search_key = (string) Options::get('search_box.widget_key', '') ?: (string) Options::get('enhanced.widget_key', ''); +$datalumo_setup_source_id = (string) Options::get('setup_source_id', ''); +$datalumo_setup_types = ['post', 'page']; + +foreach ($datalumo_syncs as $datalumo_sync_row) { + if (($datalumo_sync_row['source_id'] ?? '') === $datalumo_setup_source_id) { + $datalumo_setup_types = $datalumo_sync_row['post_types'] ?? $datalumo_setup_types; + break; + } +} ?>

Datalumo

- -

+ +
+

+
- + + + -
- - - + 'for-developers', + 'chatbot' => 'chat-page-actions', + 'search-box' => 'add-a-search-box', + 'enhanced-search' => 'enhanced-search', + default => '', + }; + $datalumo_docs_hint = match ($datalumo_tab) { + 'content-sync' => __('Custom fields, filters, and sync details.', 'datalumo'), + 'chatbot' => __('Shortcodes, visitor identity, and chat page actions.', 'datalumo'), + 'search-box', 'enhanced-search' => __('Search box shortcode and enhanced search.', 'datalumo'), + default => __('Connect, sync, and widgets.', 'datalumo'), + }; + ?> - - - +
+
+ + + + + + + + + + + + +
+ + +
+

+

+
+ +
+ + + + +
+ + + +
+
+ + +
+ + + + +
+ +
+
+ + + +
+ + + + +
+ +
+
+ + +

+ + +

+ +
+ + + + + + +
+

+
+ + + + + +
> + +

+ + +

+
+ + +

+ +

+
+ + +
> +
+ + + +
+ + + + + + + + + + + + - + - - - - - - - - + + - - - - - - - - - - + + + + +
+ +
+ + + + +

-

+ 1; + ?>
- $sync) : ?> -
- + $datalumo_sync) : ?> +
+ - + - @@ -145,10 +327,9 @@ class="nav-tab "> -

- + @@ -180,14 +361,13 @@ class="nav-tab "> -

- + @@ -199,7 +379,7 @@ class="nav-tab "> - + @@ -221,11 +401,11 @@ class="nav-tab ">