From 13d6b3e14eba01923999494bff2e367def5f535b Mon Sep 17 00:00:00 2001
From: James Griffin
Date: Tue, 28 Jul 2026 10:58:17 -0300
Subject: [PATCH] Send students to a chosen page when registration succeeds
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Student Registration block's "After email confirmation" panel becomes
"After registration": the page it selects is now where a newly registered
student continues to, and a new autoRedirect toggle sends them there
instead of showing the link.
Only the two finished states qualify (RegistrationPage::isRegistrationComplete):
an invited student who is now logged in, and a self-signup back from the
emailed confirmation link. A validation error, an expired confirmation
link, and the intermediate "check your email" step all stay on the page so
their message is read.
The invited-student success previously had no link at all; it gains a
"Continue to your account" one. That path deliberately has no
WordPress-login-screen fallback — pointing someone already signed in at the
login screen helps nobody — so continueUrl() distinguishes "no page chosen"
from "page chosen", and the redirect does nothing until one is picked.
Closes #115
Co-Authored-By: Claude Opus 5
---
CHANGELOG.md | 1 +
assets/js/blocks.js | 11 +-
docs/features/account-registration.md | 7 ++
docs/features/editor-blocks.md | 12 +-
src/Auth/RegistrationPage.php | 74 ++++++++++--
src/BlockRegistrar.php | 53 +++++++-
templates/frontend/register-page.php | 6 +-
tests/Unit/Auth/RegistrationPageTest.php | 48 ++++++++
tests/Unit/BlockRegistrarTest.php | 147 ++++++++++++++++++++++-
9 files changed, 339 insertions(+), 20 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9935427..bd308fa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ each change under the current top section as you work.
### Added
- The **Group Classes** block can now be pinned to a single class, under **Classes shown → Class** in the block sidebar (shortcode: `[us_group_classes offering="…"]`). Pick a class and the block shows only that one, so it can be embedded on a page that describes the class. In this mode the class's own description is left out to avoid repeating the page copy — the card shows the schedule, instructor, price, enrolment deadline and the enrol/withdraw controls. Leaving it on **All classes** keeps the full browsable catalog with descriptions.
+- The **Student Registration** block can now send students onward to a page of your choosing once they finish registering. Its **After email confirmation** panel is now **After registration**: the page you pick there is where the link shown to a newly registered student points — the "Sign in to your account" link after they confirm their email, and a "Continue to your account" link for an invited student, who is signed in immediately. A new **Redirect automatically** option takes them straight there instead of showing the link. Registration errors are never skipped — a failed sign-up and an expired confirmation link still show their message on the page, as does the "check your email to confirm your address" step. The redirect needs a page to be chosen; with none set, students see the link (or, for invited students, just the confirmation) as before.
## [1.2.1]
diff --git a/assets/js/blocks.js b/assets/js/blocks.js
index 96cdd1f..7c24482 100644
--- a/assets/js/blocks.js
+++ b/assets/js/blocks.js
@@ -151,18 +151,25 @@
shortcode: 'us_student_register',
attributes: {
loginPageId: { type: 'number', default: 0 },
+ autoRedirect: { type: 'boolean', default: false },
inviteOnlyMessage: { type: 'string', default: '' },
},
inspector: (attributes, setAttributes) => [
el(
PanelBody,
- { title: __('After email confirmation', 'unsupervised-schedular'), key: 'confirmation' },
+ { title: __('After registration', 'unsupervised-schedular'), key: 'confirmation' },
el(PageSelect, {
label: __('Sign-in page', 'unsupervised-schedular'),
- help: __('Where the sign-in link shown after a student confirms their email address sends them.', 'unsupervised-schedular'),
+ help: __('Where students are sent once registration finishes — after they confirm their email address, or straight away for an invited student.', 'unsupervised-schedular'),
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
value: attributes.loginPageId,
onChange: (loginPageId) => setAttributes({ loginPageId }),
+ }),
+ el(ToggleControl, {
+ label: __('Redirect automatically', 'unsupervised-schedular'),
+ help: __('Send students straight to that page instead of showing the link. Requires a page to be chosen; errors and the "check your email" step are never skipped.', 'unsupervised-schedular'),
+ checked: !!attributes.autoRedirect,
+ onChange: (autoRedirect) => setAttributes({ autoRedirect }),
})
),
el(
diff --git a/docs/features/account-registration.md b/docs/features/account-registration.md
index e0d29c7..4e20698 100644
--- a/docs/features/account-registration.md
+++ b/docs/features/account-registration.md
@@ -128,6 +128,13 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
- `[us_student_register]` — the registration page. In `invite` mode: shows the form for a valid pending invite, else an "by invitation only" message. In `self_approval` mode: shows the form to anyone (editable email), and renders confirmation-result notices from `?us_confirmed=1|expired`.
- The invitation-only message is customisable: block attribute `inviteOnlyMessage` (set under the block's **Invitation-only notice** panel) / shortcode attribute `invite_only_message`. Blank falls back to the default wording (`RegistrationPage::inviteOnlyMessage()`).
+## Where Students Go Next
+The block's **After registration** panel picks the page a student continues to once
+registration finishes, and whether they get there by hand or automatically.
+
+- **Sign-in page** (`loginPageId` / `login_page_id`) — the target of the "Sign in to your account" link shown after email confirmation (`?us_confirmed=1|ready`, falling back to the WordPress login screen) and of the "Continue to your account" link an invited student sees on the spot (`?us_registered=invite`; no link at all with no page chosen, since an already-signed-in student has no use for the login screen).
+- **Redirect automatically** (`autoRedirect`, block only) — sends the student to that page instead of showing the link, via `BlockRegistrar::maybeAutoRedirect()` on `template_redirect`. It fires only on those two finished states (`RegistrationPage::isRegistrationComplete()`), so the "check your email" step, a validation error, and an `expired` confirmation link are always shown rather than redirected past. With no page chosen nothing happens — there is deliberately no login-screen fallback for the redirect. See `editor-blocks.md`.
+
## Token Redirect
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
sends any front-end request carrying a `us_invite` token to the configured
diff --git a/docs/features/editor-blocks.md b/docs/features/editor-blocks.md
index 67e520e..3b76daf 100644
--- a/docs/features/editor-blocks.md
+++ b/docs/features/editor-blocks.md
@@ -29,7 +29,8 @@ Four blocks have sidebar (inspector) options:
| `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. |
| `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. |
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
-| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page the "Sign in to your account" link points to after a student confirms their email. `0` = the WordPress login screen. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
+| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
+| `us-scheduler/student-register` | `autoRedirect` (boolean) | `false` | Send students straight to that page instead of showing the link. Does nothing until a page is chosen — there is no login-screen fallback here. |
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. The class description is then omitted — only the schedule, instructor, price and enrolment controls are shown, so the surrounding page's own copy is not repeated. `0` = browse all classes, descriptions included. Shortcode equivalent: `[us_group_classes offering="…"]`. |
The page selects list all published pages; if a chosen page is later deleted,
@@ -48,6 +49,15 @@ queried singular post's content for the block (including inside nested
blocks), and redirects when the block opts in. A block whose target is its
own page is ignored to avoid a redirect loop.
+The registration block's auto-redirect additionally only fires on a
+**finished** registration — `RegistrationPage::isRegistrationComplete()`: an
+invited student who is now logged in (`?us_registered=invite`), or a
+self-signup back from the emailed confirmation link (`?us_confirmed=ready|1`).
+The intermediate "check your email" step and every failure (a validation
+error, `?us_confirmed=expired`) stay on the page so the student reads the
+message. That check runs before the content is parsed, so an ordinary page
+view does not pay for the extra block scan.
+
## How it works
- **`BlockRegistrar`** (`src/BlockRegistrar.php`) hooks `init` and registers
diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php
index feb6c62..5623675 100644
--- a/src/Auth/RegistrationPage.php
+++ b/src/Auth/RegistrationPage.php
@@ -66,9 +66,20 @@ class RegistrationPage {
if ( is_user_logged_in() ) {
if ( self::RESULT_INVITE === $registered ) {
+ // An invited student is done the moment they land here logged in,
+ // so this is where their "continue" link belongs. The sign-in-page
+ // fallback is deliberately not used: pointing someone who is
+ // already signed in at the login screen helps nobody.
+ $continue = $this->continueUrl( $this->successPageId( $atts ) );
+ $link = null === $continue
+ ? ''
+ : ''
+ . esc_html__( 'Continue to your account', 'unsupervised-schedular' )
+ . '
';
+
return '';
+ . '
' . $link . '';
}
return '' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '
';
@@ -98,7 +109,7 @@ class RegistrationPage {
$confirmResult = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
// Where the post-confirmation prompt sends students to sign in.
- $loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) );
+ $loginUrl = $this->loginUrl( $this->successPageId( $atts ) );
$policyForms = $this->signupPolicies();
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
@@ -319,21 +330,68 @@ class RegistrationPage {
return $autoApprove ? self::RESULT_CONFIRM_GROUP : self::RESULT_CONFIRM;
}
+ /**
+ * The page id chosen for the post-registration destination, from either the
+ * block (`loginPageId`) or shortcode (`login_page_id`) attribute.
+ *
+ * @param array $atts
+ */
+ private function successPageId( array $atts ): int {
+ return Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
+ }
+
/**
* URL the post-confirmation sign-in link points to: the chosen login page
* when one is configured (and still exists), otherwise the WordPress login
* screen.
*/
private function loginUrl( int $loginPageId ): string {
- if ( $loginPageId > 0 ) {
- $url = get_permalink( $loginPageId );
+ return $this->continueUrl( $loginPageId ) ?? wp_login_url();
+ }
- if ( is_string( $url ) ) {
- return $url;
- }
+ /**
+ * The chosen post-registration page's URL, or null when none is configured
+ * (or it has since been deleted). Unlike {@see loginUrl()} this has no
+ * WordPress-login-screen fallback, so callers that need a page the student
+ * was actually sent to — the invited-student link and the block's
+ * auto-redirect — can tell "not configured" from "configured".
+ */
+ public function continueUrl( int $pageId ): ?string {
+ if ( $pageId <= 0 ) {
+ return null;
}
- return wp_login_url();
+ $url = get_permalink( $pageId );
+
+ return is_string( $url ) ? $url : null;
+ }
+
+ /**
+ * Whether this request is a *finished* registration — the states the
+ * block's auto-redirect may act on:
+ *
+ * - an invited student who just signed up and is now logged in, and
+ * - a self-signup returning from the emailed confirmation link, whether
+ * their account is ready (`ready`) or awaiting studio approval (`1`).
+ *
+ * Deliberately excluded: the intermediate "check your email" step (the
+ * student would never see the instruction) and every failure — a validation
+ * error or an expired confirmation link (`expired`) — so the message always
+ * gets shown. The `us_confirmed` values are set by
+ * {@see EmailConfirmationHandler::maybeConfirm()}.
+ */
+ public function isRegistrationComplete(): bool {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
+ $registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
+
+ if ( self::RESULT_INVITE === $registered ) {
+ return is_user_logged_in();
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag set by EmailConfirmationHandler's redirect.
+ $confirmed = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
+
+ return in_array( $confirmed, [ '1', 'ready' ], true );
}
/**
diff --git a/src/BlockRegistrar.php b/src/BlockRegistrar.php
index dc364b2..f1299e1 100644
--- a/src/BlockRegistrar.php
+++ b/src/BlockRegistrar.php
@@ -109,6 +109,7 @@ class BlockRegistrar {
'type' => 'number',
'default' => 0,
],
+ 'autoRedirect' => $redirectToggle,
'inviteOnlyMessage' => [
'type' => 'string',
'default' => '',
@@ -170,10 +171,11 @@ class BlockRegistrar {
/**
* Server-side auto-redirect for blocks that opt in via their autoRedirect
* attribute: logged-out visitors on a page containing the booking block
- * are sent to its login page, and logged-in visitors on a page containing
- * the student-login block are sent to its booking page. Hooked on
- * `template_redirect` because block rendering happens after output has
- * started, too late to send a Location header.
+ * are sent to its login page, logged-in visitors on a page containing the
+ * student-login block are sent to its booking page, and a student who has
+ * just finished registering is sent to the register block's chosen page.
+ * Hooked on `template_redirect` because block rendering happens after
+ * output has started, too late to send a Location header.
*/
public function maybeAutoRedirect(): void {
if ( is_admin() || ! is_singular() ) {
@@ -185,6 +187,10 @@ class BlockRegistrar {
return;
}
+ if ( $this->maybeRedirectAfterRegistration( $post ) ) {
+ return;
+ }
+
if ( is_user_logged_in() ) {
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-login' );
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
@@ -217,6 +223,45 @@ class BlockRegistrar {
$this->redirect( $this->bookingPage->loginUrl( $loginPageId ) );
}
+ /**
+ * Sends a student whose registration has just completed to the register
+ * block's chosen page, when the block opts in. Only the finished states
+ * qualify (see {@see RegistrationPage::isRegistrationComplete()}): a
+ * failure or the "check your email" step stays put so its message is read.
+ * Unlike the other blocks there is no login-screen fallback — with no page
+ * chosen there is nowhere to send them, so the link is shown instead.
+ *
+ * Returns whether the redirect was issued (it only ever returns in tests;
+ * {@see redirect()} exits in production).
+ */
+ private function maybeRedirectAfterRegistration( \WP_Post $post ): bool {
+ // Checked before parsing the content because it is a couple of query
+ // args, whereas every front-end request would otherwise pay for a
+ // third block scan.
+ if ( ! $this->registrationPage->isRegistrationComplete() ) {
+ return false;
+ }
+
+ $attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-register' );
+ if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
+ return false;
+ }
+
+ $pageId = Val::int( $attrs['loginPageId'] ?? 0 );
+ if ( $pageId === $post->ID ) {
+ return false; // Redirecting the page to itself would loop.
+ }
+
+ $url = $this->registrationPage->continueUrl( $pageId );
+ if ( null === $url ) {
+ return false;
+ }
+
+ $this->redirect( $url );
+
+ return true;
+ }
+
/**
* Attributes of the first occurrence of the named block in the content,
* searching inner blocks so blocks nested inside groups or columns are
diff --git a/templates/frontend/register-page.php b/templates/frontend/register-page.php
index b835188..9d605b5 100644
--- a/templates/frontend/register-page.php
+++ b/templates/frontend/register-page.php
@@ -14,7 +14,7 @@ if (! defined('ABSPATH')) {
* @var bool $canRegister
* @var string $inviteOnlyMessage Text shown when registration is closed and no valid invite is present.
* @var bool $open Whether open (self-approval) registration is enabled.
- * @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm).
+ * @var string $successType '' | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm). The invited-student success is rendered by RegistrationPage::render() itself, which returns before this template for logged-in visitors.
* @var string $confirmResult '' | '1' (email confirmed, awaiting approval) | 'ready' (confirmed + auto-approved) | 'expired'.
* @var string $loginUrl Where the post-confirmation sign-in link points.
* @var string $error
@@ -57,9 +57,7 @@ $renderQuestionField = static function (Question $question): void {
};
?>