@@ -221,11 +401,11 @@ class="nav-tab ">
-
+
- name, (array) Options::get('enhanced.post_types', []), true)); ?> />
- labels->name); ?>
+ name, (array) Options::get('enhanced.post_types', []), true)); ?> />
+ labels->name); ?>
@@ -257,4 +437,31 @@ class="nav-tab ">
+
+
+
+
+
+
+
diff --git a/src/Admin/Ajax.php b/src/Admin/Ajax.php
index 22bf87c..4befffb 100644
--- a/src/Admin/Ajax.php
+++ b/src/Admin/Ajax.php
@@ -4,6 +4,7 @@
use Datalumo\Wp\Api\ApiException;
use Datalumo\Wp\Api\Client;
+use Datalumo\Wp\Support\Credentials;
use Datalumo\Wp\Support\Options;
use Datalumo\Wp\Sync\BulkSync;
@@ -20,10 +21,10 @@ public function register(): void
}
/**
- * Verify the pasted org id + token against /me; on success cache the
- * organisation and its sources so the pickers render offline.
+ * Verify a pasted API token against GET /api/v1/me. The token identifies
+ * the organisation; no organisation ID is required.
*
- * An empty POST token falls back to the stored one — the password field
+ * An empty POST token falls back to the stored one. The password field
* renders only a "saved" placeholder, never the secret. The base URL comes
* from the form field (so an unsaved value works) and is persisted on success.
*/
@@ -31,34 +32,40 @@ public function connect(): void
{
$this->authorise();
- $organisationId = sanitize_text_field((string) ($_POST['organisation_id'] ?? ''));
- $token = sanitize_text_field((string) ($_POST['token'] ?? ''));
+ // phpcs:disable WordPress.Security.NonceVerification -- verified in authorise().
+ $token = sanitize_text_field(wp_unslash((string) ($_POST['token'] ?? '')));
$apiUrl = array_key_exists('api_url', $_POST)
- ? (esc_url_raw((string) wp_unslash($_POST['api_url'])) ?: 'https://datalumo.app')
+ ? (esc_url_raw(wp_unslash((string) $_POST['api_url'])) ?: 'https://datalumo.app')
: null;
+ // phpcs:enable WordPress.Security.NonceVerification
- if ($organisationId === '') {
- $organisationId = (string) Options::get('organisation.id', '');
+ if ($token === '') {
+ $token = (string) Options::get('api_token', '');
}
if ($token === '') {
- $token = (string) Options::get('api_token', '');
+ wp_send_json_error(['message' => __('An API token is required.', 'datalumo')]);
+ }
+
+ if (Credentials::looksLikeWidgetKey($token)) {
+ wp_send_json_error(['message' => __('That looks like a widget key. Paste an API token from API keys instead.', 'datalumo')]);
}
- if ($organisationId === '' || $token === '') {
- wp_send_json_error(['message' => __('Both the organisation ID and an API token are required.', 'datalumo')]);
+ if (Credentials::looksLikeSecret($token)) {
+ wp_send_json_error(['message' => __('That looks like a widget secret. Paste an API token from API keys instead.', 'datalumo')]);
}
try {
- $me = (new Client())->me($organisationId, $token, $apiUrl);
+ $me = (new Client())->me('', $token, $apiUrl);
} catch (ApiException $e) {
wp_send_json_error(['message' => $e->getMessage()]);
}
$stored = [
'api_token' => $token,
- 'organisation' => $me['organisation'] ?? ['id' => $organisationId],
+ 'organisation' => $me['organisation'] ?? [],
'sources' => $me['sources'] ?? [],
+ 'connected_via' => 'token',
];
if ($apiUrl !== null) {
@@ -98,7 +105,8 @@ public function syncStatus(): void
private function syncId(): string
{
- return sanitize_text_field((string) ($_POST['sync_id'] ?? ''));
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in authorise().
+ return sanitize_text_field(wp_unslash((string) ($_POST['sync_id'] ?? '')));
}
private function authorise(): void
diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php
index 54731ea..efeab38 100644
--- a/src/Admin/SettingsPage.php
+++ b/src/Admin/SettingsPage.php
@@ -2,14 +2,22 @@
namespace Datalumo\Wp\Admin;
+use Datalumo\Wp\Api\ApiException;
+use Datalumo\Wp\Api\Client;
+use Datalumo\Wp\Support\Assets;
+use Datalumo\Wp\Support\Credentials;
use Datalumo\Wp\Support\Options;
class SettingsPage
{
public const NONCE = 'datalumo_settings';
+ public const ASK_MAX_LENGTH = 280;
+
private const TABS = ['connection', 'content-sync', 'chatbot', 'search-box', 'enhanced-search'];
+ private bool $widgetKeyRejected = false;
+
public function register(): void
{
add_action('admin_menu', function (): void {
@@ -32,16 +40,18 @@ public function assets(string $hook): void
return;
}
- wp_enqueue_style('datalumo-admin', DATALUMO_URL . 'resources/css/admin.css', [], DATALUMO_VERSION);
- wp_enqueue_script('datalumo-admin', DATALUMO_URL . 'resources/js/admin.js', [], DATALUMO_VERSION, ['in_footer' => true]);
+ wp_enqueue_style('datalumo-admin', DATALUMO_URL . 'resources/css/admin.css', [], Assets::version());
+ wp_enqueue_script('datalumo-admin', DATALUMO_URL . 'resources/js/admin.js', [], Assets::version(), ['in_footer' => true]);
wp_localize_script('datalumo-admin', 'datalumoAdmin', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce(Ajax::NONCE),
'i18n' => [
- 'connected' => __('Connected to %s — %d source(s) available.', 'datalumo'),
+ /* translators: 1: organisation name, 2: number of sources */
+ 'connected' => __('Connected to %1$s — %2$d source(s) available.', 'datalumo'),
'connectionFailed' => __('Connection failed:', 'datalumo'),
'syncStarting' => __('Starting sync…', 'datalumo'),
+ /* translators: 1: number of posts already synced, 2: total posts */
'syncProgress' => __('%1$d of %2$d posts synced…', 'datalumo'),
'syncDone' => __('Sync complete.', 'datalumo'),
],
@@ -50,11 +60,73 @@ public function assets(string $hook): void
public function render(): void
{
- $tab = isset($_GET['tab']) && in_array($_GET['tab'], self::TABS, true) ? $_GET['tab'] : 'connection';
+ $datalumo_tab = $this->requestedTab();
+
+ if ($datalumo_tab === 'content-sync') {
+ $this->refreshSources();
+ }
require DATALUMO_DIR . '/resources/views/settings.php';
}
+ /**
+ * Other tabs are empty until a connection exists and the post-connect
+ * checklist (if any) has been saved.
+ */
+ public static function setupIsReady(): bool
+ {
+ return Options::isConnected() && ! Options::get('setup_pending');
+ }
+
+ public static function docsUrl(string $hash = ''): string
+ {
+ $url = Options::baseUrl().'/docs/wordpress';
+
+ return $hash !== '' ? $url.'#'.$hash : $url;
+ }
+
+ /**
+ * Landing page for the settings "Need help?" form. Query `ask` is
+ * honoured on first-party docs only, as a composer prefill.
+ */
+ public static function helpUrl(): string
+ {
+ return self::docsUrl();
+ }
+
+ /**
+ * Pull a fresh source list (including knowledge base names) so the
+ * picker stays current after a connect or a rename in Datalumo.
+ */
+ private function refreshSources(): void
+ {
+ if (! Options::isConnected()) {
+ return;
+ }
+
+ try {
+ $me = (new Client())->me('', (string) Options::get('api_token'));
+ } catch (ApiException) {
+ return;
+ }
+
+ if (isset($me['sources']) && is_array($me['sources'])) {
+ Options::set('sources', $me['sources']);
+ }
+ }
+
+ private function requestedTab(): string
+ {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- settings tab is a navigation query arg.
+ $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : '';
+
+ if (! self::setupIsReady()) {
+ return 'connection';
+ }
+
+ return in_array($tab, self::TABS, true) ? $tab : 'connection';
+ }
+
public function save(): void
{
if (! current_user_can('manage_options')) {
@@ -63,23 +135,27 @@ public function save(): void
check_admin_referer(self::NONCE);
- $tab = sanitize_key((string) ($_POST['datalumo_tab'] ?? 'connection'));
$input = wp_unslash($_POST);
+ $tab = sanitize_key((string) ($input['datalumo_tab'] ?? 'connection'));
+
+ if (! in_array($tab, self::TABS, true)) {
+ $tab = 'connection';
+ }
match ($tab) {
'content-sync' => $this->saveSyncs($input),
'chatbot' => Options::merge(['chatbot' => [
'enabled' => ! empty($input['chatbot_enabled']),
- 'widget_key' => sanitize_text_field((string) ($input['chatbot_widget_key'] ?? '')),
+ 'widget_key' => $this->sanitizeWidgetKey((string) ($input['chatbot_widget_key'] ?? ''), (string) Options::get('chatbot.widget_key', '')),
'identity_enabled' => ! empty($input['chatbot_identity_enabled']),
- 'signing_secret' => sanitize_text_field((string) ($input['chatbot_signing_secret'] ?? '')),
+ 'signing_secret' => $this->sanitizeSigningSecret((string) ($input['chatbot_signing_secret'] ?? '')),
]]),
'search-box' => Options::merge(['search_box' => [
- 'widget_key' => sanitize_text_field((string) ($input['search_box_widget_key'] ?? '')),
+ 'widget_key' => $this->sanitizeWidgetKey((string) ($input['search_box_widget_key'] ?? ''), (string) Options::get('search_box.widget_key', '')),
]]),
'enhanced-search' => Options::merge(['enhanced' => [
'enabled' => ! empty($input['enhanced_enabled']),
- 'widget_key' => sanitize_text_field((string) ($input['enhanced_widget_key'] ?? '')),
+ 'widget_key' => $this->sanitizeWidgetKey((string) ($input['enhanced_widget_key'] ?? ''), (string) Options::get('enhanced.widget_key', '')),
'post_types' => array_map('sanitize_key', (array) ($input['enhanced_post_types'] ?? [])),
'summary_enabled' => ! empty($input['enhanced_summary_enabled']),
'summary_selector' => sanitize_text_field((string) ($input['enhanced_summary_selector'] ?? '')),
@@ -88,10 +164,20 @@ public function save(): void
default => $this->saveConnection($input),
};
- wp_safe_redirect(add_query_arg(
- ['page' => 'datalumo', 'tab' => $tab, 'updated' => '1'],
- admin_url('options-general.php'),
- ));
+ $args = [
+ 'page' => 'datalumo',
+ 'tab' => $tab,
+ '_wpnonce' => wp_create_nonce('datalumo_settings_updated'),
+ ];
+
+ if ($this->widgetKeyRejected) {
+ $args['datalumo_notice'] = 'invalid_widget_key';
+ } else {
+ // options-head.php prints "Settings saved." when updated=1 on a Settings screen.
+ $args['updated'] = '1';
+ }
+
+ wp_safe_redirect(add_query_arg($args, admin_url('options-general.php')));
exit;
}
@@ -144,4 +230,24 @@ private function saveSyncs(array $input): void
Options::set('syncs', $syncs);
}
+
+ private function sanitizeWidgetKey(string $value, string $current = ''): string
+ {
+ $value = sanitize_text_field($value);
+
+ if ($value === '' || Credentials::looksLikeWidgetKey($value)) {
+ return $value;
+ }
+
+ $this->widgetKeyRejected = true;
+
+ return $current;
+ }
+
+ private function sanitizeSigningSecret(string $value): string
+ {
+ $value = sanitize_text_field($value);
+
+ return $value !== '' ? $value : (string) Options::get('chatbot.signing_secret', '');
+ }
}
diff --git a/src/Admin/index.php b/src/Admin/index.php
new file mode 100644
index 0000000..6220032
--- /dev/null
+++ b/src/Admin/index.php
@@ -0,0 +1,2 @@
+request('GET', $this->rootUrl('api/v1/me', $baseUrl), token: $token);
+ }
+
return $this->request('GET', $this->orgUrl($organisationId, 'me', $baseUrl), token: $token);
}
+ /**
+ * @return array
+ */
+ public function exchangeWordPressGrant(string $code, string $state, ?string $baseUrl = null): array
+ {
+ return $this->request(
+ 'POST',
+ $this->rootUrl('api/integrations/wordpress/exchange', $baseUrl),
+ ['code' => $code, 'state' => $state],
+ anonymous: true,
+ );
+ }
+
public function pushPage(string $sourceId, array $payload): array
{
return $this->request('POST', $this->sourceUrl($sourceId, 'pages'), $payload);
@@ -70,13 +88,18 @@ public function search(string $widgetKey, string $query, int $limit = 50, ?strin
);
}
- private function orgUrl(string $organisationId, string $path, ?string $baseUrl = null): string
+ private function rootUrl(string $path, ?string $baseUrl = null): string
{
$base = $baseUrl !== null && $baseUrl !== ''
? Options::normaliseBaseUrl($baseUrl)
: Options::baseUrl();
- return $base . '/api/v1/' . rawurlencode($organisationId) . '/' . $path;
+ return $base . '/' . ltrim($path, '/');
+ }
+
+ private function orgUrl(string $organisationId, string $path, ?string $baseUrl = null): string
+ {
+ return $this->rootUrl('api/v1/' . rawurlencode($organisationId) . '/' . $path, $baseUrl);
}
private function sourceUrl(string $sourceId, string $path): string
@@ -94,13 +117,13 @@ public function parseWidgetKey(string $key): array
$parts = explode('/', trim($key), 2);
if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') {
- throw new ApiException(__('Invalid widget key — copy it from the widget editor.', 'datalumo'));
+ throw new ApiException(esc_html__('Invalid widget key — copy it from the widget editor.', 'datalumo'));
}
return [$parts[0], $parts[1]];
}
- private function request(string $method, string $url, array $body = [], ?string $token = null, ?string $origin = null): array
+ private function request(string $method, string $url, array $body = [], ?string $token = null, ?string $origin = null, bool $anonymous = false): array
{
$headers = [
'Accept' => 'application/json',
@@ -109,7 +132,7 @@ private function request(string $method, string $url, array $body = [], ?string
if ($origin !== null) {
$headers['Origin'] = $origin;
- } else {
+ } elseif (! $anonymous) {
$token ??= (string) Options::get('api_token');
if ($token !== '') {
@@ -126,20 +149,24 @@ private function request(string $method, string $url, array $body = [], ?string
]);
if (is_wp_error($response)) {
- throw new ApiException($response->get_error_message());
+ throw new ApiException(esc_html($response->get_error_message()));
}
$status = (int) wp_remote_retrieve_response_code($response);
$decoded = json_decode((string) wp_remote_retrieve_body($response), true);
if ($status >= 400) {
- $message = is_array($decoded) && isset($decoded['message'])
- ? (string) $decoded['message']
- : sprintf(__('Datalumo request failed (%d).', 'datalumo'), $status);
+ if (is_array($decoded) && isset($decoded['message'])) {
+ $message = (string) $decoded['message'];
+ } else {
+ /* translators: %d: HTTP status code */
+ $message = sprintf(esc_html__('Datalumo request failed (%d).', 'datalumo'), $status);
+ }
$retryAfter = (int) wp_remote_retrieve_header($response, 'retry-after');
- throw new ApiException($message, $status, $retryAfter > 0 ? $retryAfter : null);
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception payload, not rendered output.
+ throw new ApiException(esc_html($message), $status, $retryAfter > 0 ? $retryAfter : null);
}
return is_array($decoded) ? $decoded : [];
diff --git a/src/Api/index.php b/src/Api/index.php
new file mode 100644
index 0000000..6220032
--- /dev/null
+++ b/src/Api/index.php
@@ -0,0 +1,2 @@
+exchangeWordPressGrant($code, $state);
+ } catch (ApiException $e) {
+ self::redirectSettings('connection', 'grant_failed');
+ }
+
+ self::apply($payload);
+ self::redirectSettings('connection', 'connected');
+ }
+
+ public static function disconnect(): void
+ {
+ self::authorise();
+ check_admin_referer(self::ACTION_DISCONNECT);
+
+ $chatbot = (array) Options::get('chatbot', []);
+ $chatbot['widget_key'] = '';
+ $chatbot['signing_secret'] = '';
+ $chatbot['enabled'] = false;
+ $chatbot['identity_enabled'] = false;
+
+ $searchBox = (array) Options::get('search_box', []);
+ $searchBox['widget_key'] = '';
+
+ $enhanced = (array) Options::get('enhanced', []);
+ $enhanced['widget_key'] = '';
+ $enhanced['enabled'] = false;
+
+ Options::merge([
+ 'api_token' => '',
+ 'organisation' => [],
+ 'sources' => [],
+ 'setup_pending' => false,
+ 'setup_source_id' => '',
+ 'connected_via' => '',
+ 'chatbot' => $chatbot,
+ 'search_box' => $searchBox,
+ 'enhanced' => $enhanced,
+ ]);
+
+ self::redirectSettings('connection', 'disconnected');
+ }
+
+ public static function saveSetup(): void
+ {
+ self::authorise();
+ check_admin_referer(self::ACTION_SETUP);
+
+ $input = wp_unslash($_POST);
+ $sourceId = (string) Options::get('setup_source_id', '');
+ $postTypes = array_map('sanitize_key', (array) ($input['setup_post_types'] ?? []));
+
+ if ($sourceId !== '' && $postTypes !== []) {
+ $syncs = (array) Options::get('syncs', []);
+ $existing = false;
+
+ foreach ($syncs as $index => $row) {
+ if (($row['source_id'] ?? '') === $sourceId) {
+ $syncs[$index]['post_types'] = $postTypes;
+ $existing = true;
+ break;
+ }
+ }
+
+ if (! $existing) {
+ $syncs[] = [
+ 'id' => wp_generate_uuid4(),
+ 'source_id' => $sourceId,
+ 'post_types' => $postTypes,
+ 'meta_mappings' => [],
+ ];
+ }
+
+ Options::set('syncs', $syncs);
+ }
+
+ $chatbot = (array) Options::get('chatbot', []);
+ $chatbot['enabled'] = ! empty($input['setup_chat_enabled']);
+ $chatbot['identity_enabled'] = ! empty($input['setup_identity_enabled']);
+ Options::set('chatbot', $chatbot);
+
+ $enhanced = (array) Options::get('enhanced', []);
+ $enhanced['enabled'] = ! empty($input['setup_enhanced_enabled']);
+ Options::set('enhanced', $enhanced);
+
+ Options::set('setup_pending', false);
+
+ $args = [
+ 'page' => 'datalumo',
+ 'tab' => 'connection',
+ 'datalumo_notice' => 'setup_saved',
+ '_wpnonce' => wp_create_nonce('datalumo_settings_updated'),
+ ];
+
+ if (! empty($input['setup_sync_now']) && $sourceId !== '') {
+ $syncId = '';
+
+ foreach ((array) Options::get('syncs', []) as $row) {
+ if (($row['source_id'] ?? '') === $sourceId) {
+ $syncId = (string) ($row['id'] ?? '');
+ break;
+ }
+ }
+
+ if ($syncId !== '') {
+ $args['tab'] = 'content-sync';
+ $args['datalumo_sync'] = $syncId;
+ }
+ }
+
+ wp_safe_redirect(add_query_arg($args, admin_url('options-general.php')));
+ exit;
+ }
+
+ /**
+ * @param array $payload
+ */
+ public static function apply(array $payload): void
+ {
+ $organisation = is_array($payload['organisation'] ?? null) ? $payload['organisation'] : [];
+ $sources = is_array($payload['sources'] ?? null) ? $payload['sources'] : [];
+ $source = is_array($payload['source'] ?? null) ? $payload['source'] : [];
+ $chatbot = is_array($payload['chatbot'] ?? null) ? $payload['chatbot'] : null;
+ $search = is_array($payload['search'] ?? null) ? $payload['search'] : null;
+
+ $sourceId = (string) ($source['id'] ?? '');
+ $stored = [
+ 'api_token' => (string) ($payload['token'] ?? ''),
+ 'organisation' => $organisation,
+ 'sources' => $sources,
+ 'setup_pending' => true,
+ 'setup_source_id' => $sourceId,
+ 'connected_via' => 'grant',
+ ];
+
+ if ($sourceId !== '') {
+ $syncs = (array) Options::get('syncs', []);
+ $hasSource = false;
+
+ foreach ($syncs as $row) {
+ if (($row['source_id'] ?? '') === $sourceId) {
+ $hasSource = true;
+ break;
+ }
+ }
+
+ if (! $hasSource) {
+ $syncs[] = [
+ 'id' => wp_generate_uuid4(),
+ 'source_id' => $sourceId,
+ 'post_types' => ['post', 'page'],
+ 'meta_mappings' => [],
+ ];
+ $stored['syncs'] = $syncs;
+ }
+ }
+
+ if (is_array($chatbot)) {
+ $stored['chatbot'] = array_merge((array) Options::get('chatbot', []), [
+ 'widget_key' => (string) ($chatbot['widget_key'] ?? ''),
+ 'signing_secret' => (string) ($chatbot['signing_secret'] ?? ''),
+ ]);
+ }
+
+ if (is_array($search)) {
+ $key = (string) ($search['widget_key'] ?? '');
+ $stored['search_box'] = array_merge((array) Options::get('search_box', []), [
+ 'widget_key' => $key,
+ ]);
+ $stored['enhanced'] = array_merge((array) Options::get('enhanced', []), [
+ 'widget_key' => $key,
+ ]);
+ }
+
+ Options::merge($stored);
+ }
+
+ public static function startUrl(string $baseUrl, string $state): string
+ {
+ $base = Options::normaliseBaseUrl($baseUrl);
+ $home = home_url('/');
+ $host = (string) wp_parse_url($home, PHP_URL_HOST);
+
+ return $base.'/integrations/wordpress?'.http_build_query([
+ 'site' => $host,
+ 'site_url' => $home,
+ 'return' => admin_url('admin-post.php?action='.self::ACTION_CALLBACK),
+ 'cancel' => admin_url('options-general.php').'?page=datalumo&tab=connection',
+ 'state' => $state,
+ ]);
+ }
+
+ private static function authorise(): void
+ {
+ if (! current_user_can('manage_options')) {
+ wp_die(esc_html__('Not allowed.', 'datalumo'));
+ }
+ }
+
+ private static function redirectSettings(string $tab, string $notice): void
+ {
+ wp_safe_redirect(add_query_arg(
+ [
+ 'page' => 'datalumo',
+ 'tab' => $tab,
+ 'datalumo_notice' => $notice,
+ '_wpnonce' => wp_create_nonce('datalumo_settings_updated'),
+ ],
+ admin_url('options-general.php'),
+ ));
+ exit;
+ }
+}
diff --git a/src/Connect/index.php b/src/Connect/index.php
new file mode 100644
index 0000000..6220032
--- /dev/null
+++ b/src/Connect/index.php
@@ -0,0 +1,2 @@
+ true],
);
}
@@ -105,9 +106,66 @@ 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_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();
+
+ 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/Embed/index.php b/src/Embed/index.php
new file mode 100644
index 0000000..6220032
--- /dev/null
+++ b/src/Embed/index.php
@@ -0,0 +1,2 @@
+ 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/HostNavigation.php b/src/Integration/HostNavigation.php
new file mode 100644
index 0000000..8f918b8
--- /dev/null
+++ b/src/Integration/HostNavigation.php
@@ -0,0 +1,223 @@
+ 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_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 ($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' => __('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;
+ }
+
+ if ($event === 'open_page') {
+ return $this->publishedUrlFromPayload($payload);
+ }
+
+ return null;
+ }
+
+ /**
+ * @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 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;
+ }
+
+ /**
+ * @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/Integration/WooCommerce.php b/src/Integration/WooCommerce.php
index 1894c68..930b18c 100644
--- a/src/Integration/WooCommerce.php
+++ b/src/Integration/WooCommerce.php
@@ -2,12 +2,14 @@
namespace Datalumo\Wp\Integration;
+use WP_Post;
use WP_Query;
/**
* 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 +22,272 @@ public function register(): void
{
add_filter('datalumo_sort_map', [$this, 'sortMap']);
add_filter('datalumo_resolve_args', [$this, 'resolveArgs'], 10, 2);
+ add_filter('datalumo_page_payload', [$this, 'enrichProduct'], 10, 2);
+
+ (new AddToCart())->register();
+ }
+
+ /**
+ * Default product fields for search and chat: short description,
+ * product categories/tags, visible attributes, and SKU (including
+ * variation SKUs). A mapping that already set sku is left alone.
+ *
+ * @param array $payload
+ * @return array
+ */
+ public function enrichProduct(array $payload, WP_Post $post): array
+ {
+ if (! in_array($post->post_type, ['product', 'product_variation'], true)
+ || ! function_exists('wc_get_product')) {
+ return $payload;
+ }
+
+ $product = wc_get_product($post->ID);
+
+ if (! $product) {
+ return $payload;
+ }
+
+ $payload = $this->attachShortDescription($payload, $product);
+ $payload = $this->attachTaxonomies($payload, $post);
+ $payload = $this->attachAttributes($payload, $product);
+
+ return $this->attachSku($payload, $product);
+ }
+
+ /**
+ * @param array $payload
+ * @return array
+ */
+ private function attachShortDescription(array $payload, object $product): array
+ {
+ if (! method_exists($product, 'get_short_description')) {
+ return $payload;
+ }
+
+ $short = trim(wp_strip_all_tags((string) $product->get_short_description()));
+
+ if ($short === '') {
+ return $payload;
+ }
+
+ $html = ''.esc_html($short).'
';
+ $content = trim((string) ($payload['content'] ?? ''));
+
+ if ($content === '') {
+ $payload['content'] = $html;
+ } elseif (! str_contains($content, $short)) {
+ $payload['content'] = $html."\n\n".$content;
+ }
+
+ return $payload;
+ }
+
+ /**
+ * @param array $payload
+ * @return array
+ */
+ private function attachTaxonomies(array $payload, WP_Post $post): array
+ {
+ $categoryNames = $this->termField($post->ID, 'product_cat', 'name');
+ $categorySlugs = $this->termField($post->ID, 'product_cat', 'slug');
+ $tagNames = $this->termField($post->ID, 'product_tag', 'name');
+ $tagSlugs = $this->termField($post->ID, 'product_tag', 'slug');
+ $meta = is_array($payload['meta'] ?? null) ? $payload['meta'] : [];
+ $lines = [];
+
+ if ($categorySlugs !== []) {
+ $meta['categories'] = $categorySlugs;
+ $lines[] = 'category: '.implode(', ', $categoryNames !== [] ? $categoryNames : $categorySlugs);
+ }
+
+ if ($tagSlugs !== []) {
+ $meta['tags'] = $tagSlugs;
+ $lines[] = 'tag: '.implode(', ', $tagNames !== [] ? $tagNames : $tagSlugs);
+ }
+
+ $payload['meta'] = $meta;
+
+ return $this->appendSearchableLines($payload, $lines);
+ }
+
+ /**
+ * @param array $payload
+ * @return array
+ */
+ private function attachAttributes(array $payload, object $product): array
+ {
+ if (! method_exists($product, 'get_attributes')) {
+ return $payload;
+ }
+
+ $attributes = [];
+ $lines = [];
+
+ foreach ($product->get_attributes() as $attribute) {
+ if (! is_object($attribute)) {
+ continue;
+ }
+
+ if (method_exists($attribute, 'get_visible') && ! $attribute->get_visible()) {
+ continue;
+ }
+
+ $label = $this->attributeLabel($attribute);
+ $values = $this->attributeValues($product, $attribute);
+
+ if ($label === '' || $values === []) {
+ continue;
+ }
+
+ $attributes[$label] = $values;
+ $lines[] = $label.': '.implode(', ', $values);
+ }
+
+ if ($attributes === []) {
+ return $payload;
+ }
+
+ $meta = is_array($payload['meta'] ?? null) ? $payload['meta'] : [];
+
+ if (! isset($meta['attributes'])) {
+ $meta['attributes'] = $attributes;
+ $payload['meta'] = $meta;
+ }
+
+ return $this->appendSearchableLines($payload, $lines);
+ }
+
+ /**
+ * @param array $payload
+ * @return array
+ */
+ private function attachSku(array $payload, object $product): array
+ {
+ $skus = $this->productSkus($product);
+
+ if ($skus === []) {
+ return $payload;
+ }
+
+ $meta = is_array($payload['meta'] ?? null) ? $payload['meta'] : [];
+
+ if (isset($meta['sku']) && $meta['sku'] !== '' && $meta['sku'] !== []) {
+ return $payload;
+ }
+
+ $meta['sku'] = count($skus) === 1 ? $skus[0] : $skus;
+ $payload['meta'] = $meta;
+
+ return $this->appendSearchableLines($payload, ['sku: '.implode(', ', $skus)]);
+ }
+
+ /**
+ * @return array
+ */
+ private function productSkus(object $product): array
+ {
+ $skus = [];
+ $own = trim((string) $product->get_sku());
+
+ if ($own !== '') {
+ $skus[] = $own;
+ }
+
+ if (method_exists($product, 'is_type') && $product->is_type('variable') && method_exists($product, 'get_children')) {
+ foreach ($product->get_children() as $childId) {
+ $child = wc_get_product((int) $childId);
+
+ if (! $child) {
+ continue;
+ }
+
+ $sku = trim((string) $child->get_sku());
+
+ if ($sku !== '') {
+ $skus[] = $sku;
+ }
+ }
+ }
+
+ return array_values(array_unique($skus));
+ }
+
+ /**
+ * @return array
+ */
+ private function termField(int $postId, string $taxonomy, string $field): array
+ {
+ $terms = get_the_terms($postId, $taxonomy);
+
+ if (! is_array($terms)) {
+ return [];
+ }
+
+ $values = [];
+
+ foreach ($terms as $term) {
+ $value = trim((string) (is_object($term) ? ($term->{$field} ?? '') : ''));
+
+ if ($value !== '') {
+ $values[] = $value;
+ }
+ }
+
+ return array_values(array_unique($values));
+ }
+
+ private function attributeLabel(object $attribute): string
+ {
+ $name = method_exists($attribute, 'get_name') ? (string) $attribute->get_name() : '';
+
+ if (function_exists('wc_attribute_label') && $name !== '') {
+ $label = trim((string) wc_attribute_label($name));
+
+ if ($label !== '') {
+ return $label;
+ }
+ }
+
+ return $name;
+ }
+
+ /**
+ * @return array
+ */
+ private function attributeValues(object $product, object $attribute): array
+ {
+ if (method_exists($attribute, 'is_taxonomy') && $attribute->is_taxonomy()
+ && method_exists($attribute, 'get_name')
+ && function_exists('wc_get_product_terms')) {
+ $id = method_exists($product, 'get_id') ? (int) $product->get_id() : 0;
+ $terms = wc_get_product_terms($id, $attribute->get_name(), ['fields' => 'names']);
+
+ return is_array($terms)
+ ? array_values(array_filter(array_map(strval(...), $terms)))
+ : [];
+ }
+
+ $options = method_exists($attribute, 'get_options') ? $attribute->get_options() : [];
+
+ return array_values(array_filter(array_map(strval(...), (array) $options)));
+ }
+
+ /**
+ * @param array $payload
+ * @param array $lines
+ * @return array
+ */
+ private function appendSearchableLines(array $payload, array $lines): array
+ {
+ if ($lines === []) {
+ return $payload;
+ }
+
+ $payload['content'] = rtrim((string) ($payload['content'] ?? ''))
+ ."\n\n".esc_html(implode("\n", $lines)).'
';
+
+ return $payload;
}
/**
@@ -90,8 +358,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 +379,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/src/Integration/index.php b/src/Integration/index.php
new file mode 100644
index 0000000..6220032
--- /dev/null
+++ b/src/Integration/index.php
@@ -0,0 +1,2 @@
+register();
(new Ajax())->register();
+ Grant::register();
}
(new ContentSync())->register();
@@ -54,6 +56,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/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/Search/index.php b/src/Search/index.php
new file mode 100644
index 0000000..6220032
--- /dev/null
+++ b/src/Search/index.php
@@ -0,0 +1,2 @@
+cleanContent($post);
- if ($content === '') {
- return null;
- }
-
$meta = [
'post_type' => $post->post_type,
'categories' => $this->termSlugs($post->ID, 'category'),
@@ -70,7 +66,18 @@ public function prepare(WP_Post $post, array $metaMappings = []): ?array
'meta' => array_filter($meta, fn ($value) => $value !== null && $value !== '' && $value !== []),
];
- return apply_filters('datalumo_page_payload', $payload, $post);
+ $payload = apply_filters('datalumo_page_payload', $payload, $post);
+
+ if (! is_array($payload)) {
+ return null;
+ }
+
+ // WooCommerce may fill an empty body (short description, SKU, attributes).
+ if (trim(wp_strip_all_tags((string) ($payload['content'] ?? ''))) === '') {
+ return null;
+ }
+
+ return $payload;
}
/**
@@ -80,17 +87,45 @@ public function prepare(WP_Post $post, array $metaMappings = []): ?array
*/
private function cleanContent(WP_Post $post): string
{
+ $this->ensureWooCommerceNotices();
+
$raw = (string) $post->post_content;
$raw = apply_filters('datalumo_render_shortcodes', false)
? do_shortcode($raw)
: strip_shortcodes($raw);
- $html = wp_filter_content_tags(wptexturize(do_blocks(wpautop($raw))));
+ $raw = wpautop($raw);
+
+ try {
+ $html = wp_filter_content_tags(wptexturize(do_blocks($raw)));
+ } catch (\Throwable) {
+ // WooCommerce Blocks (cart, checkout) call frontend helpers that
+ // are not loaded under WP-Cron. Keep the rest of the batch going.
+ $html = wp_filter_content_tags(wptexturize($raw));
+ }
return trim(wp_strip_all_tags($html) === '' ? '' : $html);
}
+ /**
+ * WooCommerce only loads notice helpers on the storefront. Action
+ * Scheduler still runs do_blocks(), and Hydration::cache_store_notices()
+ * fatals if wc_get_notices() is missing.
+ */
+ private function ensureWooCommerceNotices(): void
+ {
+ if (function_exists('wc_get_notices') || ! defined('WC_ABSPATH')) {
+ return;
+ }
+
+ $file = WC_ABSPATH.'includes/wc-notice-functions.php';
+
+ if (is_readable($file)) {
+ require_once $file;
+ }
+ }
+
/**
* @return array
*/
diff --git a/src/Sync/index.php b/src/Sync/index.php
new file mode 100644
index 0000000..6220032
--- /dev/null
+++ b/src/Sync/index.php
@@ -0,0 +1,2 @@
+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/AssetsTest.php b/tests/Unit/AssetsTest.php
new file mode 100644
index 0000000..d99e660
--- /dev/null
+++ b/tests/Unit/AssetsTest.php
@@ -0,0 +1,28 @@
+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/ClientConnectTest.php b/tests/Unit/ClientConnectTest.php
new file mode 100644
index 0000000..e6f0eb6
--- /dev/null
+++ b/tests/Unit/ClientConnectTest.php
@@ -0,0 +1,52 @@
+justReturn(false);
+ Functions\when('wp_json_encode')->alias(fn ($value) => json_encode($value));
+ Functions\when('wp_remote_retrieve_response_code')->justReturn(200);
+ Functions\when('wp_remote_retrieve_body')->justReturn('{"organisation":{"id":"org-1"}}');
+ Functions\when('wp_remote_retrieve_header')->justReturn('');
+
+ $seen = null;
+ Functions\when('wp_remote_request')->alias(function (string $url) use (&$seen) {
+ $seen = $url;
+
+ return ['response' => ['code' => 200], 'body' => '{"organisation":{"id":"org-1"}}'];
+ });
+
+ $result = (new Client())->me('', 'plain-token');
+
+ expect($seen)->toBe('https://datalumo.app/api/v1/me')
+ ->and($result['organisation']['id'])->toBe('org-1');
+});
+
+it('keeps the org-scoped /me path when an organisation id is given', function () {
+ Functions\when('is_wp_error')->justReturn(false);
+ Functions\when('wp_json_encode')->alias(fn ($value) => json_encode($value));
+ Functions\when('wp_remote_retrieve_response_code')->justReturn(200);
+ Functions\when('wp_remote_retrieve_body')->justReturn('{"organisation":{"id":"org-1"}}');
+ Functions\when('wp_remote_retrieve_header')->justReturn('');
+
+ $seen = null;
+ Functions\when('wp_remote_request')->alias(function (string $url) use (&$seen) {
+ $seen = $url;
+
+ return ['response' => ['code' => 200], 'body' => '{"organisation":{"id":"org-1"}}'];
+ });
+
+ (new Client())->me('org-1', 'plain-token');
+
+ expect($seen)->toBe('https://datalumo.app/api/v1/org-1/me');
+});
+
+it('parses a compound widget key and rejects a secret', function () {
+ Functions\when('esc_html__')->returnArg(1);
+
+ expect((new Client())->parseWidgetKey('org-1/widget-2'))->toBe(['org-1', 'widget-2'])
+ ->and(fn () => (new Client())->parseWidgetKey('dl_secret'))
+ ->toThrow(ApiException::class);
+});
diff --git a/tests/Unit/CredentialsTest.php b/tests/Unit/CredentialsTest.php
new file mode 100644
index 0000000..ae95fe8
--- /dev/null
+++ b/tests/Unit/CredentialsTest.php
@@ -0,0 +1,20 @@
+toBeTrue();
+});
+
+it('does not treat a secret as a widget key', function () {
+ expect(Credentials::looksLikeWidgetKey('dl_abc123'))->toBeFalse();
+});
+
+it('does not treat a bare token as a widget key', function () {
+ expect(Credentials::looksLikeWidgetKey('1|plain-api-token'))->toBeFalse();
+});
+
+it('recognises a dl_ secret', function () {
+ expect(Credentials::looksLikeSecret('dl_abc123'))->toBeTrue()
+ ->and(Credentials::looksLikeSecret('1|plain-api-token'))->toBeFalse();
+});
diff --git a/tests/Unit/EmbedTest.php b/tests/Unit/EmbedTest.php
new file mode 100644
index 0000000..9440927
--- /dev/null
+++ b/tests/Unit/EmbedTest.php
@@ -0,0 +1,43 @@
+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
+ {
+ public function get_sku(): string
+ {
+ return 'VNECK-BLU';
+ }
+ });
+ 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/GrantTest.php b/tests/Unit/GrantTest.php
new file mode 100644
index 0000000..83fcf33
--- /dev/null
+++ b/tests/Unit/GrantTest.php
@@ -0,0 +1,104 @@
+justReturn('https://shop.example/');
+ Functions\when('admin_url')->alias(fn (string $path) => 'https://shop.example/wp-admin/'.$path);
+ Functions\when('wp_parse_url')->alias(fn (string $url, int $component = -1) => parse_url($url, $component));
+
+ expect(Grant::startUrl('https://datalumo.app', 'state-1'))->toBe(
+ 'https://datalumo.app/integrations/wordpress?'.http_build_query([
+ 'site' => 'shop.example',
+ 'site_url' => 'https://shop.example/',
+ 'return' => 'https://shop.example/wp-admin/admin-post.php?action=datalumo_oauth_callback',
+ 'cancel' => 'https://shop.example/wp-admin/options-general.php?page=datalumo&tab=connection',
+ 'state' => 'state-1',
+ ]),
+ );
+});
+
+it('maps an exchange payload onto stored options', function () {
+ Grant::apply([
+ 'token' => 'tok_1',
+ 'organisation' => ['id' => 'org_1', 'name' => 'Acme'],
+ 'sources' => [['id' => 'src_1', 'name' => 'shop.example']],
+ 'source' => ['id' => 'src_1', 'name' => 'shop.example'],
+ 'chatbot' => ['widget_key' => 'org_1/chat_1', 'signing_secret' => 'dl_secret'],
+ 'search' => ['widget_key' => 'org_1/search_1'],
+ ]);
+
+ expect(Options::get('api_token'))->toBe('tok_1')
+ ->and(Options::get('organisation.id'))->toBe('org_1')
+ ->and(Options::get('setup_pending'))->toBeTrue()
+ ->and(Options::get('setup_source_id'))->toBe('src_1')
+ ->and(Options::get('connected_via'))->toBe('grant')
+ ->and(Options::get('chatbot.widget_key'))->toBe('org_1/chat_1')
+ ->and(Options::get('chatbot.signing_secret'))->toBe('dl_secret')
+ ->and(Options::get('search_box.widget_key'))->toBe('org_1/search_1')
+ ->and(Options::get('enhanced.widget_key'))->toBe('org_1/search_1')
+ ->and(Options::get('syncs.0.source_id'))->toBe('src_1')
+ ->and(Options::get('syncs.0.post_types'))->toBe(['post', 'page']);
+});
+
+it('sends the browser to Datalumo with wp_redirect so an off-site host is not rewritten', function () {
+ Functions\when('current_user_can')->justReturn(true);
+ Functions\when('check_admin_referer')->justReturn(true);
+ Functions\when('set_transient')->justReturn(true);
+ Functions\when('get_current_user_id')->justReturn(1);
+ Functions\when('esc_url_raw')->returnArg(1);
+ Functions\when('wp_unslash')->returnArg(1);
+ Functions\when('home_url')->justReturn('http://datalumo-wp.test/');
+ Functions\when('admin_url')->alias(fn (string $path) => 'http://datalumo-wp.test/wp-admin/'.$path);
+ Functions\when('wp_parse_url')->alias(fn (string $url, int $component = -1) => parse_url($url, $component));
+
+ $_POST = ['api_url' => 'https://dl.test'];
+
+ $redirected = null;
+ Functions\expect('wp_redirect')->once()->andReturnUsing(function (string $url) use (&$redirected): void {
+ $redirected = $url;
+ throw new RuntimeException('redirect');
+ });
+ Functions\expect('wp_safe_redirect')->never();
+
+ try {
+ Grant::start();
+ expect(false)->toBeTrue();
+ } catch (RuntimeException $e) {
+ expect($e->getMessage())->toBe('redirect');
+ }
+
+ expect($redirected)->toStartWith('https://dl.test/integrations/wordpress?');
+});
+
+it('rejects a callback when the stored state does not match', function () {
+ Functions\when('current_user_can')->justReturn(true);
+ Functions\when('get_current_user_id')->justReturn(1);
+ Functions\when('get_transient')->justReturn('expected-state');
+ Functions\when('delete_transient')->justReturn(true);
+ Functions\when('sanitize_text_field')->returnArg(1);
+ Functions\when('wp_unslash')->returnArg(1);
+ Functions\when('wp_create_nonce')->justReturn('nonce');
+ Functions\when('admin_url')->justReturn('https://shop.example/wp-admin/options-general.php');
+ Functions\when('add_query_arg')->alias(fn (array $args, string $url) => $url.'?'.http_build_query($args));
+
+ $redirected = null;
+ Functions\when('wp_safe_redirect')->alias(function (string $url) use (&$redirected): void {
+ $redirected = $url;
+ throw new RuntimeException('redirect');
+ });
+
+ $_GET = ['state' => 'wrong-state', 'code' => 'abc'];
+
+ try {
+ Grant::callback();
+ expect(false)->toBeTrue();
+ } catch (RuntimeException $e) {
+ expect($e->getMessage())->toBe('redirect');
+ }
+
+ expect($redirected)->toContain('grant_failed')
+ ->and($redirected)->not->toContain('connected');
+});
diff --git a/tests/Unit/HostNavigationTest.php b/tests/Unit/HostNavigationTest.php
new file mode 100644
index 0000000..2daedb0
--- /dev/null
+++ b/tests/Unit/HostNavigationTest.php
@@ -0,0 +1,78 @@
+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('ignores start_form', function () {
+ expect(hostNav()->resolveUrl('start_form', ['page_id' => '9']))->toBeNull();
+});
diff --git a/tests/Unit/IndexPhpTest.php b/tests/Unit/IndexPhpTest.php
new file mode 100644
index 0000000..18dcb4e
--- /dev/null
+++ b/tests/Unit/IndexPhpTest.php
@@ -0,0 +1,35 @@
+isDir() || ! in_array($current->getFilename(), $skip, true);
+ },
+ ),
+ RecursiveIteratorIterator::SELF_FIRST,
+ );
+
+ $missing = [];
+
+ foreach (array_merge([$root], iterator_to_array($iterator)) as $file) {
+ $dir = $file instanceof SplFileInfo
+ ? ($file->isDir() ? $file->getPathname() : null)
+ : $file;
+
+ if ($dir === null) {
+ continue;
+ }
+
+ $index = $dir.'/index.php';
+
+ if (! is_file($index) || ! str_contains((string) file_get_contents($index), 'Silence is golden')) {
+ $missing[] = $dir;
+ }
+ }
+
+ expect($missing)->toBe([]);
+});
diff --git a/tests/Unit/PagePreparerTest.php b/tests/Unit/PagePreparerTest.php
index 31c9c5b..b413063 100644
--- a/tests/Unit/PagePreparerTest.php
+++ b/tests/Unit/PagePreparerTest.php
@@ -33,6 +33,18 @@
->and($payload['content_mime'])->toBe('text/html');
});
+it('falls back when block rendering fatals', function () {
+ Functions\when('get_the_title')->justReturn('Product');
+ Functions\when('do_blocks')->alias(function (): string {
+ throw new Error('Call to undefined function Automattic\WooCommerce\Blocks\Domain\Services\wc_get_notices()');
+ });
+
+ $post = new WP_Post();
+ $post->post_content = 'Ceramic mug
';
+
+ expect((new PagePreparer())->prepare($post)['content'])->toBe('Ceramic mug
');
+});
+
it('skips a post with no content', function () {
Functions\when('get_the_title')->justReturn('Empty');
Functions\when('wp_strip_all_tags')->justReturn('');
@@ -42,3 +54,19 @@
expect((new PagePreparer())->prepare($post))->toBeNull();
});
+
+it('keeps a payload the filter filled after an empty body', function () {
+ Functions\when('get_the_title')->justReturn('Mug');
+ Functions\when('apply_filters')->alias(function (string $hook, mixed $value) {
+ if ($hook === 'datalumo_page_payload' && is_array($value)) {
+ $value['content'] = 'A ceramic mug.
';
+ }
+
+ return $value;
+ });
+
+ $post = new WP_Post();
+ $post->post_content = '';
+
+ expect((new PagePreparer())->prepare($post)['content'])->toBe('A ceramic mug.
');
+});
diff --git a/tests/Unit/SettingsPageTest.php b/tests/Unit/SettingsPageTest.php
new file mode 100644
index 0000000..ada4439
--- /dev/null
+++ b/tests/Unit/SettingsPageTest.php
@@ -0,0 +1,73 @@
+returnArg(1);
+
+ $page = new SettingsPage();
+ $method = new ReflectionMethod(SettingsPage::class, 'sanitizeWidgetKey');
+ $rejected = new ReflectionProperty(SettingsPage::class, 'widgetKeyRejected');
+
+ expect($method->invoke($page, $pasted, 'org-1/widget-2'))->toBe('org-1/widget-2')
+ ->and($rejected->getValue($page))->toBeTrue();
+})->with([
+ 'dl_secret',
+ '1|plain-api-token',
+ 'not-a-key',
+]);
+
+it('does not print a second Settings saved notice', function () {
+ $view = (string) file_get_contents(dirname(__DIR__, 2).'/resources/views/settings.php');
+
+ expect($view)->not->toContain("esc_html_e('Settings saved.'");
+});
+
+it('points settings help at the WordPress plugin docs', function () {
+ expect(SettingsPage::docsUrl())->toBe('https://datalumo.app/docs/wordpress')
+ ->and(SettingsPage::docsUrl('chat-page-actions'))->toBe('https://datalumo.app/docs/wordpress#chat-page-actions')
+ ->and(SettingsPage::helpUrl())->toBe('https://datalumo.app/docs/wordpress')
+ ->and(SettingsPage::ASK_MAX_LENGTH)->toBe(280);
+});
+
+it('hides the self-hosted URL behind a disclosure on connect', function () {
+ $view = (string) file_get_contents(dirname(__DIR__, 2).'/resources/views/settings.php');
+
+ expect($view)->toContain('datalumo-self-host')
+ ->and($view)->toContain("Using a self-hosted Datalumo?")
+ ->and($view)->toContain('datalumo-grant-api-url');
+});
+
+it('hides the settings sidebar until setup is ready', function () {
+ $view = (string) file_get_contents(dirname(__DIR__, 2).'/resources/views/settings.php');
+
+ expect($view)->toContain('datalumo-settings-aside')
+ ->and(substr_count($view, 'if (SettingsPage::setupIsReady())'))->toBe(2);
+});
+
+it('hides other tabs until the post-connect checklist is saved', function () {
+ Options::merge([
+ 'api_token' => 'tok_1',
+ 'organisation' => ['id' => 'org_1'],
+ 'setup_pending' => true,
+ ]);
+
+ expect(SettingsPage::setupIsReady())->toBeFalse();
+
+ Options::set('setup_pending', false);
+
+ expect(SettingsPage::setupIsReady())->toBeTrue();
+});
+
+it('accepts a compound widget key', function () {
+ Functions\when('sanitize_text_field')->returnArg(1);
+
+ $page = new SettingsPage();
+ $method = new ReflectionMethod(SettingsPage::class, 'sanitizeWidgetKey');
+ $rejected = new ReflectionProperty(SettingsPage::class, 'widgetKeyRejected');
+
+ expect($method->invoke($page, 'org-1/widget-2', ''))->toBe('org-1/widget-2')
+ ->and($rejected->getValue($page))->toBeFalse();
+});
diff --git a/tests/Unit/WooCommerceSkuTest.php b/tests/Unit/WooCommerceSkuTest.php
new file mode 100644
index 0000000..cad9da3
--- /dev/null
+++ b/tests/Unit/WooCommerceSkuTest.php
@@ -0,0 +1,230 @@
+id;
+ }
+
+ public function get_sku(): string
+ {
+ return $this->sku;
+ }
+
+ public function get_short_description(): string
+ {
+ return $this->shortDescription;
+ }
+
+ public function get_attributes(): array
+ {
+ return $this->attributes;
+ }
+
+ public function is_type(string $type): bool
+ {
+ return $this->type === $type;
+ }
+
+ public function get_children(): array
+ {
+ return $this->children;
+ }
+ };
+}
+
+function wcAttribute(string $name, array $options, bool $visible = true, bool $taxonomy = false): object
+{
+ return new class($name, $options, $visible, $taxonomy)
+ {
+ public function __construct(
+ private string $name,
+ private array $options,
+ private bool $visible,
+ private bool $taxonomy,
+ ) {}
+
+ public function get_name(): string
+ {
+ return $this->name;
+ }
+
+ public function get_visible(): bool
+ {
+ return $this->visible;
+ }
+
+ public function is_taxonomy(): bool
+ {
+ return $this->taxonomy;
+ }
+
+ public function get_options(): array
+ {
+ return $this->options;
+ }
+ };
+}
+
+beforeEach(function () {
+ Functions\when('wp_strip_all_tags')->returnArg(1);
+ Functions\when('wc_attribute_label')->returnArg(1);
+ Functions\when('get_the_terms')->justReturn([]);
+});
+
+it('leaves non-product payloads alone', function () {
+ $post = new WP_Post();
+ $post->post_type = 'page';
+
+ $payload = ['content' => 'About
', 'meta' => ['post_type' => 'page']];
+
+ expect((new WooCommerce())->enrichProduct($payload, $post))->toBe($payload);
+});
+
+it('adds a simple product sku to meta and searchable content', function () {
+ Functions\when('wc_get_product')->justReturn(wcProduct(sku: 'VNECK-BLU'));
+
+ $post = new WP_Post();
+ $post->ID = 12;
+ $post->post_type = 'product';
+
+ $payload = (new WooCommerce())->enrichProduct([
+ 'content' => 'A blue v-neck.
',
+ 'meta' => ['post_type' => 'product'],
+ ], $post);
+
+ expect($payload['meta']['sku'])->toBe('VNECK-BLU')
+ ->and($payload['content'])->toContain('sku: VNECK-BLU');
+});
+
+it('includes variation skus on a variable product', function () {
+ Functions\when('wc_get_product')->alias(function (int $id) {
+ return match ($id) {
+ 10 => wcProduct(sku: 'jacket', type: 'variable', children: [11, 12], id: 10),
+ 11 => wcProduct(sku: 'jacket-blue-m', id: 11),
+ 12 => wcProduct(sku: 'jacket-red-l', id: 12),
+ default => null,
+ };
+ });
+
+ $post = new WP_Post();
+ $post->ID = 10;
+ $post->post_type = 'product';
+
+ $payload = (new WooCommerce())->enrichProduct([
+ 'content' => 'A jacket.
',
+ 'meta' => ['post_type' => 'product'],
+ ], $post);
+
+ expect($payload['meta']['sku'])->toBe(['jacket', 'jacket-blue-m', 'jacket-red-l'])
+ ->and($payload['content'])->toContain('sku: jacket, jacket-blue-m, jacket-red-l');
+});
+
+it('does not overwrite an existing sku mapping', function () {
+ Functions\when('wc_get_product')->justReturn(wcProduct(sku: 'FROM-WC'));
+
+ $post = new WP_Post();
+ $post->ID = 3;
+ $post->post_type = 'product';
+
+ $payload = (new WooCommerce())->enrichProduct([
+ 'content' => 'Mapped.
',
+ 'meta' => ['sku' => 'FROM-MAP'],
+ ], $post);
+
+ expect($payload['meta']['sku'])->toBe('FROM-MAP')
+ ->and($payload['content'])->toBe('Mapped.
');
+});
+
+it('prepends the short description when the body is empty', function () {
+ Functions\when('wc_get_product')->justReturn(wcProduct(
+ sku: 'MUG-1',
+ shortDescription: 'A ceramic mug.',
+ ));
+
+ $post = new WP_Post();
+ $post->ID = 4;
+ $post->post_type = 'product';
+
+ $payload = (new WooCommerce())->enrichProduct([
+ 'content' => '',
+ 'meta' => ['post_type' => 'product'],
+ ], $post);
+
+ expect($payload['content'])->toStartWith('A ceramic mug.
')
+ ->and($payload['content'])->toContain('sku: MUG-1');
+});
+
+it('adds product categories and tags as searchable meta', function () {
+ Functions\when('wc_get_product')->justReturn(wcProduct(sku: 'SHOE-1'));
+ Functions\when('get_the_terms')->alias(function (int $id, string $taxonomy) {
+ return match ($taxonomy) {
+ 'product_cat' => [
+ (object) ['name' => 'Running', 'slug' => 'running'],
+ (object) ['name' => 'Mens', 'slug' => 'mens'],
+ ],
+ 'product_tag' => [
+ (object) ['name' => 'Sale', 'slug' => 'sale'],
+ ],
+ default => [],
+ };
+ });
+
+ $post = new WP_Post();
+ $post->ID = 8;
+ $post->post_type = 'product';
+
+ $payload = (new WooCommerce())->enrichProduct([
+ 'content' => 'Trail shoe.
',
+ 'meta' => ['post_type' => 'product'],
+ ], $post);
+
+ expect($payload['meta']['categories'])->toBe(['running', 'mens'])
+ ->and($payload['meta']['tags'])->toBe(['sale'])
+ ->and($payload['content'])->toContain('category: Running, Mens')
+ ->and($payload['content'])->toContain('tag: Sale');
+});
+
+it('adds visible attributes and skips hidden ones', function () {
+ Functions\when('wc_get_product')->justReturn(wcProduct(
+ sku: 'JKT-1',
+ attributes: [
+ wcAttribute('Color', ['Blue', 'Red']),
+ wcAttribute('Internal', ['secret'], visible: false),
+ ],
+ ));
+
+ $post = new WP_Post();
+ $post->ID = 9;
+ $post->post_type = 'product';
+
+ $payload = (new WooCommerce())->enrichProduct([
+ 'content' => 'A jacket.
',
+ 'meta' => ['post_type' => 'product'],
+ ], $post);
+
+ expect($payload['meta']['attributes'])->toBe(['Color' => ['Blue', 'Red']])
+ ->and($payload['content'])->toContain('Color: Blue, Red')
+ ->and($payload['content'])->not->toContain('Internal');
+});
diff --git a/tests/Unit/index.php b/tests/Unit/index.php
new file mode 100644
index 0000000..6220032
--- /dev/null
+++ b/tests/Unit/index.php
@@ -0,0 +1,2 @@
+