diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ae906e..9e4d12e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,10 @@ All notable changes to the InstaWP Integration plugin will be documented in this
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [0.0.12] - 2026-05-07
+
+### Added
+- **Working Show/Hide toggle on the Sites list password column.** The masked password column now has a functional reveal button that toggles between hidden and visible inline values.
### Changed
- **Full compatibility with WooCommerce High-Performance Order Storage (HPOS).**
@@ -14,6 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The My Account "Your Sites" panel now lists every site the customer owns regardless of which data store WooCommerce is configured to use.
- The admin "Sites created" statistic reflects real data under HPOS instead of returning zero.
- Standard debug-log notices previously emitted by WooCommerce when the plugin issued order queries under HPOS are gone.
+- New `IWP_Woo_Product_Fields::field_label()` static — single source of truth for the customer-facing **Subdomain** label, ready for reuse in new code that mentions the field by name (cart/checkout review, error messages). Existing strings that embed the label inside a full sentence are left as-is so existing translations remain valid.
+
+### Fixed
+- **Orphan sites can now be deleted from the admin Sites list.** Sites that lived only in WooCommerce order meta — typically left over from older plugin versions before `wp_iwp_sites` existed — used to silently reappear after delete because the trash flow only updated the DB row, and there was no DB row to update. Delete now records a deletion marker in `wp_iwp_sites`, and the Sites list skips any order-meta entry whose `site_id` is already represented in the DB so the standard trashed-status filter hides it from the default view (and shows it under the **Trash** tab).
+- **Sites list page now sees orders created under HPOS.** The order-meta merge in the admin Sites list ran a raw SQL join against `wp_posts` + `wp_postmeta` filtered by `post_type='shop_order'`, which under authoritative HPOS misses every order (orders live in `wp_wc_orders`, meta in `wp_wc_orders_meta`). Replaced with a new `IWP_Woo_HPOS::get_orders_with_meta()` helper that queries the active store and UNIONs in any legacy `wp_postmeta` values that haven't been forward-migrated yet — single round trip in both modes. The orphan-delete fix above now works correctly on HPOS-authoritative stores.
+- **Friendlier "subdomain already taken" error message.** Previously surfaced to customers as `API request failed with status code 422: john-doe site name is not available.` It now reads `The subdomain "john-doe" is already taken. Please choose a different one.` on both the `[iwp_site_creator]` shortcode form and the WooCommerce checkout flow.
+- **API error messages no longer get prefixed with `API request failed with status code N: `.** The upstream API's human-readable message is used directly when present; the generic status-code text is kept only as a fallback when the response body has no message.
## [0.0.11] - 2026-04-21
diff --git a/CLAUDE.md b/CLAUDE.md
index c584079..bc922c5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -690,6 +690,28 @@ if (!empty($s_hash)) {
// "Magic Login" button replaces "Admin Login" when s_hash available
```
+### HPOS Compatibility (`class-iwp-woo-hpos.php`)
+
+`IWP_Woo_HPOS` is the abstraction layer for WooCommerce's High-Performance Order Storage. Always go through it for order/order-meta reads and writes — never query `wp_posts`/`wp_postmeta` directly when looking for `shop_order` rows or their meta. Under authoritative HPOS those tables don't carry the data, so direct queries silently return nothing.
+
+#### Picking the right read helper
+
+| Scenario | Use | Why |
+|----------|-----|-----|
+| You have an order ID and want one meta value | `IWP_Woo_HPOS::get_order_meta($order_or_id, $key)` | Reads from active store, falls back to legacy postmeta and forward-migrates on hit. |
+| You want orders matching first-class args (customer/status/date) and meta is incidental | `IWP_Woo_HPOS::get_orders($args_with_meta_query)` | Wraps `wc_get_orders()`. Strips `meta_query` and re-evaluates in PHP via `get_order_meta()` — handles legacy postmeta. **Slow on site-wide queries** (loads all matching orders into objects before filtering); the docstring at the top of the method warns to bound it with first-class args or a `limit`. |
+| You want every order that has any of N meta keys, and meta IS the primary selector | `IWP_Woo_HPOS::get_orders_with_meta(array $meta_keys)` | Single raw SQL query (with UNION on the legacy postmeta path under HPOS). Returns raw `(order_id, meta_key, meta_value)` rows; caller decides whether to `maybe_unserialize` and whether to hydrate orders. **Use this on admin list pages** and any site-wide scan. |
+
+#### Read-helper performance trade-off
+
+`get_orders()` is ergonomic but does PHP-side meta filtering — fine when the result set is bounded by other args (e.g. one customer's orders) but quadratic-ish when used on a whole-store scan. `get_orders_with_meta()` exists exactly for the whole-store-scan case: one indexed query, returns only matching rows, no per-non-match hydration cost.
+
+Concrete example: the admin Sites list at `wp-admin/admin.php?page=instawp-sites` has to enumerate every order with `_iwp_sites_created` or `_iwp_created_sites` meta. Using `get_orders(['limit' => -1, 'meta_query' => ...])` on a 100k-order store would hydrate all 100k orders into PHP and then filter — likely OOM at default admin memory limits. `get_orders_with_meta()` returns the matching ~50 rows in a single millisecond-scale query.
+
+#### Writes
+
+Use `IWP_Woo_HPOS::update_order_meta($order_or_id, $key, $value)` and `IWP_Woo_HPOS::delete_order_meta($order_or_id, $key)`. They route through the active store and run the standard WC order-update hooks. Direct `update_post_meta()`/`delete_post_meta()` writes are an anti-pattern — under HPOS they end up only in `wp_postmeta` and become invisible to the rest of the plugin (and require the `get_order_meta()` legacy-fallback path to repair on next read).
+
## Admin Interface
### Simplified Admin System
diff --git a/README.md b/README.md
index 6ad2ffa..000aa73 100644
--- a/README.md
+++ b/README.md
@@ -356,6 +356,23 @@ This plugin is licensed under the GPL v2 or later.
## Changelog
+### Version 0.0.12
+- **NEW: Show/Hide toggle works on the Sites list Password column** — masked password reveals inline when clicked
+- **FIXED: Orphan sites (in order meta only) can now be deleted from the admin Sites list** — previously they silently reappeared after delete; now hidden via the standard Trash filter and visible under the **Trash** tab
+- **FIXED: Sites list page now sees orders created under HPOS** — the order-meta merge previously ran raw SQL against `wp_posts`/`wp_postmeta`, missing every order under authoritative HPOS; replaced with a new HPOS-aware single-query helper that also UNIONs in legacy postmeta values for full coverage on stores mid-migration
+- **FIXED: Friendlier "subdomain already taken" error** — replaces the raw `API request failed with status code 422: ... site name is not available.` text on both the shortcode form and WooCommerce checkout
+- **FIXED: API error messages no longer prefixed with `API request failed with status code N:`** — upstream message surfaces directly when present
+- **CHANGED: Full compatibility with WooCommerce High-Performance Order Storage (HPOS)** — site records now store and read correctly across all flows under HPOS; older orders are migrated forward on first read; My Account "Your Sites" panel and admin "Sites created" statistic both work regardless of data store
+
+### Version 0.0.11
+- **NEW: Trash filter on the Sites list page** — deleting a site now moves it to a new **Trash** status instead of removing the row
+- **NEW: Trash view** alongside All, Active, Creating, Failed, Expired; on Trash the URL is shown as plain text and row actions (Visit Site, Magic Login, Delete, Send Credentials, Open in InstaWP) are hidden
+- **NEW: "Show Site Credentials on Dashboard" option** (off by default) — when enabled, each site card on My Account shows the WordPress admin username and password
+- **CHANGED: Default "All" view now excludes trashed sites** (standard WordPress convention); deleting a site no longer removes the local record — moved to **Trash** instead. Remote API delete still runs as before.
+- **CHANGED: Trashed sites no longer appear in customer-facing views** (My Account dashboard, order detail page, order emails); the order detail "Sites" section is hidden entirely when every site for that order has been trashed
+- **CHANGED: Snapshots cache duration increased from 15 minutes to 4 hours** — still overridable via **Refresh Snapshots** in Settings → InstaWP Data
+- **FIXED: Trashed sites no longer get resurrected after a new order** — the pending-sites poller now skips trashed rows and treats a remote "site not found" response as proof the site is deleted
+
### Version 0.0.10
- **NEW: Post-Purchase Onboarding** — defer site creation to after purchase, collect credentials via `[iwp_onboarding]` shortcode
- **NEW: My Account deferred setup** — pending sites show on order view and dashboard banner
diff --git a/assets/js/sites-list.js b/assets/js/sites-list.js
new file mode 100644
index 0000000..604b6cd
--- /dev/null
+++ b/assets/js/sites-list.js
@@ -0,0 +1,30 @@
+/**
+ * Sites list page UI for the InstaWP Integration admin.
+ *
+ * Currently wires the Show/Hide toggle for the Password column rendered by
+ * IWP_Sites_List_Table::column_password().
+ *
+ * @package IWP
+ */
+
+(function ($) {
+ 'use strict';
+
+ var labels = (window.iwpSitesList && window.iwpSitesList.labels) || {
+ show: 'Show',
+ hide: 'Hide'
+ };
+
+ $(document).on('click', '.iwp-show-password', function (e) {
+ e.preventDefault();
+ var $btn = $(this);
+ var $revealed = $btn.next('.iwp-password-revealed');
+ if ($btn.text() === labels.show) {
+ $revealed.text($btn.data('password'));
+ $btn.text(labels.hide);
+ } else {
+ $revealed.text('');
+ $btn.text(labels.show);
+ }
+ });
+}(jQuery));
diff --git a/includes/admin/class-iwp-admin-simple.php b/includes/admin/class-iwp-admin-simple.php
index 7ebfa1a..c4ed2d3 100644
--- a/includes/admin/class-iwp-admin-simple.php
+++ b/includes/admin/class-iwp-admin-simple.php
@@ -597,6 +597,21 @@ public function enqueue_admin_scripts($hook) {
wp_enqueue_script('jquery');
+ // Sites list page UI (password show/hide toggle, etc.).
+ wp_enqueue_script(
+ 'iwp-sites-list',
+ IWP_PLUGIN_URL . 'assets/js/sites-list.js',
+ array('jquery'),
+ IWP_VERSION,
+ true
+ );
+ wp_localize_script('iwp-sites-list', 'iwpSitesList', array(
+ 'labels' => array(
+ 'show' => __('Show', 'iwp-wp-integration'),
+ 'hide' => __('Hide', 'iwp-wp-integration'),
+ ),
+ ));
+
// Send Credentials button handler
wp_add_inline_script('jquery', '
jQuery(document).ready(function($) {
@@ -651,8 +666,17 @@ public function enqueue_admin_scripts($hook) {
border-top: none;
padding: 20px;
}
- .iwp-tab-content.active {
- display: block;
+ .iwp-tab-content.active {
+ display: block;
+ }
+ .iwp-password-revealed {
+ display: block;
+ font-family: monospace;
+ word-break: break-all;
+ margin-top: 2px;
+ }
+ .iwp-password-revealed:empty {
+ display: none;
}
');
}
diff --git a/includes/admin/class-iwp-admin.php b/includes/admin/class-iwp-admin.php
index bb6925c..4bc9f7d 100644
--- a/includes/admin/class-iwp-admin.php
+++ b/includes/admin/class-iwp-admin.php
@@ -2270,12 +2270,12 @@ public function ajax_delete_site() {
));
}
- // Remote API delete already succeeded above. Soft-delete the local
- // row so it stays visible under the Trash filter.
- if (IWP_Sites_Model::get_by_site_id($site_id)) {
- IWP_Sites_Model::trash($site_id);
- IWP_Logger::info('Marked site as trashed in database table', 'admin', array('site_id' => $site_id));
- }
+ // Remote API delete already succeeded above. Trash the local row
+ // (or insert a deletion marker for orphans that live only in order
+ // meta) so the site is hidden from the default list and visible
+ // under the Trash filter.
+ IWP_Sites_Model::trash($site_id);
+ IWP_Logger::info('Marked site as trashed in database table', 'admin', array('site_id' => $site_id));
IWP_Logger::info('Site deleted successfully', 'admin', array(
'site_id' => $site_id,
diff --git a/includes/admin/class-iwp-sites-list-table.php b/includes/admin/class-iwp-sites-list-table.php
index be2c7b9..07432da 100644
--- a/includes/admin/class-iwp-sites-list-table.php
+++ b/includes/admin/class-iwp-sites-list-table.php
@@ -385,8 +385,11 @@ private function get_all_sites() {
$db_sites = $this->get_sites_from_database();
$all_sites = array_merge($all_sites, $db_sites);
- // Get sites from WooCommerce orders (legacy/backup)
- $order_sites = $this->get_sites_from_orders();
+ // Get sites from WooCommerce orders (legacy/backup). Pass $db_sites so
+ // any order-meta entry whose site_id is already represented in the DB
+ // (including trashed rows / deletion markers) is skipped at the source
+ // instead of relying on downstream dedupe.
+ $order_sites = $this->get_sites_from_orders($db_sites);
$all_sites = array_merge($all_sites, $order_sites);
// Get sites from shortcode usage (stored in options)
@@ -516,40 +519,45 @@ private function get_sites_from_database() {
*
* @return array
*/
- private function get_sites_from_orders() {
- global $wpdb;
-
+ private function get_sites_from_orders($db_sites = array()) {
$sites = array();
- // Query all orders with InstaWP sites
- $results = $wpdb->get_results("
- SELECT p.ID as order_id, pm.meta_value as sites_data
- FROM {$wpdb->posts} p
- JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
- WHERE p.post_type = 'shop_order'
- AND pm.meta_key IN ('_iwp_sites_created', '_iwp_created_sites')
- AND pm.meta_value IS NOT NULL
- AND pm.meta_value != ''
- ");
+ // Site_ids already represented by the DB-sourced list. Order-meta
+ // entries matching one of these are skipped: the DB row is the source
+ // of truth (including trashed rows / orphan deletion markers, which
+ // the downstream status filter then hides).
+ $db_site_ids = empty($db_sites) ? array(): array_column($db_sites, 'site_id');
+ $has_site_ids = count($db_site_ids) > 0;
+
+ // One HPOS-aware round trip — returns only orders that actually have
+ // one of the IWP meta keys (under either data store, with legacy
+ // postmeta fallback under HPOS). See
+ // IWP_Woo_HPOS::get_orders_with_meta() for the data-store branching.
+ $results = IWP_Woo_HPOS::get_orders_with_meta(array(
+ '_iwp_sites_created',
+ '_iwp_created_sites',
+ ));
foreach ($results as $result) {
- $order_id = $result->order_id;
- $sites_data = maybe_unserialize($result->sites_data);
-
+ $sites_data = maybe_unserialize($result->meta_value);
if (!is_array($sites_data)) {
continue;
}
- $order = wc_get_order($order_id);
+ $order = wc_get_order($result->order_id);
if (!$order) {
continue;
}
foreach ($sites_data as $site_data) {
$site = $this->format_order_site($site_data, $order);
- if ($site) {
- $sites[] = $site;
+ if (!$site) {
+ continue;
}
+ if ( $has_site_ids && !empty($site['site_id']) && in_array($site['site_id'], $db_site_ids, true) ) {
+ continue;
+ }
+ $sites[] = $site;
}
}
@@ -908,9 +916,9 @@ public function column_password($item) {
if (empty($item['password'])) {
return '—';
}
-
+
return sprintf(
- '•••••••• ',
+ '•••••••• ',
esc_attr($item['password']),
__('Show', 'iwp-wp-integration')
);
diff --git a/includes/core/class-iwp-api-client.php b/includes/core/class-iwp-api-client.php
index 0030b50..850a5e3 100644
--- a/includes/core/class-iwp-api-client.php
+++ b/includes/core/class-iwp-api-client.php
@@ -53,6 +53,35 @@ public function __construct() {
$this->team_id = isset($options['selected_team_id']) ? intval($options['selected_team_id']) : null;
}
+ /**
+ * Convert a raw API error message into customer-friendly text.
+ *
+ * Single source of truth for InstaWP API error rewrites. Currently
+ * handles the upstream "X site name is not available." response (the
+ * subdomain-already-taken case) and pass-through everything else
+ * verbatim. Add new rewrite rules here so every customer-facing
+ * surface (shortcode JSON responses, WooCommerce checkout WP_Errors,
+ * deferred-onboarding AJAX, subscription-switch order notes) gets
+ * the same humanized text.
+ *
+ * @param WP_Error|string $error WP_Error from this client, or a raw
+ * error message string.
+ * @return string Customer-friendly version of the message.
+ */
+ public static function humanize_error($error) {
+ $message = is_wp_error($error) ? $error->get_error_message() : (string) $error;
+
+ if (preg_match('/^(.+?) site name is not available\.?$/i', $message, $m)) {
+ return sprintf(
+ /* translators: %s is the site name the customer tried to use. */
+ __('The site name "%s" is already taken. Please choose a different one.', 'iwp-wp-integration'),
+ $m[1]
+ );
+ }
+
+ return $message;
+ }
+
/**
* Set API key
*
@@ -149,19 +178,31 @@ private function make_request($endpoint, $args = array()) {
IWP_Logger::debug('API response body received', 'api-client');
if ($response_code < 200 || $response_code >= 300) {
- $error_message = sprintf(
- __('API request failed with status code %d', 'iwp-woo-v2'),
- $response_code
- );
-
- // Try to get error message from response body
+ // Prefer the upstream body message — it's already a human-readable
+ // explanation (e.g. "john-doe site name is not available."). Fall
+ // back to a generic "status code N" only when no message exists.
$body_data = json_decode($response_body, true);
- if (is_array($body_data) && isset($body_data['message'])) {
- $error_message .= ': ' . sanitize_text_field($body_data['message']);
+ if (is_array($body_data) && !empty($body_data['message'])) {
+ $error_message = sanitize_text_field($body_data['message']);
+ } else {
+ $error_message = sprintf(
+ __('API request failed with status code %d', 'iwp-woo-v2'),
+ $response_code
+ );
}
-
- IWP_Logger::error('API request failed', 'api-client', array('error' => $error_message));
- return new WP_Error('api_request_failed', $error_message);
+
+ // Log the RAW upstream message so support / debug context
+ // still captures exactly what the API said.
+ IWP_Logger::error('API request failed', 'api-client', array(
+ 'status_code' => $response_code,
+ 'error' => $error_message,
+ ));
+
+ // Return a HUMANIZED WP_Error so every downstream caller —
+ // shortcode AJAX, WooCommerce checkout, deferred onboarding,
+ // subscription switch — surfaces customer-friendly text by
+ // default, without each having to know the rewrite rules.
+ return new WP_Error('api_request_failed', self::humanize_error($error_message));
}
$data = json_decode($response_body, true);
diff --git a/includes/core/class-iwp-shortcode.php b/includes/core/class-iwp-shortcode.php
index c250ea9..241edc6 100644
--- a/includes/core/class-iwp-shortcode.php
+++ b/includes/core/class-iwp-shortcode.php
@@ -204,7 +204,7 @@ public function handle_ajax_site_creation() {
$result = $api_client->create_site_from_snapshot($snapshot_slug, $site_data);
if (is_wp_error($result)) {
- wp_send_json_error(array('message' => $result->get_error_message()));
+ wp_send_json_error(array('message' => IWP_API_Client::humanize_error($result)));
}
// Check if site was created successfully
diff --git a/includes/core/class-iwp-sites-model.php b/includes/core/class-iwp-sites-model.php
index 4e8f0d2..d1ca426 100644
--- a/includes/core/class-iwp-sites-model.php
+++ b/includes/core/class-iwp-sites-model.php
@@ -183,7 +183,157 @@ public static function trash($site_id, $api_client = null) {
$api_client->delete_site($site_id);
}
- return self::update($site_id, array('status' => self::STATUS_TRASHED));
+ if (self::get_by_site_id($site_id)) {
+ return self::update($site_id, array('status' => self::STATUS_TRASHED));
+ }
+
+ // Orphan: site lives only in order meta. Record a deletion marker
+ // populated with whatever metadata we can recover from the order
+ // entry (URL, credentials, order, customer, dates) so the Trash
+ // view at admin.php?page=instawp-sites&status=trashed shows
+ // meaningful details instead of an empty row with just the site_id.
+ // Tombstone-specific fields (site_id, status, source) overwrite
+ // anything the recovery returned for those keys.
+ $tombstone = array_merge(
+ self::collect_orphan_metadata($site_id),
+ array(
+ 'site_id' => $site_id,
+ 'status' => self::STATUS_TRASHED,
+ 'source' => 'order_meta',
+ )
+ );
+
+ return (bool) self::create($tombstone);
+ }
+
+ /**
+ * Pull whatever metadata exists in WooCommerce order meta for $site_id
+ * and map it onto wp_iwp_sites columns. Used by trash() to give orphan-
+ * tombstone rows real context when no DB row existed at delete time —
+ * without this, the Trash filter view shows empty cells for every
+ * orphan because only site_id/status/source were populated.
+ *
+ * Same data path as IWP_Sites_List_Table::get_sites_from_orders():
+ * IWP_Woo_HPOS::get_orders_with_meta() returns one indexed row per
+ * matching order under either data store, with legacy postmeta
+ * fallback. Entry-shape detection mirrors format_order_site() — both
+ * the new wrapped form (['site_data' => [...]]) and the legacy flat
+ * form are handled.
+ *
+ * Always returns at least {site_url, wp_admin_url} pointing at the
+ * InstaWP dashboard for $site_id, so the Trash row is always
+ * actionable even when the lookup matched nothing in order meta.
+ *
+ * @param string $site_id InstaWP site identifier.
+ * @return array Subset of wp_iwp_sites columns.
+ */
+ private static function collect_orphan_metadata($site_id) {
+ $extracted = array();
+ $matched = false;
+
+ if (class_exists('IWP_Woo_HPOS')) {
+ $rows = IWP_Woo_HPOS::get_orders_with_meta(array(
+ '_iwp_sites_created',
+ '_iwp_created_sites',
+ ));
+
+ foreach ($rows as $row) {
+ $sites_data = maybe_unserialize($row->meta_value);
+ if (!is_array($sites_data)) {
+ continue;
+ }
+
+ foreach ($sites_data as $entry) {
+ $inner = (isset($entry['site_data']) && is_array($entry['site_data']))
+ ? $entry['site_data']
+ : $entry;
+
+ $entry_site_id = isset($inner['site_id']) ? $inner['site_id']
+ : (isset($inner['id']) ? $inner['id'] : '');
+ if ((string) $entry_site_id !== (string) $site_id) {
+ continue;
+ }
+
+ $matched = true;
+
+ $extracted['order_id'] = (int) $row->order_id;
+ if (!empty($entry['product_id'])) {
+ $extracted['product_id'] = (int) $entry['product_id'];
+ }
+
+ // Source field name → wp_iwp_sites column. First non-
+ // empty value wins; alternative source-field names
+ // accommodate older entry shapes.
+ $field_map = array(
+ 'site_url' => array('wp_url', 'site_url'),
+ 'wp_username' => array('wp_username'),
+ 'wp_password' => array('wp_password'),
+ 'wp_admin_url' => array('wp_admin_url'),
+ 's_hash' => array('s_hash'),
+ 'plan_id' => array('plan_id'),
+ 'snapshot_slug' => array('snapshot_slug'),
+ 'task_id' => array('task_id'),
+ 'created_at' => array('created_at'),
+ );
+ foreach ($field_map as $col => $candidates) {
+ foreach ($candidates as $key) {
+ if (!empty($inner[$key])) {
+ $extracted[$col] = $inner[$key];
+ break;
+ }
+ }
+ }
+
+ // Pull customer + order date from the WC_Order itself.
+ if (function_exists('wc_get_order')) {
+ $order = wc_get_order((int) $row->order_id);
+ if ($order) {
+ $extracted['user_id'] = (int) $order->get_customer_id();
+ if (empty($extracted['created_at'])) {
+ $created = $order->get_date_created();
+ if ($created) {
+ $extracted['created_at'] = $created->format('Y-m-d H:i:s');
+ }
+ }
+ }
+ }
+
+ // Forensic snapshot — once order meta is cleaned up, the
+ // tombstone is the only place this entry survives.
+ // create() JSON-encodes arrays, so pass an array, not a
+ // pre-encoded string.
+ $extracted['source_data'] = array(
+ 'order_id' => (int) $row->order_id,
+ 'meta_key' => $row->meta_key,
+ 'entry' => $entry,
+ 'trashed_at' => current_time('mysql'),
+ );
+
+ break 2;
+ }
+ }
+ }
+
+ // Derive wp_admin_url from site_url when the entry didn't carry
+ // one (older order-meta payloads usually don't).
+ if ($matched && empty($extracted['wp_admin_url']) && !empty($extracted['site_url']) && function_exists('trailingslashit')) {
+ $extracted['wp_admin_url'] = trailingslashit($extracted['site_url']) . 'wp-admin';
+ }
+
+ // Last-resort fallback when order meta yielded nothing: at least
+ // give admins a clickable InstaWP dashboard link for $site_id so
+ // the Trash row is never an empty cell. Both site_url and
+ // wp_admin_url get the same dashboard URL — the actual WP site
+ // URL isn't recoverable without the order-meta entry, but the
+ // dashboard is still actionable (lets the admin investigate /
+ // restore on the InstaWP side).
+ if (!$matched && defined('IWP_PLUGIN_APP_URL')) {
+ $dashboard = IWP_PLUGIN_APP_URL . '/sites/' . rawurlencode($site_id);
+ $extracted['site_url'] = $dashboard;
+ $extracted['wp_admin_url'] = $dashboard;
+ }
+
+ return $extracted;
}
/**
diff --git a/includes/frontend/class-iwp-onboarding.php b/includes/frontend/class-iwp-onboarding.php
index 98d4ff6..2b632e1 100644
--- a/includes/frontend/class-iwp-onboarding.php
+++ b/includes/frontend/class-iwp-onboarding.php
@@ -320,7 +320,7 @@ public function handle_create_site() {
'order_id' => $order_id,
'error' => $result->get_error_message(),
));
- wp_send_json_error(array('message' => $result->get_error_message()));
+ wp_send_json_error(array('message' => IWP_API_Client::humanize_error($result)));
}
// Store result in _iwp_sites_created — HPOS-safe read/write.
diff --git a/includes/integrations/woocommerce/class-iwp-woo-hpos.php b/includes/integrations/woocommerce/class-iwp-woo-hpos.php
index a67b33b..39d3730 100644
--- a/includes/integrations/woocommerce/class-iwp-woo-hpos.php
+++ b/includes/integrations/woocommerce/class-iwp-woo-hpos.php
@@ -260,6 +260,101 @@ public static function get_orders($args = array()) {
return $filtered;
}
+ /**
+ * Fetch (order_id, meta_key, meta_value) rows for any order that has at
+ * least one of the supplied meta keys, regardless of which order data
+ * store WooCommerce is configured to use. Single round trip in both modes.
+ *
+ * Returns raw stdClass rows so callers can keep their own
+ * maybe_unserialize / format / dedupe loop. This is the high-throughput
+ * sibling of get_orders() — use it on call sites where the matching set
+ * is far smaller than total orders (e.g. admin list pages on stores with
+ * 10k+ orders) and the caller doesn't need every order hydrated as a
+ * WC_Order. get_orders() loads the entire result set into objects before
+ * filtering in PHP; that's appropriate when the meta_query is incidental,
+ * but the wrong tool when the meta is the primary selector.
+ *
+ * Under CPT-authoritative: SELECT against wp_posts + wp_postmeta.
+ * Under HPOS-authoritative: SELECT against wc_orders + wc_orders_meta,
+ * UNION-ed with wp_posts + wp_postmeta entries that have no
+ * corresponding wc_orders_meta row. The UNION half catches legacy
+ * values written by pre-HPOS-fix plugin builds via update_post_meta()
+ * that haven't been forward-migrated yet, and the NOT EXISTS clause
+ * de-dupes when sync mode mirrors the same value into both stores.
+ *
+ * @param string[] $meta_keys Meta keys to match (IN list). Caller-
+ * provided literals only — values are bound
+ * via prepare(). Empty array short-circuits.
+ * @return stdClass[] Each row has order_id (int), meta_key (string),
+ * meta_value (string — still serialized; caller
+ * decides whether to maybe_unserialize).
+ */
+ public static function get_orders_with_meta(array $meta_keys) {
+ global $wpdb;
+
+ $meta_keys = array_values(array_unique(array_filter($meta_keys, 'is_string')));
+ if (empty($meta_keys)) {
+ return array();
+ }
+
+ $placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
+
+ if (!self::is_hpos_enabled()) {
+ // CPT path — same shape as the historic raw SQL.
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $sql = $wpdb->prepare(
+ "SELECT p.ID AS order_id, pm.meta_key AS meta_key, pm.meta_value AS meta_value
+ FROM {$wpdb->posts} p
+ INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
+ WHERE p.post_type = 'shop_order'
+ AND pm.meta_key IN ($placeholders)
+ AND pm.meta_value IS NOT NULL
+ AND pm.meta_value <> ''",
+ $meta_keys
+ );
+ return (array) $wpdb->get_results($sql);
+ }
+
+ // HPOS path — query the active store, then UNION with wp_postmeta
+ // rows the active store doesn't have. Table names come from
+ // OrderUtil::get_table_for_* (same primitive class-iwp-database.php
+ // already uses), so they're trusted; meta keys are bound via
+ // prepare() (twice — once per IN clause in the UNION).
+ $orders_table = \Automattic\WooCommerce\Utilities\OrderUtil::get_table_for_orders();
+ $meta_table = \Automattic\WooCommerce\Utilities\OrderUtil::get_table_for_order_meta();
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $sql = $wpdb->prepare(
+ "SELECT o.id AS order_id, m.meta_key AS meta_key, m.meta_value AS meta_value
+ FROM {$orders_table} o
+ INNER JOIN {$meta_table} m ON o.id = m.order_id
+ WHERE o.type = 'shop_order'
+ AND m.meta_key IN ($placeholders)
+ AND m.meta_value IS NOT NULL
+ AND m.meta_value <> ''
+
+ UNION ALL
+
+ SELECT p.ID AS order_id, pm.meta_key AS meta_key, pm.meta_value AS meta_value
+ FROM {$wpdb->posts} p
+ INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
+ WHERE p.post_type = 'shop_order'
+ AND pm.meta_key IN ($placeholders)
+ AND pm.meta_value IS NOT NULL
+ AND pm.meta_value <> ''
+ AND NOT EXISTS (
+ SELECT 1 FROM {$meta_table} m2
+ WHERE m2.order_id = p.ID
+ AND m2.meta_key = pm.meta_key
+ AND m2.meta_value IS NOT NULL
+ AND m2.meta_value <> ''
+ )",
+ array_merge($meta_keys, $meta_keys)
+ );
+
+ return (array) $wpdb->get_results($sql);
+ }
+
/**
* Get order count (HPOS compatible)
*
diff --git a/includes/integrations/woocommerce/class-iwp-woo-order-processor.php b/includes/integrations/woocommerce/class-iwp-woo-order-processor.php
index bc7608a..a714fde 100644
--- a/includes/integrations/woocommerce/class-iwp-woo-order-processor.php
+++ b/includes/integrations/woocommerce/class-iwp-woo-order-processor.php
@@ -396,6 +396,10 @@ private function create_site_for_product($order, $product, $snapshot_slug, $item
);
if (is_wp_error($result)) {
+ $humanized = IWP_API_Client::humanize_error($result);
+ if ($humanized !== $result->get_error_message()) {
+ $result = new WP_Error($result->get_error_code(), $humanized);
+ }
return $result;
}
diff --git a/includes/integrations/woocommerce/class-iwp-woo-product-fields.php b/includes/integrations/woocommerce/class-iwp-woo-product-fields.php
index 1729d96..2ae983c 100644
--- a/includes/integrations/woocommerce/class-iwp-woo-product-fields.php
+++ b/includes/integrations/woocommerce/class-iwp-woo-product-fields.php
@@ -19,6 +19,19 @@
*/
class IWP_Woo_Product_Fields {
+ /**
+ * Customer-facing label for the WooCommerce site-name field. Single
+ * source of truth — reuse anywhere new code mentions the field by name
+ * (cart/checkout review, error messages) so the term stays consistent.
+ * Pre-existing strings that embed the label inside a full sentence are
+ * left as-is to avoid invalidating existing translations.
+ *
+ * @return string
+ */
+ public static function field_label() {
+ return __('Subdomain', 'iwp-wp-integration');
+ }
+
/**
* Constructor
*/
@@ -206,7 +219,7 @@ public function display_cart_item_data($item_data, $cart_item) {
if (!empty($cart_item['iwp_subdomain'])) {
$item_data[] = array(
- 'key' => __('Subdomain', 'iwp-wp-integration'),
+ 'key' => self::field_label(),
'value' => esc_html($cart_item['iwp_subdomain']),
);
}
diff --git a/iwp-wp-integration.php b/iwp-wp-integration.php
index 7d37708..904f605 100644
--- a/iwp-wp-integration.php
+++ b/iwp-wp-integration.php
@@ -3,7 +3,7 @@
* Plugin Name: InstaWP Integration
* Plugin URI: https://instawp.com
* Description: A comprehensive WordPress integration plugin for InstaWP that provides enhanced functionality, seamless integration, WooCommerce support, and standalone site creation tools.
- * Version: 0.0.11
+ * Version: 0.0.12
* Author: InstaWP
* Author URI: https://instawp.com
* Text Domain: iwp-wp-integration
@@ -24,7 +24,7 @@
}
// Define plugin constants
-define('IWP_VERSION', '0.0.11');
+define('IWP_VERSION', '0.0.12');
define('IWP_PLUGIN_FILE', __FILE__);
define('IWP_PLUGIN_PATH', plugin_dir_path(__FILE__));
define('IWP_PLUGIN_URL', plugin_dir_url(__FILE__));