diff --git a/assets/js/template-switching.js b/assets/js/template-switching.js
index ac08bf1c..7c9f5569 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 676426f4..87de7640 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 912fd00c..37de43b6 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/class-settings.php b/inc/class-settings.php
index 1c1710ed..1ec687a8 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);
diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php
index 0269bb24..e541ff32 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 8f207c2e..c74cc10c 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 ])) {
diff --git a/inc/managers/class-site-manager.php b/inc/managers/class-site-manager.php
index 44399835..57e71142 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 a29f1b89..0a1943b2 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 a25d00c8..4f8cb763 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 cef45e25..3720c47a 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;
}
diff --git a/inc/ui/class-template-switching-element.php b/inc/ui/class-template-switching-element.php
index f6c2e1d5..ace0e276 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 23b2531e..f55be953 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 00000000..2f9712b1
--- /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;
+ ?>
+
+
+
+
+
+
+
; ?>)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ error_message }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/views/ui/template-switching-current.php b/views/ui/template-switching-current.php
index 23c423db..92022b07 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"