Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e14b74b
feat(hpos): full WooCommerce High-Performance Order Storage compatibi…
Apr 23, 2026
b8e1d2d
refactor(hpos): accept WC_Abstract_Order or ID, strict arg resolver
Apr 23, 2026
5bfbf67
fix(hpos): route IWP_Database::append_order_meta/update_order_meta th…
Apr 23, 2026
02f0f85
chore: make IWP_PLUGIN_APP_URL overridable via wp-config.php
Apr 23, 2026
c02a0e3
Merge pull request #7 from InstaWP/feat/86d2qpz4e-woo-high-performace…
randhirinsta Apr 23, 2026
e3a4762
fix(sites-list): orphan delete + HPOS-aware order-meta query
May 6, 2026
ab1472e
fix(sites-list): short-circuit dedupe check when no DB sites
May 6, 2026
39dd8f5
fix(sites-model): populate orphan tombstone with order-meta details
May 6, 2026
add814f
release: bump version to 0.0.12
May 6, 2026
ab29aee
refactor(api-client): centralize humanize_error + add to onboarding flow
May 6, 2026
d3edd70
fix(api-client): humanize error at the source in make_request
May 6, 2026
de8c4f8
Merge pull request #8 from InstaWP/fix/86d2wg96a-site-delete-list
randhirinsta May 6, 2026
703d3f3
fix(frontend): show real error on failed site cards + emails
May 7, 2026
7b5ee45
fix(sites-list): hide useless rows + show real failure cause
May 7, 2026
1ebfd7d
fix(sites-model): pass explicit $wpdb format to override WP's site_id %d
May 7, 2026
e0ae4ed
refactor(sites-render): pass api_response through, decode at render
May 7, 2026
7c9bbf8
docs(changelog): rewrite 0.0.12 entries in plain end-user language
May 7, 2026
c36cd30
polish: error wording, render fallbacks, status tooltip
May 7, 2026
7edb963
Merge pull request #10 from InstaWP/fix/86d2wg96a-site-delete-list
randhirinsta May 7, 2026
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ 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).

## [0.0.12] - 2026-05-08

### Added
- **Show/Hide password toggle on the Sites list.** Click to reveal a site's WordPress password inline; click again to hide it.

### Changed
- **Full WooCommerce HPOS (High-Performance Order Storage) support.** Sites created from orders now show up correctly everywhere — the customer's My Account dashboard, the order details page, and the admin "Sites created" statistic — regardless of which order storage WooCommerce is configured to use. Orders from older plugin versions are upgraded automatically on first read.

### Fixed
- **Clearer "subdomain already taken" error.** Customers attempting to use a subdomain that's already in use now see *"The subdomain "X" is already taken. Please choose a different one."* — on the product checkout, the thank-you page, the deferred-onboarding form, and the order-confirmation email. Replaces the previous raw technical error text.
- **Failed site creations now show the real reason to the customer.** On the thank-you page, order details, and order-confirmation email, the customer now sees the actual cause of the failure (e.g. subdomain taken) instead of a generic "Sorry, please contact support" message.
- **Admin Sites list shows the failure reason at a glance.** Failed sites now display the real cause directly under the "Failed" status badge — no need to click into the order to find out what went wrong.
- **No more phantom rows in the admin Sites list.** Failed or trashed sites that have no usable URL are hidden from every tab, since they carry no actionable information.
- **Old orphaned sites can now be deleted.** Sites left over in order meta from older plugin versions (before the dedicated sites table existed) used to silently reappear after deletion. They can now be deleted properly and stay deleted.
- **Every failed order now keeps its own record.** Previously, due to a database quirk, only the first failed site creation per cycle was recorded — every subsequent failed order silently lost its row, and the failed card didn't render on the customer's view. Each failed order now keeps its own record and renders correctly.

## [0.0.11] - 2026-04-21

### Added
Expand Down
35 changes: 35 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ iwp-wp-integration/

Handles all communication with InstaWP's REST API.

#### `IWP_PLUGIN_APP_URL` — no trailing slash

Defined in `iwp-wp-integration.php`. This constant is the base host for every
URL the plugin builds — API calls, magic-login URLs, dashboard links, values
passed to localized JS.

**Rule:** store as scheme + host only, no path and no trailing slash
(`'https://app.instawp.io'`, never `'https://app.instawp.io/'`). All
consumer code owns the leading slash on its side (`. '/api/v2/...'`,
`. '/wordpress-auto-login?...'`). Concatenating a trailing-slash base with a
leading-slash path produces doubled slashes in the resulting URL, which some
reverse-proxy / router stacks no longer silently normalize.

#### Key Features
- **Authentication**: Bearer token with API key
- **Endpoints**: Complete API coverage (v1 and v2)
Expand Down Expand Up @@ -677,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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,25 @@ This plugin is licensed under the GPL v2 or later.

## Changelog

### Version 0.0.12
- **NEW**: Show/Hide password toggle on the Sites list — click to reveal a site's WordPress password inline
- **CHANGED**: Full WooCommerce HPOS support — sites created from orders show up correctly everywhere (My Account dashboard, order details, admin "Sites created" stat); older orders are upgraded automatically on first read
- **FIXED**: Clearer "subdomain already taken" error — customers see *"The subdomain X is already taken. Please choose a different one."* on checkout, thank-you page, deferred-onboarding form, and order email
- **FIXED**: Failed site creations now show the real reason to the customer on the thank-you page, order details, and order email — not just a generic "contact support" message
- **FIXED**: Admin Sites list shows the failure reason under the "Failed" badge, so admins can see at a glance why each one failed
- **FIXED**: No more phantom rows in the admin Sites list — failed/trashed sites with no URL are hidden from every tab
- **FIXED**: Old orphaned sites (left over from older plugin versions) can now be deleted from the admin Sites list — previously they would reappear after deletion
- **FIXED**: Every failed order now keeps its own record — previously a database quirk caused only the first failed site per cycle to be recorded

### 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));
67 changes: 50 additions & 17 deletions includes/admin/class-iwp-admin-simple.php
Original file line number Diff line number Diff line change
Expand Up @@ -259,30 +259,35 @@ private function get_sites_from_orders() {
if (!function_exists('wc_get_orders')) {
return array();
}

$orders = wc_get_orders(array(
'limit' => 50,
'status' => array('completed', 'processing'),

// Route through the HPOS/CPT compat wrapper so meta_query is evaluated
// correctly on both data stores and orders whose meta still lives in
// wp_postmeta (pre-HPOS-fix plugin builds) are included.
$orders = IWP_Woo_HPOS::get_orders(array(
'limit' => 50,
'status' => array('completed', 'processing'),
'meta_query' => array(
array(
'key' => '_iwp_created_sites',
'compare' => 'EXISTS'
)
)
'key' => '_iwp_created_sites',
'compare' => 'EXISTS',
),
),
));

$sites = array();
foreach ($orders as $order) {
$order_sites = $order->get_meta('_iwp_created_sites');
// HPOS-safe read — handles wp_postmeta fallback + forward-migrate
// for values written by older plugin builds.
$order_sites = IWP_Woo_HPOS::get_order_meta($order, '_iwp_created_sites');
if (is_array($order_sites)) {
foreach ($order_sites as $site) {
$site['order_id'] = $order->get_id();
$site['order_id'] = $order->get_id();
$site['order_date'] = $order->get_date_created()->format('Y-m-d H:i:s');
$sites[] = $site;
$sites[] = $site;
}
}
}

return $sites;
}

Expand Down Expand Up @@ -592,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 @@ -646,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 Expand Up @@ -986,7 +1015,11 @@ private function update_site_url_with_mapped_domain($order_id, $site_id, $domain
}

// Update created sites meta
$created_sites = $order->get_meta('_iwp_created_sites');
// Read via the helper so pre-HPOS-fix orders whose _iwp_created_sites
// value still lives in wp_postmeta are picked up (and migrated
// forward). Write stays on the in-scope $order — one meta-only save,
// no order-lifecycle hooks.
$created_sites = IWP_Woo_HPOS::get_order_meta($order, '_iwp_created_sites');
if (is_array($created_sites)) {
foreach ($created_sites as $index => $site) {
if (isset($site['site_id']) && intval($site['site_id']) === $site_id) {
Expand All @@ -996,7 +1029,7 @@ private function update_site_url_with_mapped_domain($order_id, $site_id, $domain
}
}
$order->update_meta_data('_iwp_created_sites', $created_sites);
$order->save();
$order->save_meta_data();
}

// Update sites database if using IWP_Site_Manager
Expand Down
30 changes: 17 additions & 13 deletions includes/admin/class-iwp-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -2194,22 +2194,26 @@ private function update_site_url_with_mapped_domain($order_id, $site_id, $domain

error_log('IWP WooCommerce V2: Updating site URL to mapped domain: ' . $new_url);

// Update in _iwp_sites_created (order processor format)
$sites_created = get_post_meta($order_id, '_iwp_sites_created', true);
// Update in _iwp_sites_created (order processor format).
// Route through the HPOS-safe helpers so reads and writes target
// whichever table the active data store uses and so any legacy
// postmeta-only value is migrated forward on read.
$sites_created = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_sites_created');
if (is_array($sites_created)) {
foreach ($sites_created as &$site_data) {
if (isset($site_data['site_data']['site_id']) && $site_data['site_data']['site_id'] == $site_id) {
$site_data['site_data']['site_url'] = $new_url;
$site_data['site_data']['wp_url'] = $new_url;
$site_data['site_data']['wp_url'] = $new_url;
error_log('IWP WooCommerce V2: Updated site URL in _iwp_sites_created');
break;
}
}
update_post_meta($order_id, '_iwp_sites_created', $sites_created);
IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_sites_created', $sites_created);
}

// Update in _iwp_created_sites (site manager format)
$created_sites = get_post_meta($order_id, '_iwp_created_sites', true);
// Update in _iwp_created_sites (site manager format) — same HPOS-safe
// routing as above.
$created_sites = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_created_sites');
if (is_array($created_sites)) {
foreach ($created_sites as &$site_info) {
if (isset($site_info['site_id']) && $site_info['site_id'] == $site_id) {
Expand All @@ -2218,7 +2222,7 @@ private function update_site_url_with_mapped_domain($order_id, $site_id, $domain
break;
}
}
update_post_meta($order_id, '_iwp_created_sites', $created_sites);
IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_created_sites', $created_sites);
}

error_log('IWP WooCommerce V2: Site URL update completed for domain: ' . $domain_name);
Expand Down Expand Up @@ -2266,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
Loading
Loading