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 '

' . esc_html__( 'Your account has been created and you are now logged in.', 'unsupervised-schedular' ) - . '

'; + . '

' . $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 { }; ?>
- -

- +

diff --git a/tests/Unit/Auth/RegistrationPageTest.php b/tests/Unit/Auth/RegistrationPageTest.php index 6839334..e202399 100644 --- a/tests/Unit/Auth/RegistrationPageTest.php +++ b/tests/Unit/Auth/RegistrationPageTest.php @@ -484,6 +484,54 @@ class RegistrationPageTest extends TestCase self::assertStringContainsString('us-success', $html); self::assertStringContainsString('now logged in', $html); + // No page chosen: the sign-in-screen fallback is useless to someone who + // is already signed in, so no link is offered at all. + self::assertStringNotContainsString(' 'invite' ]; + Functions\when('is_user_logged_in')->justReturn(true); + Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v)); + Functions\expect('get_permalink')->once()->with(4)->andReturn('http://home.test/welcome/'); + + $html = $this->ctx['page']->render([ 'loginPageId' => 4 ]); + + self::assertStringContainsString('now logged in', $html); + self::assertStringContainsString('href="http://home.test/welcome/"', $html); + } + + public function testContinueUrlIsNullWithoutAResolvablePage(): void + { + Functions\when('get_permalink')->justReturn(false); + + self::assertNull($this->ctx['page']->continueUrl(0)); + self::assertNull($this->ctx['page']->continueUrl(4)); + } + + public function testIsRegistrationCompleteOnlyForFinishedStates(): void + { + Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v)); + + // [ query args, logged in, finished ] + $cases = [ + 'invited student, now logged in' => [['us_registered' => 'invite'], true, true], + 'invited student, not logged in' => [['us_registered' => 'invite'], false, false], + 'email confirmed, ready' => [['us_confirmed' => 'ready'], false, true], + 'email confirmed, pending review' => [['us_confirmed' => '1'], false, true], + 'confirmation link expired' => [['us_confirmed' => 'expired'], false, false], + 'awaiting email confirmation' => [['us_registered' => 'confirm'], false, false], + 'group signup awaiting confirm' => [['us_registered' => 'confirm_group'], false, false], + 'plain page view' => [[], false, false], + ]; + + foreach ($cases as $label => [$get, $loggedIn, $expected]) { + $_GET = $get; + Functions\when('is_user_logged_in')->justReturn($loggedIn); + + self::assertSame($expected, $this->ctx['page']->isRegistrationComplete(), $label); + } } public function testInviteOnlyMessageCanBeCustomised(): void diff --git a/tests/Unit/BlockRegistrarTest.php b/tests/Unit/BlockRegistrarTest.php index 2013110..86674bc 100644 --- a/tests/Unit/BlockRegistrarTest.php +++ b/tests/Unit/BlockRegistrarTest.php @@ -52,6 +52,12 @@ class BlockRegistrarTest extends TestCase $this->registrationPage = Mockery::mock(RegistrationPage::class); $this->groupClassPage = Mockery::mock(GroupClassPage::class); + // Most requests are not a just-finished registration; the tests that + // exercise that path override this. + $this->registrationPage->shouldReceive('isRegistrationComplete') + ->andReturn(false) + ->byDefault(); + $this->registrar = new TestableBlockRegistrar( $this->bookingPage, $this->loginPage, @@ -126,7 +132,7 @@ class BlockRegistrarTest extends TestCase array_keys($registered['us-scheduler/student-login']['attributes']) ); self::assertSame( - ['loginPageId', 'inviteOnlyMessage'], + ['loginPageId', 'autoRedirect', 'inviteOnlyMessage'], array_keys($registered['us-scheduler/student-register']['attributes']) ); self::assertSame( @@ -397,6 +403,145 @@ class BlockRegistrarTest extends TestCase self::assertSame([], $this->registrar->redirects); } + public function testAutoRedirectSendsAFinishedRegistrationToTheChosenPage(): void + { + $this->stubSingularRequest( + 30, + [ + [ + 'blockName' => 'us-scheduler/student-register', + 'attrs' => ['autoRedirect' => true, 'loginPageId' => 4], + 'innerBlocks' => [], + ], + ], + false + ); + + $this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true); + $this->registrationPage->shouldReceive('continueUrl') + ->once()->with(4)->andReturn('https://example.com/welcome/'); + + $this->registrar->maybeAutoRedirect(); + + self::assertSame(['https://example.com/welcome/'], $this->registrar->redirects); + } + + public function testAutoRedirectSendsAJustLoggedInInvitedStudentToTheChosenPage(): void + { + // The invited-student branch completes logged in, so the logged-in + // student-login branch must not get first claim on the request. + $this->stubSingularRequest( + 30, + [ + [ + 'blockName' => 'us-scheduler/student-register', + 'attrs' => ['autoRedirect' => true, 'loginPageId' => 4], + 'innerBlocks' => [], + ], + ], + true + ); + + $this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true); + $this->registrationPage->shouldReceive('continueUrl') + ->once()->with(4)->andReturn('https://example.com/welcome/'); + + $this->registrar->maybeAutoRedirect(); + + self::assertSame(['https://example.com/welcome/'], $this->registrar->redirects); + } + + public function testNoRedirectWhenTheRegistrationIsNotFinished(): void + { + // e.g. the "check your email" step, or a validation error — the + // message has to be read, so the block never redirects past it. + $this->stubSingularRequest( + 30, + [ + [ + 'blockName' => 'us-scheduler/student-register', + 'attrs' => ['autoRedirect' => true, 'loginPageId' => 4], + 'innerBlocks' => [], + ], + ], + false + ); + + $this->registrationPage->shouldReceive('continueUrl')->never(); + + $this->registrar->maybeAutoRedirect(); + + self::assertSame([], $this->registrar->redirects); + } + + public function testNoRedirectWhenTheRegisterBlockDoesNotOptIn(): void + { + $this->stubSingularRequest( + 30, + [ + [ + 'blockName' => 'us-scheduler/student-register', + 'attrs' => ['loginPageId' => 4], + 'innerBlocks' => [], + ], + ], + false + ); + + $this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true); + $this->registrationPage->shouldReceive('continueUrl')->never(); + + $this->registrar->maybeAutoRedirect(); + + self::assertSame([], $this->registrar->redirects); + } + + public function testNoRedirectWhenTheRegisterBlockHasNoPageChosen(): void + { + $this->stubSingularRequest( + 30, + [ + [ + 'blockName' => 'us-scheduler/student-register', + 'attrs' => ['autoRedirect' => true], + 'innerBlocks' => [], + ], + ], + false + ); + + $this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true); + // No page chosen: there is no login-screen fallback to redirect to, + // so the student keeps the on-page confirmation instead. + $this->registrationPage->shouldReceive('continueUrl')->once()->with(0)->andReturnNull(); + + $this->registrar->maybeAutoRedirect(); + + self::assertSame([], $this->registrar->redirects); + } + + public function testNoRedirectWhenTheRegisterBlockPointsAtItsOwnPage(): void + { + $this->stubSingularRequest( + 4, + [ + [ + 'blockName' => 'us-scheduler/student-register', + 'attrs' => ['autoRedirect' => true, 'loginPageId' => 4], + 'innerBlocks' => [], + ], + ], + false + ); + + $this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true); + $this->registrationPage->shouldReceive('continueUrl')->never(); + + $this->registrar->maybeAutoRedirect(); + + self::assertSame([], $this->registrar->redirects); + } + public function testNoRedirectOutsideSingularFrontEndRequests(): void { Functions\when('is_admin')->justReturn(false);