diff --git a/assets/js/admin-connect.js b/assets/js/admin-connect.js
index 334a019..f213d81 100644
--- a/assets/js/admin-connect.js
+++ b/assets/js/admin-connect.js
@@ -2,6 +2,52 @@
'use strict';
$(function () {
+ const diagBtn = $('#octavawms-connectivity-run');
+ const diagOut = $('#octavawms-connectivity-output');
+ const diagCfg = window.octavawmsConnect;
+ if (
+ diagBtn.length &&
+ diagOut.length &&
+ diagCfg &&
+ diagCfg.ajaxUrl &&
+ diagCfg.connectivityProbeAction &&
+ diagCfg.connectivityProbeNonce
+ ) {
+ const spDiag = $('#octavawms-connectivity-spinner');
+ const strings = diagCfg.strings || {};
+ diagBtn.on('click', function () {
+ diagBtn.prop('disabled', true);
+ spDiag.css('visibility', 'visible');
+ diagOut.text(strings.connectivityRunning || 'Running diagnostics…');
+ $.post(
+ diagCfg.ajaxUrl,
+ {
+ action: diagCfg.connectivityProbeAction,
+ security: diagCfg.connectivityProbeNonce,
+ },
+ function (res) {
+ if (res && res.success && res.data && typeof res.data.report === 'string') {
+ diagOut.text(res.data.report);
+ return;
+ }
+ diagOut.text(
+ (res && res.data && res.data.message) ||
+ strings.connectivityFailed ||
+ 'Diagnostics failed.'
+ );
+ },
+ 'json'
+ )
+ .fail(function () {
+ diagOut.text(strings.connectivityFailed || 'Diagnostics failed.');
+ })
+ .always(function () {
+ spDiag.css('visibility', 'hidden');
+ diagBtn.prop('disabled', false);
+ });
+ });
+ }
+
const btn = $('#octavawms-connect-btn');
const panelBtn = $('#octavawms-panel-login-btn');
if (!btn.length && !panelBtn.length) {
diff --git a/assets/js/admin-settings-matrix.js b/assets/js/admin-settings-matrix.js
index 6b0a673..5f59b4a 100644
--- a/assets/js/admin-settings-matrix.js
+++ b/assets/js/admin-settings-matrix.js
@@ -13,7 +13,120 @@
return window.octavawmsCarrierMatrix || {};
}
+ function matrixStrings() {
+ return cfg().strings || {};
+ }
+
+ function matrixAjaxConfigOk() {
+ var c = cfg();
+ return !!(c.ajaxUrl && c.action && c.nonce);
+ }
+
+ function rejectMissingMatrixAjaxConfig() {
+ var msg =
+ matrixStrings().ajaxMissingConfig ||
+ 'Carrier matrix script configuration is missing. Reload the page or clear your cache.';
+ var fakeXhr = {
+ status: 0,
+ responseText: '',
+ responseJSON: { data: { message: msg } }
+ };
+ var d = $.Deferred();
+ d.reject(fakeXhr, 'error', msg);
+ return d.promise();
+ }
+
+ function appendAjaxDebug(base, xhr, textStatus) {
+ if (!cfg().detailAjaxErrors) {
+ return base;
+ }
+ var status = xhr && xhr.status != null ? xhr.status : '';
+ var raw = xhr && xhr.responseText != null ? String(xhr.responseText).trim() : '';
+ var first = raw ? raw.split(/\r?\n/)[0] : '';
+ if (first.length > 200) {
+ first = first.slice(0, 200) + '…';
+ }
+ var bits = [];
+ if (status !== '') {
+ bits.push('HTTP ' + status);
+ }
+ if (textStatus) {
+ bits.push(textStatus);
+ }
+ if (first) {
+ bits.push(first);
+ }
+ return bits.length ? base + ' [' + bits.join(' | ') + ']' : base;
+ }
+
+ /**
+ * Maps admin-ajax failures (including WordPress bare "0" + 400) to a readable message.
+ *
+ * @param {JQuery.jqXHR|null} xhr
+ * @param {string} textStatus
+ * @returns {string}
+ */
+ function formatMatrixAjaxError(xhr, textStatus) {
+ var s = matrixStrings();
+ if (textStatus === 'parsererror') {
+ return appendAjaxDebug(s.ajaxParseError || s.saveFailed || 'Error', xhr, textStatus);
+ }
+ var wpJsonMsg = null;
+ if (xhr && xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
+ wpJsonMsg = String(xhr.responseJSON.data.message);
+ } else if (xhr && xhr.responseText) {
+ try {
+ var parsed = JSON.parse(String(xhr.responseText));
+ if (parsed && parsed.data && parsed.data.message) {
+ wpJsonMsg = String(parsed.data.message);
+ }
+ } catch (ignore) {
+ /* not JSON */
+ }
+ }
+ if (wpJsonMsg) {
+ return appendAjaxDebug(wpJsonMsg, xhr, textStatus);
+ }
+ var status = xhr && xhr.status != null ? xhr.status : 0;
+ var body = xhr && xhr.responseText != null ? String(xhr.responseText).trim() : '';
+ if (status === 400 && body === '0') {
+ return appendAjaxDebug(s.ajaxNoHandler || s.saveFailed || 'Error', xhr, textStatus);
+ }
+ if (status === 403 && (body === '-1' || body === '0')) {
+ return appendAjaxDebug(s.ajaxNonceExpired || s.saveFailed || 'Error', xhr, textStatus);
+ }
+ if (status >= 400) {
+ return appendAjaxDebug(s.ajaxHttpError || s.saveFailed || 'Error', xhr, textStatus);
+ }
+ return appendAjaxDebug(s.ajaxHttpError || s.saveFailed || 'Error', xhr, textStatus);
+ }
+
+ function setSpinner(on) {
+ var $sp = $('#octavawms-matrix-spinner');
+ if (!$sp.length) {
+ return;
+ }
+ $sp.css('visibility', on ? 'visible' : 'hidden');
+ if (on) {
+ $sp.addClass('is-active');
+ } else {
+ $sp.removeClass('is-active');
+ }
+ }
+
+ function setMessage(text, isError) {
+ var $m = $('#octavawms-matrix-message');
+ if (!$m.length) {
+ return;
+ }
+ $m.text(text || '');
+ $m.css('color', isError ? '#b32d2d' : '#1e4620');
+ }
+
function post(subaction, extra) {
+ if (!matrixAjaxConfigOk()) {
+ return rejectMissingMatrixAjaxConfig();
+ }
var data = $.extend(
{
action: cfg().action,
@@ -79,7 +192,7 @@
$('
').text(ds).html() +
'" data-initial-label="">' +
' | ' +
' | ' +
''
@@ -109,23 +222,27 @@
var $rate = $tr.find('.octavawms-rate');
$rate.empty();
$rate.append(
- $('', { value: '', text: (cfg().strings && cfg().strings.anyRate) || '—' })
+ $('', { value: '', text: matrixStrings().anyRate || '—' })
);
if (!deliveryServiceId || deliveryServiceId <= 0) {
return;
}
- post('rates', { delivery_service_id: String(deliveryServiceId) }).done(function (res) {
- if (!res || !res.success || !res.data || !res.data.items) {
- return;
- }
- res.data.items.forEach(function (it) {
- var o = $('', { value: String(it.id), text: it.text || String(it.id) });
- if (selectedRateId && String(it.id) === String(selectedRateId)) {
- o.prop('selected', true);
+ post('rates', { delivery_service_id: String(deliveryServiceId) })
+ .done(function (res) {
+ if (!res || !res.success || !res.data || !res.data.items) {
+ return;
}
- $rate.append(o);
+ res.data.items.forEach(function (it) {
+ var o = $('', { value: String(it.id), text: it.text || String(it.id) });
+ if (selectedRateId && String(it.id) === String(selectedRateId)) {
+ o.prop('selected', true);
+ }
+ $rate.append(o);
+ });
+ })
+ .fail(function (jqXHR, textStatus) {
+ setMessage(formatMatrixAjaxError(jqXHR, textStatus), true);
});
- });
}
function initCarrierSelect($tr, row) {
@@ -137,7 +254,7 @@
$sel.selectWoo({
width: '100%',
allowClear: true,
- placeholder: (cfg().strings && cfg().strings.pickCarrier) || '…',
+ placeholder: matrixStrings().pickCarrier || '…',
ajax: {
url: cfg().ajaxUrl,
type: 'POST',
@@ -153,6 +270,28 @@
page: params.page || 1
};
},
+ transport: function (params, success, failure) {
+ if (!matrixAjaxConfigOk()) {
+ var msg =
+ matrixStrings().ajaxMissingConfig ||
+ 'Carrier matrix script configuration is missing. Reload the page or clear your cache.';
+ setMessage(msg, true);
+ var fakeXhr = {
+ status: 0,
+ responseText: '',
+ responseJSON: { data: { message: msg } }
+ };
+ failure(fakeXhr, 'error', msg);
+ return $.Deferred().resolve().promise();
+ }
+ var request = $.ajax(params);
+ request.then(success);
+ request.fail(function (jqXHR, textStatus) {
+ setMessage(formatMatrixAjaxError(jqXHR, textStatus), true);
+ failure(jqXHR, textStatus, jqXHR && jqXHR.statusText);
+ });
+ return request;
+ },
processResults: function (data, params) {
params.page = params.page || 1;
if (!data || !data.success || !data.data || !data.data.items) {
@@ -212,26 +351,17 @@
});
}
- function setSpinner(on) {
- var $sp = $('#octavawms-matrix-spinner');
- if (!$sp.length) {
- return;
- }
- $sp.css('visibility', on ? 'visible' : 'hidden');
- if (on) {
- $sp.addClass('is-active');
- } else {
- $sp.removeClass('is-active');
+ function unexpectedJsonResponseMessage(res) {
+ var s = matrixStrings();
+ var base = s.ajaxInvalidResponse || s.saveFailed || 'Error';
+ if (!cfg().detailAjaxErrors) {
+ return base;
}
- }
-
- function setMessage(text, isError) {
- var $m = $('#octavawms-matrix-message');
- if (!$m.length) {
- return;
+ var snippet = res === null || res === undefined ? '' : String(res);
+ if (snippet.length > 120) {
+ snippet = snippet.slice(0, 120) + '…';
}
- $m.text(text || '');
- $m.css('color', isError ? '#b32d2d' : '#1e4620');
+ return snippet ? base + ' [' + snippet + ']' : base;
}
$(function () {
@@ -261,7 +391,7 @@
$visual.hide();
$jsonWrap.show();
jsonMode = true;
- $toggle.text((cfg().strings && cfg().strings.switchVisual) || 'Visual');
+ $toggle.text(matrixStrings().switchVisual || 'Visual');
}
function switchToVisual() {
@@ -270,11 +400,11 @@
try {
parsed = JSON.parse(raw);
} catch (e) {
- setMessage((cfg().strings && cfg().strings.invalidJson) || 'Invalid JSON', true);
+ setMessage(matrixStrings().invalidJson || 'Invalid JSON', true);
return;
}
if (!Array.isArray(parsed)) {
- setMessage((cfg().strings && cfg().strings.invalidJson) || 'Invalid JSON', true);
+ setMessage(matrixStrings().invalidJson || 'Invalid JSON', true);
return;
}
setMessage('', false);
@@ -285,7 +415,7 @@
$jsonWrap.hide();
$visual.show();
jsonMode = false;
- $toggle.text((cfg().strings && cfg().strings.switchJson) || 'JSON');
+ $toggle.text(matrixStrings().switchJson || 'JSON');
}
$toggle.on('click', function () {
@@ -318,11 +448,11 @@
try {
payload = JSON.parse($jsonTa.val());
} catch (e) {
- setMessage((cfg().strings && cfg().strings.invalidJson) || 'Invalid JSON', true);
+ setMessage(matrixStrings().invalidJson || 'Invalid JSON', true);
return;
}
if (!Array.isArray(payload)) {
- setMessage((cfg().strings && cfg().strings.invalidJson) || 'Invalid JSON', true);
+ setMessage(matrixStrings().invalidJson || 'Invalid JSON', true);
return;
}
} else {
@@ -333,8 +463,12 @@
post('save', { carrier_mapping_json: JSON.stringify(payload) })
.done(function (res) {
setSpinner(false);
+ if (res === 0 || res === '0') {
+ setMessage(unexpectedJsonResponseMessage(res), true);
+ return;
+ }
if (res && res.success) {
- setMessage((cfg().strings && cfg().strings.saved) || 'Saved', false);
+ setMessage(matrixStrings().saved || 'Saved', false);
if (!jsonMode && res.data && res.data.carrierMapping) {
$tbody.find('tr').each(function () {
destroySelectWoo($(this).find('.octavawms-carrier'));
@@ -344,37 +478,45 @@
} else {
setMessage(
(res && res.data && res.data.message) ||
- (cfg().strings && cfg().strings.saveFailed) ||
+ matrixStrings().saveFailed ||
'Error',
true
);
}
})
- .fail(function () {
+ .fail(function (jqXHR, textStatus) {
setSpinner(false);
- setMessage((cfg().strings && cfg().strings.saveFailed) || 'Error', true);
+ setMessage(formatMatrixAjaxError(jqXHR, textStatus), true);
});
});
// Populate WC meta key datalist from order meta in the database.
- post('meta_keys', { search: '' }).done(function (res) {
- if (!res || !res.success || !res.data || !res.data.items) {
- return;
- }
- var $dl = $('#octavawms-wc-meta-keys');
- res.data.items.forEach(function (k) {
- $dl.append($('', { value: k }));
+ post('meta_keys', { search: '' })
+ .done(function (res) {
+ if (!res || !res.success || !res.data || !res.data.items) {
+ return;
+ }
+ var $dl = $('#octavawms-wc-meta-keys');
+ res.data.items.forEach(function (k) {
+ $dl.append($('', { value: k }));
+ });
+ })
+ .fail(function (jqXHR, textStatus) {
+ setMessage(formatMatrixAjaxError(jqXHR, textStatus), true);
});
- });
setSpinner(true);
post('get')
.done(function (res) {
setSpinner(false);
+ if (res === 0 || res === '0') {
+ setMessage(unexpectedJsonResponseMessage(res), true);
+ return;
+ }
if (!res || !res.success) {
setMessage(
(res && res.data && res.data.message) ||
- (cfg().strings && cfg().strings.loadFailed) ||
+ matrixStrings().loadFailed ||
'Load failed',
true
);
@@ -384,9 +526,9 @@
renderRows($tbody, rows);
$jsonTa.val(rowsToJsonPretty(rows));
})
- .fail(function () {
+ .fail(function (jqXHR, textStatus) {
setSpinner(false);
- setMessage((cfg().strings && cfg().strings.loadFailed) || 'Load failed', true);
+ setMessage(formatMatrixAjaxError(jqXHR, textStatus), true);
});
});
})(jQuery);
diff --git a/composer.json b/composer.json
index 9c53413..8cc68ac 100644
--- a/composer.json
+++ b/composer.json
@@ -23,10 +23,13 @@
"scripts": {
"php-lint": [
"php -l octavawms-woocommerce.php",
+ "php -l scripts/check-pro-oawms-connection.php",
"php -l src/Activation.php",
"php -l src/AdminLabelActions.php",
+ "php -l src/Admin/ConnectivityProbe.php",
"php -l src/Admin/LabelAjax.php",
"php -l src/Admin/LabelMetaBox.php",
+ "php -l src/Admin/SettingsAjax.php",
"php -l src/Api/BackendApiClient.php",
"php -l src/Api/LabelService.php",
"php -l src/ConnectService.php",
diff --git a/octavawms-woocommerce.php b/octavawms-woocommerce.php
index 8578167..0e8b614 100644
--- a/octavawms-woocommerce.php
+++ b/octavawms-woocommerce.php
@@ -47,12 +47,18 @@ static function (string $class): void {
if ($done) {
return;
}
- $done = true;
+
+ /**
+ * Prevents attaching the WooCommerce integrations filter twice if bootstrap retries after a fatal error mid-run.
+ */
+ static $integrationsFilterAttached = false;
if (function_exists('add_filter')) {
\OctavaWMS\WooCommerce\I18n\BrandedStrings::register();
}
- if (is_readable(__DIR__ . '/src/SettingsPage.php') && class_exists(\WC_Integration::class, false)) {
+ if (! $integrationsFilterAttached && is_readable(__DIR__ . '/src/SettingsPage.php')
+ && class_exists(\WC_Integration::class, false)) {
+ $integrationsFilterAttached = true;
require_once __DIR__ . '/src/SettingsPage.php';
if (class_exists(\OctavaWMS\WooCommerce\SettingsPage::class, false)) {
add_filter('woocommerce_integrations', static function (array $list): array {
@@ -67,8 +73,7 @@ static function (string $class): void {
$connect->register();
$apiClient = new \OctavaWMS\WooCommerce\Api\BackendApiClient();
- $settingsAjax = new \OctavaWMS\WooCommerce\Admin\SettingsAjax($apiClient);
- $settingsAjax->register();
+ \OctavaWMS\WooCommerce\Admin\SettingsAjax::registerAjax();
$orderSync = new \OctavaWMS\WooCommerce\OrderSyncService($apiClient);
$orderSync->register();
$labelService = new \OctavaWMS\WooCommerce\Api\LabelService($apiClient);
@@ -76,8 +81,43 @@ static function (string $class): void {
$labelAjax = new \OctavaWMS\WooCommerce\Admin\LabelAjax($apiClient, $labelService, $labelMetaBox);
$adminActions = new \OctavaWMS\WooCommerce\AdminLabelActions($labelService, $labelMetaBox, $labelAjax, $apiClient);
$adminActions->register();
+
+ $done = true;
};
+// Register carrier-matrix AJAX early so admin-ajax always sees wp_ajax_* / wp_ajax_nopriv_* hooks,
+// even if WooCommerce bootstrap timing defers the rest of the plugin. No BackendApiClient here.
+add_action(
+ 'plugins_loaded',
+ static function (): void {
+ if (! function_exists('has_action')) {
+ return;
+ }
+ $action = \OctavaWMS\WooCommerce\Admin\SettingsAjax::ACTION;
+ if (has_action('wp_ajax_' . $action) && has_action('wp_ajax_nopriv_' . $action)) {
+ return;
+ }
+ \OctavaWMS\WooCommerce\Admin\SettingsAjax::registerAjax();
+ },
+ 20
+);
+
+// If hooks were removed or never attached, register before admin-ajax reaches admin_init.
+add_action(
+ 'init',
+ static function (): void {
+ if (! function_exists('has_action')) {
+ return;
+ }
+ $action = \OctavaWMS\WooCommerce\Admin\SettingsAjax::ACTION;
+ if (has_action('wp_ajax_' . $action) && has_action('wp_ajax_nopriv_' . $action)) {
+ return;
+ }
+ \OctavaWMS\WooCommerce\Admin\SettingsAjax::registerAjax();
+ },
+ 0
+);
+
// Run after WooCommerce is ready. `woocommerce_loaded` can fire before this plugin's file loads
// (plugin load order); use did_action so we still bootstrap in that case.
add_action(
@@ -95,6 +135,21 @@ static function () use ($octavawms_bootstrap_woocommerce): void {
5
);
+// Recover from load-order failures: WP 6+ returns wp_die('0', 400) when no wp_ajax_ hook exists yet.
+add_action(
+ 'plugins_loaded',
+ static function () use ($octavawms_bootstrap_woocommerce): void {
+ if (! function_exists('WC')) {
+ return;
+ }
+ $action = \OctavaWMS\WooCommerce\Admin\SettingsAjax::ACTION;
+ if (! has_action('wp_ajax_' . $action) || ! has_action('wp_ajax_nopriv_' . $action)) {
+ $octavawms_bootstrap_woocommerce();
+ }
+ },
+ PHP_INT_MAX
+);
+
if (is_admin() && is_readable(__DIR__ . '/src/Notices.php')) {
require_once __DIR__ . '/src/Notices.php';
(new \OctavaWMS\WooCommerce\Notices())->register();
diff --git a/scripts/check-pro-oawms-connection.php b/scripts/check-pro-oawms-connection.php
new file mode 100644
index 0000000..5044a08
--- /dev/null
+++ b/scripts/check-pro-oawms-connection.php
@@ -0,0 +1,82 @@
+/dev/null 2>&1; then
+ host="${BASE_URL#*://}"
+ host="${host%%/*}"
+ echo "=== DNS (dig +short ${host}) ==="
+ dig +short "${host}" A || true
+ dig +short "${host}" AAAA || true
+ echo
+fi
+
+echo "Done."
diff --git a/src/Admin/ConnectivityProbe.php b/src/Admin/ConnectivityProbe.php
new file mode 100644
index 0000000..be67e24
--- /dev/null
+++ b/src/Admin/ConnectivityProbe.php
@@ -0,0 +1,271 @@
+
+ */
+ public const STANDARD_CLOUD_BASES_FOR_DIAGNOSTICS = [
+ 'https://pro.oawms.com',
+ 'https://alpha.orderadmin.eu',
+ 'https://api.octavawms.com',
+ ];
+
+ /**
+ * Probe one HTTPS API base only (standalone script single-URL mode).
+ */
+ public static function buildReport(
+ string $baseUrl,
+ int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT,
+ int $maxTime = self::DEFAULT_MAX_TIME
+ ): string {
+ return self::buildProbeBodyForSingleBase(
+ self::canonicalHttpsBase(trim($baseUrl)),
+ $connectTimeout,
+ $maxTime
+ )
+ . "\nDone.\n";
+ }
+
+ /**
+ * Probe the store's resolved API host (when non-empty) plus every {@see STANDARD_CLOUD_BASES_FOR_DIAGNOSTICS}.
+ * Duplicates (same scheme+host as a standard endpoint) are skipped.
+ */
+ public static function buildFullDiagnosticsReport(
+ ?string $configuredIntegrationBase,
+ int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT,
+ int $maxTime = self::DEFAULT_MAX_TIME
+ ): string {
+ $blocks = [];
+ $seenHosts = [];
+
+ $append = static function (
+ string $roleLabel,
+ string $baseNorm
+ ) use (&$blocks, &$seenHosts, $connectTimeout, $maxTime): void {
+ if ($baseNorm === '') {
+ return;
+ }
+ $hk = self::hostKeyFromBaseUrl($baseNorm);
+ if ($hk === '') {
+ return;
+ }
+ if (isset($seenHosts[$hk])) {
+ return;
+ }
+ $seenHosts[$hk] = true;
+
+ $hostDisplay = '';
+ $p = parse_url($baseNorm . '/');
+ if (is_array($p) && isset($p['host']) && is_string($p['host'])) {
+ $hostDisplay = $p['host'];
+ }
+
+ $blocks[] =
+ sprintf(
+ '========================================== HOST: %s%s',
+ $hostDisplay !== '' ? $hostDisplay : $hk,
+ $roleLabel !== '' ? ' — ' . $roleLabel : ''
+ )
+ . "\n"
+ . 'Base URL: ' . $baseNorm
+ . "\n=========================================="
+ . "\n\n"
+ . self::buildProbeBodyForSingleBase($baseNorm, $connectTimeout, $maxTime);
+ };
+
+ $cfg = '';
+ if ($configuredIntegrationBase !== null && trim($configuredIntegrationBase) !== '') {
+ $cfg = self::canonicalHttpsBase(trim($configuredIntegrationBase));
+ }
+ if ($cfg !== '') {
+ $append('Integration base (configured)', $cfg);
+ }
+
+ foreach (self::STANDARD_CLOUD_BASES_FOR_DIAGNOSTICS as $url) {
+ $append('Standard cloud', self::canonicalHttpsBase(trim((string) $url)));
+ }
+
+ return implode("\n\n" . str_repeat('-', 56) . "\n\n", $blocks) . "\n\nDone.\n";
+ }
+
+ /** HTTP(S) canonical base URL; empty if invalid or missing https host. */
+ private static function canonicalHttpsBase(string $url): string
+ {
+ if ($url === '') {
+ return '';
+ }
+ $url = rtrim(preg_replace('#\s+#', '', $url) ?? '', '/');
+ $parts = parse_url($url);
+ $schemeRaw = isset($parts['scheme']) ? strtolower((string) $parts['scheme']) : '';
+ $hostRaw = isset($parts['host']) ? strtolower((string) $parts['host']) : '';
+
+ if ($schemeRaw === '' || $hostRaw === '') {
+ return '';
+ }
+ $scheme = in_array($schemeRaw, ['https', 'http'], true) ? $schemeRaw : 'https';
+
+ return $scheme . '://' . $hostRaw;
+ }
+
+ private static function hostKeyFromBaseUrl(string $canonicalHttpsBaseUrl): string
+ {
+ $p = parse_url($canonicalHttpsBaseUrl . '/');
+ if (! is_array($p) || ! isset($p['scheme'], $p['host'])) {
+ return '';
+ }
+
+ return strtolower((string) $p['scheme']) . '://' . strtolower((string) $p['host']);
+ }
+
+ private static function buildProbeBodyForSingleBase(
+ string $baseUrl,
+ int $connectTimeout,
+ int $maxTime
+ ): string {
+ $lines = [];
+
+ $parts = parse_url($baseUrl . '/');
+ $host = is_array($parts) && isset($parts['host']) && is_string($parts['host']) ? $parts['host'] : '';
+
+ $lines[] = 'Probe URL base: ' . $baseUrl;
+ $lines[] = sprintf('limits: connect_timeout=%ds max_time=%ds', $connectTimeout, $maxTime);
+ $lines[] = '';
+
+ self::curlProbeAppend(
+ $lines,
+ 'HEAD apps/woocommerce/connect',
+ $baseUrl . '/apps/woocommerce/connect',
+ 'HEAD',
+ $connectTimeout,
+ $maxTime,
+ true
+ );
+
+ self::curlProbeAppend(
+ $lines,
+ 'GET root',
+ $baseUrl . '/',
+ 'GET',
+ $connectTimeout,
+ $maxTime,
+ false
+ );
+
+ $lines[] = '=== DNS ===';
+ if ($host !== '') {
+ $resolved = @gethostbyname($host);
+ if (is_string($resolved) && $resolved !== '' && $resolved !== $host) {
+ $lines[] = 'gethostbyname: ' . $resolved;
+ } else {
+ $lines[] = 'gethostbyname: (no IPv4)';
+ }
+ if (function_exists('dns_get_record')) {
+ foreach (@dns_get_record($host, DNS_A) ?: [] as $row) {
+ if (is_array($row) && ($row['type'] ?? '') === 'A' && isset($row['ip'])) {
+ $lines[] = 'A: ' . $row['ip'];
+ }
+ }
+ foreach (@dns_get_record($host, DNS_AAAA) ?: [] as $row) {
+ if (is_array($row) && ($row['type'] ?? '') === 'AAAA' && isset($row['ipv6'])) {
+ $lines[] = 'AAAA: ' . $row['ipv6'];
+ }
+ }
+ }
+ } else {
+ $lines[] = '(could not parse host)';
+ }
+
+ $lines[] = '';
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * @param list $lines mutates
+ */
+ private static function curlProbeAppend(
+ array &$lines,
+ string $label,
+ string $url,
+ string $method,
+ int $connectTimeout,
+ int $maxTime,
+ bool $footnoteOnConnectHint
+ ): void {
+ $lines[] = '=== ' . $label . ' ===';
+
+ if (! function_exists('curl_init')) {
+ $lines[] = 'Skipped: php-curl not available.';
+ $lines[] = '';
+
+ return;
+ }
+
+ $ch = curl_init($url);
+ if ($ch === false) {
+ $lines[] = 'curl_init failed';
+ $lines[] = '';
+
+ return;
+ }
+
+ $opts = [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HEADER => false,
+ CURLOPT_FOLLOWLOCATION => false,
+ CURLOPT_CONNECTTIMEOUT => max(1, $connectTimeout),
+ CURLOPT_TIMEOUT => max(1, $maxTime),
+ ];
+ if ($method === 'HEAD') {
+ $opts[CURLOPT_NOBODY] = true;
+ } else {
+ $opts[CURLOPT_HTTPGET] = true;
+ }
+ curl_setopt_array($ch, $opts);
+
+ $ok = curl_exec($ch);
+ $errno = curl_errno($ch);
+ $err = $errno !== 0 ? curl_error($ch) : '';
+
+ $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ $nw = (float) curl_getinfo($ch, CURLINFO_NAMELOOKUP_TIME);
+ $conn = (float) curl_getinfo($ch, CURLINFO_CONNECT_TIME);
+ $tls = (float) curl_getinfo($ch, CURLINFO_APPCONNECT_TIME);
+ $ttfb = (float) curl_getinfo($ch, CURLINFO_STARTTRANSFER_TIME);
+ $tot = (float) curl_getinfo($ch, CURLINFO_TOTAL_TIME);
+ curl_close($ch);
+
+ if ($errno !== 0 || $ok === false) {
+ $lines[] = sprintf('curl error %s: %s', (string) $errno, $err);
+ }
+ $lines[] = sprintf(
+ 'status:%s namelookup:%.6fs connect:%.6fs appconnect:%.6fs starttransfer:%.6fs total:%.6fs',
+ $code > 0 ? (string) $code : 'n/a',
+ $nw,
+ $conn,
+ $tls > 0 ? $tls : 0.0,
+ $ttfb,
+ $tot
+ );
+
+ if ($footnoteOnConnectHint) {
+ $lines[] = '(405 / 401 on this path usually still means the host is reachable)';
+ }
+ $lines[] = '';
+ }
+}
diff --git a/src/Admin/LabelAjax.php b/src/Admin/LabelAjax.php
index c222348..c3f3689 100644
--- a/src/Admin/LabelAjax.php
+++ b/src/Admin/LabelAjax.php
@@ -48,6 +48,9 @@ public function __construct(BackendApiClient $apiClient, LabelService $labelServ
public function register(): void
{
+ if (has_action('wp_ajax_octavawms_order_status')) {
+ return;
+ }
add_action('wp_ajax_octavawms_order_status', [$this, 'handleAjaxOrderStatus']);
add_action('wp_ajax_octavawms_upload_order', [$this, 'handleAjaxUploadOrder']);
add_action('wp_ajax_octavawms_generate_label', [$this, 'handleAjaxGenerateLabel']);
diff --git a/src/Admin/SettingsAjax.php b/src/Admin/SettingsAjax.php
index c5f26dd..07380f4 100644
--- a/src/Admin/SettingsAjax.php
+++ b/src/Admin/SettingsAjax.php
@@ -21,9 +21,31 @@ public function __construct(BackendApiClient $apiClient)
$this->apiClient = $apiClient;
}
+ /**
+ * Registers admin-ajax hooks without instantiating this class first, so a fatal during
+ * BackendApiClient construction cannot prevent the hooks from existing.
+ *
+ * WordPress dispatches logged-out requests only to wp_ajax_nopriv_.
+ */
+ public static function registerAjax(): void
+ {
+ if (! has_action('wp_ajax_' . self::ACTION)) {
+ add_action('wp_ajax_' . self::ACTION, [self::class, 'dispatchAjax']);
+ }
+ if (! has_action('wp_ajax_nopriv_' . self::ACTION)) {
+ add_action('wp_ajax_nopriv_' . self::ACTION, [self::class, 'dispatchAjax']);
+ }
+ }
+
+ public static function dispatchAjax(): void
+ {
+ $api = new BackendApiClient();
+ (new self($api))->handleAjax();
+ }
+
public function register(): void
{
- add_action('wp_ajax_' . self::ACTION, [$this, 'handleAjax']);
+ self::registerAjax();
}
public function handleAjax(): void
diff --git a/src/ConnectService.php b/src/ConnectService.php
index d0972ad..7b33984 100644
--- a/src/ConnectService.php
+++ b/src/ConnectService.php
@@ -4,6 +4,7 @@
namespace OctavaWMS\WooCommerce;
+use OctavaWMS\WooCommerce\Admin\ConnectivityProbe;
use OctavaWMS\WooCommerce\Admin\SettingsAjax;
use OctavaWMS\WooCommerce\Api\BackendApiClient;
@@ -16,10 +17,20 @@ class ConnectService
/** @see handleAjaxPanelLoginUrl() */
public const PANEL_LOGIN_NONCE_ACTION = 'octavawms_panel_login';
+ /** @see handleAjaxConnectivityProbe() Dedicated action so diagnostics does not reuse carrier-matrix AJAX. */
+ public const CONNECTIVITY_PROBE_ACTION = 'octavawms_connectivity_probe';
+
public function register(): void
{
- add_action('wp_ajax_' . self::ACTION, [$this, 'handleAjaxConnect']);
- add_action('wp_ajax_' . self::PANEL_LOGIN_ACTION, [$this, 'handleAjaxPanelLoginUrl']);
+ if (! has_action('wp_ajax_' . self::ACTION)) {
+ add_action('wp_ajax_' . self::ACTION, [$this, 'handleAjaxConnect']);
+ }
+ if (! has_action('wp_ajax_' . self::PANEL_LOGIN_ACTION)) {
+ add_action('wp_ajax_' . self::PANEL_LOGIN_ACTION, [$this, 'handleAjaxPanelLoginUrl']);
+ }
+ if (! has_action('wp_ajax_' . self::CONNECTIVITY_PROBE_ACTION)) {
+ add_action('wp_ajax_' . self::CONNECTIVITY_PROBE_ACTION, [$this, 'handleAjaxConnectivityProbe']);
+ }
// Priority 20: run after WooCommerce (priority 10) has registered selectWoo.
add_action('admin_enqueue_scripts', [$this, 'maybeEnqueueConnectScript'], 20);
}
@@ -71,23 +82,6 @@ public function maybeEnqueueConnectScript(string $hook): void
$script_url = plugins_url($script_rel, $plugin_root . '/octavawms-woocommerce.php');
$version = is_readable($script_path) ? (string) filemtime($script_path) : '1.0.0';
- wp_register_script('octavawms-admin-connect', $script_url, ['jquery'], $version, true);
-
- wp_localize_script('octavawms-admin-connect', 'octavawmsConnect', [
- 'ajaxUrl' => admin_url('admin-ajax.php'),
- 'nonce' => wp_create_nonce(self::ACTION),
- 'panelLoginNonce' => wp_create_nonce(self::PANEL_LOGIN_NONCE_ACTION),
- 'strings' => [
- 'connected' => __('Connected to OctavaWMS', 'octavawms'),
- 'notConnected' => __('Not connected', 'octavawms'),
- 'error' => __('Connect request failed. Check your site can reach the OctavaWMS service.', 'octavawms'),
- 'panelLogin' => __('Login to the panel', 'octavawms'),
- 'panelLoginError' => __('Could not open Octava panel. Try connecting again or check logs.', 'octavawms'),
- ],
- ]);
-
- wp_enqueue_script('octavawms-admin-connect');
-
$matrixRel = 'assets/js/admin-settings-matrix.js';
$matrixPath = $plugin_root . '/' . $matrixRel;
$matrixUrl = plugins_url($matrixRel, $plugin_root . '/octavawms-woocommerce.php');
@@ -110,6 +104,7 @@ public function maybeEnqueueConnectScript(string $hook): void
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce(SettingsAjax::ACTION),
'action' => SettingsAjax::ACTION,
+ 'detailAjaxErrors' => ( defined('WP_DEBUG') && WP_DEBUG && current_user_can('manage_options') ),
'strings' => [
'switchJson' => __('Switch to JSON', 'octavawms'),
'switchVisual' => __('Switch to Visual', 'octavawms'),
@@ -120,9 +115,41 @@ public function maybeEnqueueConnectScript(string $hook): void
'pickCarrier' => __('Search carrier…', 'octavawms'),
'pickRate' => __('Rate (optional)', 'octavawms'),
'anyRate' => __('— Any / none —', 'octavawms'),
+ 'ajaxNoHandler' => __( 'WordPress returned an empty handler response for this request. In the browser Network tab, confirm the POST body includes action=octavawms_carrier_matrix. If it is missing, a security rule or proxy may be stripping the request. Otherwise reload the page, confirm WooCommerce and OctavaWMS are active and up to date, and ensure admin cookies are sent to admin-ajax.php (same site, HTTPS).', 'octavawms' ),
+ 'ajaxNonceExpired' => __( 'Your session or security token expired. Reload the page and try again.', 'octavawms' ),
+ 'ajaxHttpError' => __( 'The server returned an error. Reload the page and try again.', 'octavawms' ),
+ 'ajaxParseError' => __( 'The server response could not be read as JSON. Reload the page or check for a plugin conflict.', 'octavawms' ),
+ 'ajaxInvalidResponse' => __( 'The server returned an unexpected response. Reload the page and try again.', 'octavawms' ),
+ 'ajaxMissingConfig' => __( 'Carrier matrix script configuration is missing (ajax URL, action, or nonce). Reload this page with cache bypass (hard refresh) or clear any page cache or minify plugin output for wp-admin.', 'octavawms' ),
+ ],
+ ]);
+
+ wp_register_script(
+ 'octavawms-admin-connect',
+ $script_url,
+ ['jquery', 'octavawms-admin-settings-matrix'],
+ $version,
+ true
+ );
+
+ wp_localize_script('octavawms-admin-connect', 'octavawmsConnect', [
+ 'ajaxUrl' => admin_url('admin-ajax.php'),
+ 'nonce' => wp_create_nonce(self::ACTION),
+ 'panelLoginNonce' => wp_create_nonce(self::PANEL_LOGIN_NONCE_ACTION),
+ 'connectivityProbeAction' => self::CONNECTIVITY_PROBE_ACTION,
+ 'connectivityProbeNonce' => wp_create_nonce(self::CONNECTIVITY_PROBE_ACTION),
+ 'strings' => [
+ 'connected' => __('Connected to OctavaWMS', 'octavawms'),
+ 'notConnected' => __('Not connected', 'octavawms'),
+ 'error' => __('Connect request failed. Check your site can reach the OctavaWMS service.', 'octavawms'),
+ 'panelLogin' => __('Login to the panel', 'octavawms'),
+ 'panelLoginError' => __('Could not open Octava panel. Try connecting again or check logs.', 'octavawms'),
+ 'connectivityRunning' => __('Running diagnostics…', 'octavawms'),
+ 'connectivityFailed' => __('Diagnostics request failed.', 'octavawms'),
],
]);
- wp_enqueue_script('octavawms-admin-settings-matrix');
+
+ wp_enqueue_script('octavawms-admin-connect');
}
public function handleAjaxConnect(): void
@@ -293,6 +320,32 @@ public function handleAjaxPanelLoginUrl(): void
wp_send_json_success(['loginUrl' => $loginUrl]);
}
+ public function handleAjaxConnectivityProbe(): void
+ {
+ if (! current_user_can('manage_woocommerce')) {
+ wp_send_json_error(['message' => __('You do not have permission.', 'octavawms')], 403);
+ }
+
+ check_ajax_referer(self::CONNECTIVITY_PROBE_ACTION, 'security');
+
+ if (! function_exists('curl_init')) {
+ wp_send_json_error(['message' => __('PHP curl extension is required for diagnostics.', 'octavawms')], 500);
+ }
+
+ $baseUrl = Options::getBaseUrl();
+ if ($baseUrl === '') {
+ $baseUrl = Options::DEFAULT_API_BASE;
+ }
+
+ $report = ConnectivityProbe::buildFullDiagnosticsReport(
+ $baseUrl,
+ ConnectivityProbe::DEFAULT_CONNECT_TIMEOUT,
+ ConnectivityProbe::DEFAULT_MAX_TIME
+ );
+
+ wp_send_json_success(['report' => $report]);
+ }
+
public const CONNECT_PATH = '/apps/woocommerce/connect';
private function getConnectUrlFromForm(): string
diff --git a/src/SettingsPage.php b/src/SettingsPage.php
index 27233ae..b9daa94 100644
--- a/src/SettingsPage.php
+++ b/src/SettingsPage.php
@@ -168,6 +168,7 @@ public function admin_options(): void
echo $this->getConnectDescriptionHtml();
parent::admin_options();
echo $this->getCarrierMatrixSectionHtml();
+ echo $this->getConnectivityDiagnosticsSectionHtml();
}
private function getCarrierMatrixSectionHtml(): string
@@ -228,4 +229,38 @@ private function getCarrierMatrixSectionHtml(): string
return (string) ob_get_clean();
}
+
+ private function getConnectivityDiagnosticsSectionHtml(): string
+ {
+ if (! current_user_can('manage_woocommerce')) {
+ return '';
+ }
+
+ ob_start();
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+