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; + ?> + +
+ +
+ +
+ <?php echo esc_attr($confirm_title); ?> +
+ +
+
+ +
+

+ +

+

+ +

+
+ +
+ + + + + +
+ + + + + + + + + + +
+ +
+ + + +
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"
+