Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).**
Expand All @@ -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

Expand Down
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions assets/js/sites-list.js
Original file line number Diff line number Diff line change
@@ -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));
28 changes: 26 additions & 2 deletions includes/admin/class-iwp-admin-simple.php
Original file line number Diff line number Diff line change
Expand Up @@ -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($) {
Expand Down Expand Up @@ -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;
}
');
}
Expand Down
12 changes: 6 additions & 6 deletions includes/admin/class-iwp-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 31 additions & 23 deletions includes/admin/class-iwp-sites-list-table.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -908,9 +916,9 @@ public function column_password($item) {
if (empty($item['password'])) {
return '—';
}

return sprintf(
'<span class="iwp-password-hidden">••••••••</span> <button type="button" class="iwp-show-password button-link" data-password="%s">%s</button>',
'<span class="iwp-password-hidden">••••••••</span> <button type="button" class="iwp-show-password button-link" data-password="%s">%s</button><span class="iwp-password-revealed"></span>',
esc_attr($item['password']),
__('Show', 'iwp-wp-integration')
);
Expand Down
63 changes: 52 additions & 11 deletions includes/core/class-iwp-api-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading