From 5ffbacacdf99ebdfd3d4725c9f0b86112cef37e8 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 5 May 2026 17:22:56 -0600 Subject: [PATCH 1/4] feat(template-switch): inline AJAX errors and reset/switch label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round out the customer-panel template-switching UX so the page never silently fails or misnames the action that just succeeded. Inline AJAX errors Previously a permission-denied or network failure surfaced through `wu_ajax_error()` only — a notice rendered far above the action panel the customer just clicked, easily missed. The Vue state now carries an `error_message` field, the new `show_error()` helper keeps the confirm panel open and scrolls it into view, and `template-switching-confirm.php` renders a `role="alert"` red block between the disclaimer and the action buttons. Errors clear when the customer cancels the panel or picks a different template. The legacy global notice still fires as a belt-and-braces fallback. Reset vs Switch success label When the customer clicks Apply on the same template they're already on, the success notice now reads "Template reset successfully!" instead of the misleading "Template switched successfully!". The AJAX handler captures the previous template id BEFORE `Site_Duplicator::override_site()` rewrites it, then redirects with `?wu_template_action=reset|switch`. The admin page reads that flag and picks the appropriate label. We use a namespaced `wu_template_action` query var rather than the generic `?action=` because `wp-admin/admin.php` intercepts and rewrites generic action requests as admin-action dispatches, which silently strips the companion `updated=1` flag and breaks the notice. Initial-load reset notice Co-locate `v-init:original_template_id` and `v-init:template_id` on the same hidden input so they bind in the same Vue flush, and add a watcher guard that bails when `original_template_id <= 0`. Together this prevents the spurious "Template reset" notice that briefly flashed on first page render. Current-template summary card Render the customer's current template in its own card above the available-templates grid, with a Reset button co-located in the card. The grid filter excludes the current template id so it isn't shown twice. The filter is scoped to the customer-panel switching element so the same template-selection view can still be reused on signup (where there is no current template to exclude). Updated_message notice block Add a guarded `` block to `views/base/dash.php` so the five other pages that use this view continue to render unchanged while the template-switching page can opt in. Verified end-to-end with a real customer (`sthscah` on site 7): permission-denied path, network-error path, cancel-clears-error, new-pick-clears-error, real Reset path, real Switch path. --- assets/js/template-switching.js | 288 +++++++++++++++--- assets/js/template-switching.min.js | 2 +- .../class-template-switching-admin-page.php | 23 +- inc/ui/class-template-switching-element.php | 281 +++++++++++++---- views/base/dash.php | 24 ++ views/ui/template-switching-confirm.php | 226 ++++++++++++++ views/ui/template-switching-current.php | 22 ++ 7 files changed, 752 insertions(+), 114 deletions(-) create mode 100644 views/ui/template-switching-confirm.php diff --git a/assets/js/template-switching.js b/assets/js/template-switching.js index ac08bf1c8..7c9f5569c 100644 --- a/assets/js/template-switching.js +++ b/assets/js/template-switching.js @@ -52,7 +52,43 @@ template_category: '', stored_templates: {}, confirm_switch: 0, + + /* + * Drives visibility of the confirm panel. Set true when + * the customer picks a grid template (template_id watcher + * below) or clicks Reset (reset_template). Set false on + * Cancel and after a successful switch. We track this + * explicitly rather than deriving it from + * (template_id !== original_template_id) because the + * Reset flow needs the panel to appear *while* + * template_id equals original_template_id, which a + * derived check could not express. + */ + confirm_active: false, ready: false, + + /* + * Inline error surface for the confirm panel. + * + * The legacy error path called wu_ajax_error() which + * either shows a SweetAlert modal (when Swal is loaded + * — it isn't on customer-panel pages) or prepends a + * dismissible notice to the very top of #wpbody-content. + * In the customer panel the confirm panel sits well + * below the fold on the typical viewport, so a notice + * placed at the top of the page is invisible to a + * customer who has just clicked "Switch to X". They + * see the spinner clear, the panel close, and nothing + * else — looking like the action silently failed. + * + * Setting this string surfaces the error inline inside + * the confirm panel itself (see views/ui/template- + * switching-confirm.php), right where the customer's + * eye is. We keep the wu_ajax_error() call too for + * users who scroll up, plus belt-and-braces for the + * (rare) case where the panel itself fails to render. + */ + error_message: '', }; }, @@ -80,6 +116,85 @@ } // end if; }, + + /* + * Surface the confirm panel when the customer clicks a Select + * button in the grid. Grid buttons live inside the shared + * checkout/template-selection clean.php view (which we don't + * edit because signup uses it too); they only set + * template_id. Watching that change lets us inject the + * confirm_active = true behaviour without touching the + * shared view. + * + * Guard: don't flip confirm_active on the initial v-init + * assignment (which sets template_id = original_template_id + * before the page is interactive). Picking the customer's + * existing template from the grid is also a no-op — there's + * nothing to confirm. + */ + template_id(new_value, old_value) { + + if (old_value === undefined) { + + return; + + } // end if; + + if (new_value === 0 || new_value === '0') { + + this.confirm_active = false; + return; + + } // end if; + + /* + * Defence in depth against directive-ordering races on + * mount. The element output co-locates + * v-init:original_template_id and v-init:template_id on + * the same hidden field, but if anything ever causes the + * template_id directive to bind first (Vue plugin order, + * future markup change, third-party Vue mixin, etc.) we + * would otherwise reach the `confirm_active = true` + * branch below with original_template_id still at its + * data() sentinel of -1. -1 is a value that no real + * template can have, so we treat it as "not yet + * initialised" and bail out — the watcher will fire + * again on the next legitimate template change. + */ + // eslint-disable-next-line eqeqeq + if (this.original_template_id == -1 || this.original_template_id <= 0) { + + return; + + } // end if; + + // eslint-disable-next-line eqeqeq + if (new_value == this.original_template_id) { + + // Picking the current template from the grid is a + // no-op; reset_template() handles the deliberate + // "Reset" path and will set confirm_active = true + // itself. + return; + + } // end if; + + /* + * Clear any error message left over from a previous + * failed switch attempt. If the customer picks a + * different template after seeing an error, they are + * starting a new attempt and the stale message would + * be misleading. + */ + if (this.error_message) { + + this.error_message = ''; + + } // end if; + + this.confirm_active = true; + + }, }, methods: { get_template(template, data) { @@ -141,55 +256,142 @@ }, /** - * Re-apply the customer's current template, refreshing - * the site from the source template. Triggered by the - * "Reset current template" link in the form. + * Show the confirm panel for re-applying the customer's + * current template. Triggered by the "Reset Current + * Template" button in the current-template card. * - * Confirms with the customer first because this overwrites - * the site's content. Confirmation text is supplied via - * wu_template_switching_params.i18n.reset_confirm so it - * is translatable. + * Previously this used a native confirm() popup and fired + * the switch immediately. The new flow surfaces the same + * confirmation card used for switching to a different + * template — the customer sees the target thumbnail, name, + * and disclaimer before clicking Confirm. Pairing reset + * and switch under the same confirmation UI makes the + * "you are about to overwrite your site" warning feel + * consistent regardless of which path got the customer + * here. * - * Setting ready = true triggers the existing watcher which - * calls switch_template() — the same code path used by the - * normal "Process Switch" button. + * The actual switch is then triggered from the Confirm + * button inside the panel (which sets ready = true, + * matching the grid switch flow). */ reset_template() { - const params = window.wu_template_switching_params || {}; - const message = (params.i18n && params.i18n.reset_confirm) - ? params.i18n.reset_confirm - : 'Re-apply your current template? This will overwrite your site content with a fresh copy of the template. This cannot be undone.'; - - // Native confirm() matches the existing pattern used elsewhere - // in this codebase (edit-placeholders.js, tax-rates.js, dns-management.js) - // for destructive customer-facing actions. Avoiding the no-alert - // rule would mean introducing modal infrastructure that does not - // yet exist on this page. - // eslint-disable-next-line no-alert - if ( ! window.confirm(message)) { + if ( ! this.original_template_id || this.original_template_id <= 0) { return; } // end if; - if ( ! this.original_template_id || this.original_template_id <= 0) { + this.template_id = this.original_template_id; + this.confirm_active = true; - return; + /* + * Scroll the confirm panel into view. The current- + * template card and the panel can both fit on screen + * together for most viewports, but on shorter windows + * the panel renders below the fold; without this + * scroll, customers reported clicking Reset and seeing + * "nothing happen" until they scrolled. + */ + this.$nextTick(() => { - } // end if; + const panel = document.querySelector('.wu-template-switching-confirm'); - this.template_id = this.original_template_id; + if (panel && typeof panel.scrollIntoView === 'function') { - this.confirm_switch = true; + panel.scrollIntoView({ behavior: 'smooth', block: 'center' }); - this.ready = true; + } // end if; + + }); + + }, + + /** + * Dismiss the confirm panel without performing a switch. + * Resets the queued template selection back to the + * customer's active template so the grid no longer shows + * a "Selected" highlight on a template they decided not + * to switch to. + */ + cancel_switch() { + + this.confirm_active = false; + this.template_id = this.original_template_id; + this.error_message = ''; + + }, + + /** + * Surface an AJAX failure inline in the confirm panel and + * also via the global wu_ajax_error() toast/notice. + * + * Keeping the confirm panel open (confirm_active stays + * true) is deliberate: the previous behaviour closed the + * panel on failure and let wu_ajax_error() prepend a + * notice to the top of #wpbody-content. On the customer + * panel that notice is far above the fold from where the + * customer just clicked Confirm, so they saw nothing — + * the spinner cleared, the panel disappeared, and the + * action looked like a silent no-op. Now the error renders + * directly inside the panel where the click happened. + * + * @param {string|null} message Human-readable failure + * reason. Pass null to delegate copy to wu_ajax_error + * (used for network errors where we do not have a + * server-supplied string). + */ + show_error(message) { + + this.unblock(); + this.confirm_switch = false; + this.ready = false; + + /* + * Keep confirm_active true so the panel — which now + * carries the inline error block — stays visible. + */ + this.confirm_active = true; + + this.error_message = message || 'An error occurred while switching templates.'; + + /* + * Belt-and-braces: also show the global notice in case + * the panel itself fails to render (e.g. third-party + * JS error breaks the Vue update). + */ + wu_ajax_error(message); + + /* + * Bring the panel into view if the customer scrolled + * away or it was rendered below the fold. Without + * this, an inline error placed below the visible + * region would still go unnoticed. + */ + this.$nextTick(() => { + + const panel = document.querySelector('.wu-template-switching-confirm'); + + if (panel && typeof panel.scrollIntoView === 'function') { + + panel.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + } // end if; + + }); }, switch_template() { const that = this; + /* + * Clear any error from a previous attempt so the + * panel does not show a stale message while the new + * request is in flight. + */ + that.error_message = ''; + that.block(); this.request('wu_switch_template', { @@ -205,13 +407,7 @@ */ if ( ! results || typeof results !== 'object') { - that.unblock(); - - that.confirm_switch = false; - - that.ready = false; - - wu_ajax_error('An error occurred while switching templates.'); + that.show_error('An error occurred while switching templates.'); return; @@ -222,12 +418,6 @@ */ if (results.success === false) { - that.unblock(); - - that.confirm_switch = false; - - that.ready = false; - let errorMessage = 'An error occurred while switching templates.'; if (results.data && results.data.message) { @@ -240,7 +430,7 @@ } - wu_ajax_error(errorMessage); + that.show_error(errorMessage); return; @@ -269,21 +459,19 @@ that.unblock(); that.confirm_switch = false; + that.confirm_active = false; that.ready = false; }, function() { /* - * Handle network errors. + * Handle network errors. wu_ajax_error(null) lets that + * helper fill in its own "network error" copy; mirror it + * inline in the panel so the customer sees the same + * information without scrolling. */ - that.unblock(); - - that.confirm_switch = false; - - that.ready = false; - - wu_ajax_error(null); + that.show_error('A network error occurred. Please check your connection and try again.'); }); diff --git a/assets/js/template-switching.min.js b/assets/js/template-switching.min.js index 676426f46..87de7640d 100644 --- a/assets/js/template-switching.min.js +++ b/assets/js/template-switching.min.js @@ -1 +1 @@ -((e,t)=>{t.addAction("wu_checkout_loaded","nextpress/wp-ultimo",function(){wu_create_cookie("wu_template",!1),wu_listen_to_cookie_change("wu_template",function(t){window.wu_template_switching.template_id=t})}),e(document).ready(function(){t.doAction("wu_checkout_loaded"),window.wu_template_switching=new Vue({el:"#wp-ultimo-form-wu-template-switching-form",data(){return{template_id:0,original_template_id:-1,template_category:"",stored_templates:{},confirm_switch:0,ready:!1}},directives:{init:{bind(t,e,a){a.context[e.arg]=e.value}}},components:{dynamic:{functional:!0,template:"#dynamic",props:["template"],render(t,e){e=e.props.template;return t(e?{template:e}:"
nbsp;
")}}},watch:{ready(){!1!==this.ready&&this.switch_template()}},methods:{get_template(t,e){void 0===e.id&&(e.id="default");var a=t+"/"+e.id;return void 0!==this.stored_templates[a]?this.stored_templates[a]:(a={duration:this.duration,duration_unit:this.duration_unit,products:this.products,...e},this.fetch_template(t,a),'
Loading
')},fetch_template(a,i){let r=this;void 0===i.id&&(i.id="default"),this.request("wu_render_field_template",{template:a,attributes:i},function(t){var e=a+"/"+i.id;t.success?Vue.set(r.stored_templates,e,t.data.html):Vue.set(r.stored_templates,e,"
"+t.data[0].message+"
")})},switch_template(){let a=this;a.block(),this.request("wu_switch_template",{template_id:a.template_id},function(e){if(!1===e.success){a.unblock(),a.confirm_switch=!1,a.ready=!1;let t="An error occurred while switching templates.";e.data&&e.data.message?t=e.data.message:e.data&&Array.isArray(e.data)&&e.data[0]&&e.data[0].message&&(t=e.data[0].message),void wu_ajax_error(t)}else"string"==typeof e.data.redirect_url&&(window.location.href=e.data.redirect_url)},function(){a.unblock(),a.confirm_switch=!1,a.ready=!1,wu_ajax_error(null)})},block(){var t=jQuery(this.$el).parents().filter(function(){return"rgba(0, 0, 0, 0)"!==e(this).css("backgroundColor")}).first().css("backgroundColor");jQuery(this.$el).wu_block({message:'
',overlayCSS:{backgroundColor:t||"#ffffff",opacity:.6},css:{padding:0,margin:0,width:"50%",fontSize:"14px !important",top:"40%",left:"35%",textAlign:"center",color:"#000",border:"none",backgroundColor:"none",cursor:"wait"}})},unblock(){jQuery(this.$el).wu_unblock()},request(t,e,a,i){jQuery.ajax({method:"POST",url:wu_template_switching_params.ajaxurl+"&action="+t,data:e,success:a,error:i})}}})})})(jQuery,wp.hooks); \ No newline at end of file +((e,t)=>{t.addAction("wu_checkout_loaded","nextpress/wp-ultimo",function(){wu_create_cookie("wu_template",!1),wu_listen_to_cookie_change("wu_template",function(t){window.wu_template_switching.template_id=t})}),e(document).ready(function(){t.doAction("wu_checkout_loaded"),window.wu_template_switching=new Vue({el:"#wp-ultimo-form-wu-template-switching-form",data(){return{template_id:0,original_template_id:-1,template_category:"",stored_templates:{},confirm_switch:0,confirm_active:!1,ready:!1,error_message:""}},directives:{init:{bind(t,e,i){i.context[e.arg]=e.value}}},components:{dynamic:{functional:!0,template:"#dynamic",props:["template"],render(t,e){e=e.props.template;return t(e?{template:e}:"
nbsp;
")}}},watch:{ready(){!1!==this.ready&&this.switch_template()},template_id(t,e){void 0!==e&&(0===t||"0"===t?this.confirm_active=!1:-1==this.original_template_id||this.original_template_id<=0||t!=this.original_template_id&&(this.error_message&&(this.error_message=""),this.confirm_active=!0))}},methods:{get_template(t,e){void 0===e.id&&(e.id="default");var i=t+"/"+e.id;return void 0!==this.stored_templates[i]?this.stored_templates[i]:(i={duration:this.duration,duration_unit:this.duration_unit,products:this.products,...e},this.fetch_template(t,i),'
Loading
')},fetch_template(i,r){let a=this;void 0===r.id&&(r.id="default"),this.request("wu_render_field_template",{template:i,attributes:r},function(t){var e=i+"/"+r.id;t.success?Vue.set(a.stored_templates,e,t.data.html):Vue.set(a.stored_templates,e,"
"+t.data[0].message+"
")})},reset_template(){!this.original_template_id||this.original_template_id<=0||(this.template_id=this.original_template_id,this.confirm_active=!0,this.$nextTick(()=>{var t=document.querySelector(".wu-template-switching-confirm");t&&"function"==typeof t.scrollIntoView&&t.scrollIntoView({behavior:"smooth",block:"center"})}))},cancel_switch(){this.confirm_active=!1,this.template_id=this.original_template_id,this.error_message=""},show_error(t){this.unblock(),this.confirm_switch=!1,this.ready=!1,this.confirm_active=!0,this.error_message=t||"An error occurred while switching templates.",wu_ajax_error(t),this.$nextTick(()=>{var t=document.querySelector(".wu-template-switching-confirm");t&&"function"==typeof t.scrollIntoView&&t.scrollIntoView({behavior:"smooth",block:"center"})})},switch_template(){let i=this;i.error_message="",i.block(),this.request("wu_switch_template",{template_id:i.template_id},function(e){if(e&&"object"==typeof e)if(!1===e.success){let t="An error occurred while switching templates.";e.data&&e.data.message?t=e.data.message:e.data&&Array.isArray(e.data)&&e.data[0]&&e.data[0].message&&(t=e.data[0].message),void i.show_error(t)}else e.data&&"string"==typeof e.data.redirect_url?window.location.href=e.data.redirect_url:(i.unblock(),i.confirm_switch=!1,i.confirm_active=!1,i.ready=!1);else i.show_error("An error occurred while switching templates.")},function(){i.show_error("A network error occurred. Please check your connection and try again.")})},block(){var t=jQuery(this.$el).parents().filter(function(){return"rgba(0, 0, 0, 0)"!==e(this).css("backgroundColor")}).first().css("backgroundColor");jQuery(this.$el).wu_block({message:'
',overlayCSS:{backgroundColor:t||"#ffffff",opacity:.6},css:{padding:0,margin:0,width:"50%",fontSize:"14px !important",top:"40%",left:"35%",textAlign:"center",color:"#000",border:"none",backgroundColor:"none",cursor:"wait"}})},unblock(){jQuery(this.$el).wu_unblock()},request(t,e,i,r){jQuery.ajax({method:"POST",url:wu_template_switching_params.ajaxurl+"&action="+t,data:e,success:i,error:r})}}})})})(jQuery,wp.hooks); \ No newline at end of file diff --git a/inc/admin-pages/customer-panel/class-template-switching-admin-page.php b/inc/admin-pages/customer-panel/class-template-switching-admin-page.php index 912fd00ce..37de43b6f 100644 --- a/inc/admin-pages/customer-panel/class-template-switching-admin-page.php +++ b/inc/admin-pages/customer-panel/class-template-switching-admin-page.php @@ -140,6 +140,27 @@ public function page_loaded(): void { * @return void */ public function output(): void { + /* + * Pick the success label that matches the action just performed. + * The AJAX handler in Template_Switching_Element::switch_template() + * sets ?wu_template_action=reset when the customer re-applied + * their existing template and ?wu_template_action=switch when + * they moved to a different one. We use a namespaced query var + * (rather than the generic `?action`) because wp-admin/admin.php + * intercepts and rewrites generic `action=` requests as admin- + * action dispatches, which would drop our companion `updated=1` + * flag from the URL and silently break the notice. + * + * Falling back to the switch wording keeps the message correct + * for legacy callers that may redirect with only ?updated=1. + * + * phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, no state change. + */ + $action = isset($_GET['wu_template_action']) ? sanitize_key(wp_unslash($_GET['wu_template_action'])) : ''; + $message = 'reset' === $action + ? __('Template reset successfully!', 'ultimate-multisite') + : __('Template switched successfully!', 'ultimate-multisite'); + /* * Renders the base edit page layout, with the columns and everything else =) */ @@ -151,7 +172,7 @@ public function output(): void { 'has_full_position' => false, 'content' => '', 'labels' => [ - 'updated_message' => __('Template switched successfully!', 'ultimate-multisite'), + 'updated_message' => $message, ], ] ); diff --git a/inc/ui/class-template-switching-element.php b/inc/ui/class-template-switching-element.php index f6c2e1d54..ace0e276f 100644 --- a/inc/ui/class-template-switching-element.php +++ b/inc/ui/class-template-switching-element.php @@ -337,6 +337,16 @@ public function switch_template() { return; } + /* + * Capture the customer's current template id BEFORE override_site() + * runs. override_site() rewrites the stored template id as part of + * the duplication, so reading $this->site->get_template_id() afterwards + * would always return the newly applied template and we'd lose the + * ability to distinguish "Reset" (template_id matches previous) from + * "Switch" (template_id differs). + */ + $previous_template_id = (int) $this->site->get_template_id(); + $switch = \WP_Ultimo\Helpers\Site_Duplicator::override_site($template_id, $this->site->get_id()); if ( ! $switch) { @@ -367,11 +377,37 @@ public function switch_template() { $referer = isset($_SERVER['HTTP_REFERER']) ? sanitize_url(wp_unslash($_SERVER['HTTP_REFERER'])) : ''; + /* + * Distinguish reset from switch on the redirect so the page can + * tell the customer which action just succeeded. The duplication + * itself is identical for both paths (override_site() runs the + * same way regardless), so this flag is purely cosmetic — but + * "Template reset successfully" reads correctly when the customer + * just re-applied their existing template, where "Template + * switched successfully" would be misleading. + * + * Comparing to the *previous* template id (pre-override) is + * deliberate: by the time this code runs, override_site() has + * already changed the stored template id, so we have to compare + * against the value we captured before calling it. + */ + $action_label = ((int) $template_id === (int) $previous_template_id) ? 'reset' : 'switch'; + + /* + * Use a namespaced query var (`wu_template_action`) rather than the + * generic `action` key. WordPress's wp-admin/admin.php treats + * `?action=...` as a request to dispatch an admin-action handler + * (admin_action_{name} hook) and rewrites/strips the URL during + * routing, which would drop our `updated=1` flag from the final + * address bar and silently break the success notice. The + * namespaced key bypasses that handling entirely. + */ wp_send_json_success( [ 'redirect_url' => add_query_arg( [ - 'updated' => 1, + 'updated' => 1, + 'wu_template_action' => $action_label, ], $referer ), @@ -425,12 +461,46 @@ public function output($atts, $content = null) { $atts['template_selection_sites'] = implode(',', $template_selection_field['sites']); + /* + * Current-template summary card — rendered before the grid so the + * customer can see "what they're on" plus the Reset button up front + * without scrolling. The card is always visible (no v-show) because + * "what is the site's current template?" remains true regardless of + * what template_id the customer has clicked in the grid below. + * + * Reset is co-located here (instead of as a separate row at the + * bottom) because the operation acts on the current template, not + * on the grid selection. Pairing them avoids a stray red link + * floating below the grid. + */ + $current_template_id = (int) $this->site->get_template_id(); + $current_template = $current_template_id > 0 ? wu_get_site($current_template_id) : false; + $site_list = explode(',', $atts['template_selection_sites']); $sites = array_map('wu_get_site', $site_list); $sites = array_filter($sites); + /* + * Hide the current template from the "Available Templates" grid. + * The current template is already shown in the summary card above, + * so listing it again as a "Select" option is redundant and a + * little confusing — the customer can re-apply it via the + * "Reset Current Template" button in the card. Doing this filter + * here (in the customer-panel switching element) instead of in + * views/checkout/templates/template-selection/clean.php keeps the + * filter scoped to switching only — the same view is reused for + * new-customer signup, where there is no "current template" to + * exclude. + */ + if ($current_template_id > 0) { + $sites = array_filter( + $sites, + static fn($site_template) => (int) $site_template->get_id() !== $current_template_id + ); + } + $categories = \WP_Ultimo\Models\Site::get_all_categories($sites); $template_attributes = [ @@ -451,21 +521,6 @@ public function output($atts, $content = null) { } }; - /* - * Current-template summary card — rendered before the grid so the - * customer can see "what they're on" plus the Reset button up front - * without scrolling. The card is always visible (no v-show) because - * "what is the site's current template?" remains true regardless of - * what template_id the customer has clicked in the grid below. - * - * Reset is co-located here (instead of as a separate row at the - * bottom) because the operation acts on the current template, not - * on the grid selection. Pairing them avoids a stray red link - * floating below the grid. - */ - $current_template_id = (int) $this->site->get_template_id(); - $current_template = $current_template_id > 0 ? wu_get_site($current_template_id) : false; - $current_card_renderer = function () use ($current_template, $current_template_id) { wu_get_template( 'ui/template-switching-current', @@ -502,7 +557,11 @@ public function output($atts, $content = null) { */ $checkout_fields['template_element'] = [ 'type' => 'note', - 'wrapper_classes' => 'wu-w-full', + // `wu-template-switching-grid-field` is a marker class + // targeted by the scoped + '; $form->render(); + echo ''; } } diff --git a/views/base/dash.php b/views/base/dash.php index 23b2531ee..f55be9538 100644 --- a/views/base/dash.php +++ b/views/base/dash.php @@ -51,6 +51,30 @@
+ + + +
+

+
+ + diff --git a/views/ui/template-switching-confirm.php b/views/ui/template-switching-confirm.php new file mode 100644 index 000000000..2f9712b1c --- /dev/null +++ b/views/ui/template-switching-confirm.php @@ -0,0 +1,226 @@ + + +?" + * panel to flash visible before the customer has done anything. + */ +?> +
+ + + + get_id(); + $confirm_title = $confirm_site->get_title(); + $confirm_image = $confirm_site->get_featured_image('wu-thumb-medium'); + $is_current = $confirm_id === (int) $original_template_id; + ?> + +
+ +
+ +
+ <?php echo esc_attr($confirm_title); ?> +
+ +
+
+ +
+

+ +

+

+ +

+
+ +
+ + + + + +
+ + + + + + + + + + +
+ +
+ + + +
diff --git a/views/ui/template-switching-current.php b/views/ui/template-switching-current.php index 23c423dbb..92022b07a 100644 --- a/views/ui/template-switching-current.php +++ b/views/ui/template-switching-current.php @@ -53,9 +53,31 @@ class="wu-rounded wu-border-solid wu-border wu-border-gray-300 wu-bg-white"
+ From 0a315e146cc881625c7f79344aed85792c71fdb3 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 5 May 2026 17:23:28 -0600 Subject: [PATCH 2/4] fix(light-ajax): resolve current customer/membership without init hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wu-ajax light-ajax pipeline (`?wu-ajax=1` requests handled by `Light_Ajax::process_light_ajax`) dispatches its `wu_ajax_*` action handlers at `plugins_loaded` priority 20 and calls `die()` immediately afterwards. This means the WordPress `init` action — and therefore `Current::load_currents()`, which is hooked to `init`/`wp` — never fires for these requests. As a result every call site that reads `WP_Ultimo()->currents->get_customer()` or `->get_membership()` sees a null cache, even when the user IS logged in as a real customer with the right ownership. The customer-visible symptom that surfaced this was the new template- switching AJAX call returning `not_authorized` for legitimate site owners. The same null-cache hazard affects every wu_form-based membership action (delete site, change payment method, change default site, cancel/resubscribe membership) and the customer-panel checkout forms (upgrade/downgrade, add new site). Fix the five reachable sites with the minimal local fallback that mirrors `Current::load_currents()`'s own heuristic — `wu_get_current_ customer()` for the customer (derived directly from `get_current_user_id()`, no Current dependency) and the customer's first membership where a membership is needed: - `Site::is_customer_allowed()` — fall back to `wu_get_current_ customer()` when the cache is null. Fixes the template-switching `not_authorized` regression and the same path used by the delete- site form handler. - `Membership::is_customer_allowed()` — same fallback. Affects every membership-gated wu_form action. - `Checkout_Form::membership_change_form_fields()` and `add_new_site_form_fields()` — extend the existing fallback chain with a customer-driven membership lookup so the customer-panel upgrade/downgrade and add-new-site forms return their fields when posted via the wu-ajax pipeline. Without this they silently returned an empty field list. - `Site_Manager::maybe_validate_add_new_site()` — same membership fallback so the validator no longer emits a spurious "not-owner" error when a logged-in customer submits the add-new-site form. A proper architectural fix (lazy-loading `Current::load_currents()` the first time a `get_*` accessor is read with a null cache) is tracked separately so it can land in its own change with the wider addon-compat consideration it deserves. Audit complete: every other `currents->get_*` call reachable from wu_ajax is either inside an element `setup()` (only runs at `wp` / `admin_head`) or inside a Stripe handler hooked at `init` priority 11 (after `Current::load_currents`), so this commit covers the full known set of light-ajax-vulnerable sites. PHPStan clean on all four files. --- inc/managers/class-site-manager.php | 20 ++++++++++++++ inc/models/class-checkout-form.php | 41 +++++++++++++++++++++++++++++ inc/models/class-membership.php | 27 +++++++++++++++++++ inc/models/class-site.php | 28 ++++++++++++++++++++ 4 files changed, 116 insertions(+) diff --git a/inc/managers/class-site-manager.php b/inc/managers/class-site-manager.php index 44399835c..57e711424 100644 --- a/inc/managers/class-site-manager.php +++ b/inc/managers/class-site-manager.php @@ -235,6 +235,26 @@ public function maybe_validate_add_new_site($checkout): void { $customer = wu_get_current_customer(); + /* + * Fallback for the wu-ajax light-ajax pipeline. + * Current::load_currents() — which populates the + * Current singleton's membership cache — is hooked to + * `init`, but light-ajax handlers dispatch at + * `plugins_loaded` priority 20 and call die() before + * init runs. Without the fallback below, every + * customer-panel "add new site" submission would emit + * a spurious "not-owner" error for the rightful + * customer because $membership is null. + * + * Mirrors the heuristic used by Current::load_currents + * itself (first membership of the current customer) so + * the cached and uncached paths agree. + */ + if ( ! $membership && $customer) { + $memberships = (array) $customer->get_memberships(); + $membership = wu_get_isset($memberships, 0, false); + } + if ( ! $customer || ! $membership || $customer->get_id() !== $membership->get_customer_id()) { $errors->add('not-owner', __('You do not have the necessary permissions to add a site to this membership', 'ultimate-multisite')); } diff --git a/inc/models/class-checkout-form.php b/inc/models/class-checkout-form.php index a29f1b899..0a1943b2d 100644 --- a/inc/models/class-checkout-form.php +++ b/inc/models/class-checkout-form.php @@ -1465,6 +1465,30 @@ public static function membership_change_form_fields() { $membership = wu_get_membership(wu_request('membership_id')); } + /* + * Customer-panel fallback. The Current singleton's membership + * cache is populated by Current::load_currents() at `init`, but + * the wu-ajax light-ajax pipeline dispatches its handlers at + * `plugins_loaded` priority 20 and calls die() before init runs. + * In that pipeline `currents->get_membership()` returns null + * even when the request is being made by a logged-in customer + * with a membership. Without this fallback, the upgrade-/ + * downgrade- form returns no fields at all when posted via the + * customer-panel AJAX flow. + * + * We reuse the same "first membership" heuristic that + * Current::load_currents itself uses, so behaviour matches the + * cached path exactly. + */ + if ( ! $membership) { + $customer = wu_get_current_customer(); + + if ($customer) { + $memberships = (array) $customer->get_memberships(); + $membership = wu_get_isset($memberships, 0, false); + } + } + if ( ! $membership && current_user_can('manage_options')) { $membership = wu_mock_membership(); } @@ -1657,6 +1681,23 @@ public static function add_new_site_form_fields() { $membership = WP_Ultimo()->currents->get_membership(); + /* + * Customer-panel fallback for the wu-ajax light-ajax pipeline. + * See the equivalent comment in membership_change_form_fields() + * for the full rationale. Without this, posting "add new site" + * via the customer-panel AJAX flow returns no fields at all + * because Current::load_currents() (hooked to `init`) has not + * run by the time the handler dispatches at `plugins_loaded`. + */ + if ( ! $membership) { + $customer = wu_get_current_customer(); + + if ($customer) { + $memberships = (array) $customer->get_memberships(); + $membership = wu_get_isset($memberships, 0, false); + } + } + if ( ! $membership) { return []; } diff --git a/inc/models/class-membership.php b/inc/models/class-membership.php index a25d00c8f..4f8cb7635 100644 --- a/inc/models/class-membership.php +++ b/inc/models/class-membership.php @@ -462,8 +462,35 @@ public function is_customer_allowed($customer_id = false) { } if ( ! $customer_id) { + /* + * Resolve the current customer. + * + * `WP_Ultimo()->currents->get_customer()` returns the + * customer cached on the Current singleton, populated + * by Current::load_currents() — a method hooked to the + * `init` and `wp` actions. The wu-ajax light-ajax + * pipeline dispatches `wu_ajax_*` handlers at + * `plugins_loaded` priority 20 and calls die() before + * `init` ever fires, so the cache is null even when + * the user IS logged in as a real customer. Without + * the fallback below, every membership-gated wu_form + * action (delete site, change payment method, change + * default site, etc.) returns a spurious "not allowed" + * for the rightful owner. + * + * `wu_get_current_customer()` derives the customer + * directly from `get_current_user_id()` and has no + * dependency on the Current singleton having loaded. + * + * Mirrors the equivalent fix in + * Site::is_customer_allowed(). + */ $customer = WP_Ultimo()->currents->get_customer(); + if ( ! $customer) { + $customer = wu_get_current_customer(); + } + $customer_id = $customer ? $customer->get_id() : 0; } diff --git a/inc/models/class-site.php b/inc/models/class-site.php index cef45e255..3720c47a0 100644 --- a/inc/models/class-site.php +++ b/inc/models/class-site.php @@ -1048,8 +1048,36 @@ public function is_customer_allowed($customer_id = false) { } if ( ! $customer_id) { + /* + * Resolve the current customer. + * + * `WP_Ultimo()->currents->get_customer()` returns the + * customer cached on the Current singleton, which is + * populated by Current::load_currents() — a method + * hooked to the `init` and `wp` actions. That works + * for normal admin and front-end requests, but NOT + * for the wu-ajax light-ajax pipeline, which dispatches + * its handlers at `plugins_loaded` (priority 20) and + * calls `die()` before `init` ever fires. In that + * pipeline `currents->get_customer()` returns null + * even when the user IS logged in as a real customer, + * causing the call site to fall through to + * "$customer_id = 0" and is_customer_allowed() to + * always return false — which surfaced as a spurious + * `not_authorized` on the customer-panel template- + * switching AJAX call. + * + * We fall back to `wu_get_current_customer()` which + * derives the customer directly from + * `get_current_user_id()` and has no dependency on + * the Current singleton having loaded yet. + */ $customer = WP_Ultimo()->currents->get_customer(); + if ( ! $customer) { + $customer = wu_get_current_customer(); + } + $customer_id = $customer ? $customer->get_id() : 0; } From 78e37f102349dea37c94a127a12605ef6f2b97c9 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 5 May 2026 17:23:53 -0600 Subject: [PATCH 3/4] fix(gateway/stripe): support multi-interval carts and retried captures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related Stripe failure modes that surfaced when finishing payment on a cart that mixed a recurring hosting plan with a one-shot domain registration. `billing_mode: flexible` for subscription creation Stripe's 2025-06-30.basil API and later require `billing_mode: { type: flexible }` on the Subscription create call when the subscription items have different recurring intervals (e.g. a monthly plan plus a yearly add-on). Without it Stripe rejects the request with: All prices on a subscription must have the same `recurring. interval` and `recurring.interval_count` unless flexible billing mode is enabled and you're on the 2025-06-30.basil API version or later. Setting flexible mode is safe for single-interval carts too; it only unlocks the multi-interval case without changing existing behaviour. The bundled `stripe/stripe-php` SDK pins API version `2025-08-27.basil`, so the version requirement is always satisfied. Treat captured PaymentIntents as missing on retry `Stripe_Gateway::process_payment()` reused any existing PaymentIntent it found on the order, but only filtered out `canceled` ones. When a customer landed on the "finish payment" flow against an already- captured intent, the gateway tried to UPDATE that succeeded intent — including `application_fee_amount`, which Stripe rejects after capture. The result was a hard failure on what should have been a no-op redirect to the receipt. Now both `canceled` and `succeeded` are treated as terminal. The gateway forces a fresh intent for the retry, which is the correct behaviour for a "finish payment" flow that lands on an already- captured PI. Even when re-using an intent IS valid, drop `application_fee_amount` from the update payload — it is set at intent creation and cannot be modified afterwards. Verified end-to-end with a real customer cart that mixed a monthly hosting plan with a yearly domain registration: payment 2 captured, domain registered, order `125407050`, transaction `963405418`. --- inc/gateways/class-base-stripe-gateway.php | 19 +++++++++++++++ inc/gateways/class-stripe-gateway.php | 27 +++++++++++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php index 0269bb24e..e541ff32d 100644 --- a/inc/gateways/class-base-stripe-gateway.php +++ b/inc/gateways/class-base-stripe-gateway.php @@ -1723,12 +1723,31 @@ protected function create_recurring_payment($membership, $cart, $payment_method, /* * Subscription arguments for Stripe + * + * `billing_mode: { type: flexible }` is required when subscription + * items have different recurring intervals (e.g. a monthly hosting + * plan combined with a yearly domain registration). Without it, + * Stripe rejects subscription creation with: + * "All prices on a subscription must have the same `recurring.interval` + * and `recurring.interval_count` unless flexible billing mode is enabled + * and you're on the 2025-06-30.basil API version or later." + * + * Flexible mode is safe for single-interval carts too — it only + * unlocks the multi-interval case without changing single-interval + * behaviour. + * + * Requires Stripe API version 2025-06-30.basil or later. The bundled + * stripe/stripe-php SDK pins a newer version (2025-08-27.basil at the + * time of writing), so this is always satisfied. + * + * @since 2.5.x */ $sub_args = [ 'customer' => $s_customer->id, 'items' => array_values($stripe_cart), 'default_payment_method' => $payment_method->id, 'proration_behavior' => 'none', + 'billing_mode' => ['type' => 'flexible'], 'metadata' => $this->get_customer_metadata(), ]; diff --git a/inc/gateways/class-stripe-gateway.php b/inc/gateways/class-stripe-gateway.php index 8f207c2e9..c74cc10ce 100644 --- a/inc/gateways/class-stripe-gateway.php +++ b/inc/gateways/class-stripe-gateway.php @@ -424,8 +424,21 @@ public function run_preflight() { /* * We can't use canceled intents * for obvious reasons... + * + * We also can't reuse intents that are already + * in a terminal state (succeeded), or that have + * been captured already - Stripe rejects updates + * to those PaymentIntents (e.g. application_fee_amount + * cannot be updated after capture). + * + * Treating them as missing forces a fresh intent + * to be created for the retry, which is the + * correct behaviour for a "finish payment" flow + * landing on an already-captured PI. */ - if ( ! empty($existing_intent) && 'canceled' === $existing_intent->status) { + $terminal_intent_statuses = ['canceled', 'succeeded']; + + if ( ! empty($existing_intent) && in_array($existing_intent->status, $terminal_intent_statuses, true)) { $existing_intent = false; } @@ -472,8 +485,16 @@ public function run_preflight() { */ $intent_options['idempotency_key'] = wu_stripe_generate_idempotency_key($idempotency_args); - // Unset some options we can't update. - $unset_args = ['confirmation_method', 'confirm']; + /* + * Unset some options we can't update. + * + * application_fee_amount is set at PaymentIntent + * creation time and cannot be modified once the + * intent has progressed to capture. Stripe also + * rejects updates that include confirmation_method + * or confirm. + */ + $unset_args = ['confirmation_method', 'confirm', 'application_fee_amount']; foreach ($unset_args as $unset_arg) { if (isset($intent_args[ $unset_arg ])) { From 5d8b9539b3120bb287bd6b264e3732dcb68f3751 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 5 May 2026 17:24:18 -0600 Subject: [PATCH 4/4] fix(settings): stop persisting the literal string "false" for empty fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings text inputs that the user left blank were being saved to the network options as the four-letter string `"false"` and then re- displayed as `"false"` on every subsequent page load. The bug surfaced clearly on domain-seller registrant fields (where empty defaults are expected) but applied to any text setting registered without an explicit `default`. Root cause is a round-trip between PHP and the settings page Vue app: 1. `Settings::add_field()` built the field's `value` and `display_value` closures with `wu_get_setting($field_slug)` — no second argument. 2. `wu_get_setting`'s declared signature default is boolean `false`, so any unsaved setting resolved to `false`. 3. That `false` got JSON-encoded into the settings page Vue data-state. 4. Vue's `v-model` round-tripped it back to the form as the literal string `"false"` (booleans coerce to strings on input fields). 5. The form post wrote `"false"` into the saved setting, and the value persisted from then on. Fix on both sides of the round-trip: - Resolve each field's declared default before constructing the closures and pass it as `wu_get_setting`'s second argument. Where the field author didn't declare one, fall back to `false` for toggle/checkbox types and `''` for everything else. Unsaved text settings now resolve to an empty string instead of boolean `false`, so the Vue round-trip never sees the value that produces the `"false"` string. - In `wu_get_setting()` itself, treat the literal string `"false"` as "not set" so already-corrupted databases recover transparently without a manual cleanup. New saves cannot reintroduce the value because the closures fix is in place; this read-side guard exists purely for already-affected installs. Verified by saving an empty domain-seller registrant field, refreshing the page, and confirming the input renders empty (not `"false"`). --- inc/class-settings.php | 47 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/inc/class-settings.php b/inc/class-settings.php index 1c1710ed8..1ec687a81 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -242,7 +242,21 @@ public function get_setting($setting, $default_value = false) { _doing_it_wrong(esc_html($setting), esc_html__('Dashes are no longer supported when registering a setting. You should change it to underscores in later versions.', 'ultimate-multisite'), '2.0.0'); } - if (isset($settings[ $setting ])) { + /* + * Treat the literal string "false" as "not set" when reading a + * setting. Earlier versions of the settings save flow could + * persist the four-letter string "false" for any text field + * that was empty when the user clicked Save (the Vue data-state + * round-tripped a boolean false through v-model and back to + * PHP as the string "false"). Recovering existing installs + * without forcing a manual DB clean-up means treating that + * sentinel as missing so the caller's default (or the field + * default) takes precedence. The bug that produced these values + * is fixed in add_field()'s value/display_value closures, so new + * saves cannot reintroduce the string — this read-side guard + * exists purely for already-corrupted databases. + */ + if (isset($settings[ $setting ]) && 'false' !== $settings[ $setting ]) { $setting_value = $settings[ $setting ]; } elseif (false !== $default_value) { $setting_value = $default_value; @@ -481,6 +495,31 @@ function ($fields) use ($field_slug, $atts) { $default_order = (count($fields) + 1) * 10; + /* + * Resolve the field's declared default before building the + * value/display_value closures so they can fall back to it + * when the setting has not been saved yet. Previously the + * closures called `wu_get_setting($field_slug)` with no + * default arg, which meant unset settings resolved to + * boolean `false` from `wu_get_setting`'s signature + * default. That `false` then got JSON-encoded into the + * settings page Vue data-state, round-tripped through + * v-model as the literal string "false", and saved back to + * the database — leaving every empty text setting (e.g. + * domain_seller_registrant_*) showing the word "false" in + * its input. Passing the declared default as + * `wu_get_setting`'s second arg keeps unset values empty + * (or whatever the field author specified) and prevents + * the bogus "false" round-trip from re-occurring on every + * settings save. + */ + $declared_default = $atts['default'] ?? null; + + if (null === $declared_default) { + $type = $atts['type'] ?? 'text'; + $declared_default = in_array($type, ['toggle', 'checkbox'], true) ? false : ''; + } + $atts = wp_parse_args( $atts, [ @@ -488,13 +527,13 @@ function ($fields) use ($field_slug, $atts) { 'title' => '', 'desc' => '', 'order' => $default_order, - 'default' => null, + 'default' => $declared_default, 'capability' => 'manage_network', 'wrapper_html_attr' => [], 'require' => [], 'html_attr' => [], - 'value' => fn() => wu_get_setting($field_slug), - 'display_value' => fn() => wu_get_setting($field_slug), + 'value' => fn() => wu_get_setting($field_slug, $declared_default), + 'display_value' => fn() => wu_get_setting($field_slug, $declared_default), 'img' => function () use ($field_slug) { $img_id = wu_get_setting($field_slug);