CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m0s
CI / Coding Standards (pull_request) Successful in 2m51s
CI / PHPStan (pull_request) Successful in 2m57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 2s
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 <[email protected]>
321 lines
9.7 KiB
PHP
321 lines
9.7 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular;
|
|
|
|
use Unsupervised\Schedular\Auth\LoginPage;
|
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
|
use Unsupervised\Schedular\Booking\BookingPage;
|
|
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
|
|
|
/**
|
|
* Registers Gutenberg dynamic-block wrappers for the front-end shortcodes so
|
|
* the pages can be previewed and styled inside the block editor.
|
|
*
|
|
* On the front end each block delegates to the same page object its shortcode
|
|
* uses, so output is identical either way. Inside the editor (the
|
|
* block-renderer REST preview used by wp.serverSideRender) a static preview
|
|
* from BlockPreview is rendered instead — same markup and CSS classes, no
|
|
* live REST calls, redirects, or Stripe.js.
|
|
*/
|
|
class BlockRegistrar {
|
|
|
|
public const SCRIPT_HANDLE = 'us-scheduler-blocks';
|
|
public const STYLE_HANDLE = 'us-scheduler';
|
|
|
|
public function __construct(
|
|
private BookingPage $bookingPage,
|
|
private LoginPage $loginPage,
|
|
private RegistrationPage $registrationPage,
|
|
private GroupClassPage $groupClassPage,
|
|
) {}
|
|
|
|
public function register(): void {
|
|
add_action( 'init', [ $this, 'registerBlocks' ] );
|
|
add_action( 'template_redirect', [ $this, 'maybeAutoRedirect' ] );
|
|
}
|
|
|
|
public function registerBlocks(): void {
|
|
// The editor script registers the client side of each block (title,
|
|
// icon, shortcode transform, inspector controls) and previews it via
|
|
// wp.serverSideRender.
|
|
wp_register_script(
|
|
self::SCRIPT_HANDLE,
|
|
USC_PLUGIN_URL . 'assets/js/blocks.js',
|
|
[ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-core-data', 'wp-server-side-render', 'wp-i18n', 'wp-api-fetch' ],
|
|
USC_VERSION,
|
|
true
|
|
);
|
|
|
|
// The front-end stylesheet doubles as the block style so editor
|
|
// previews look like the published page. ShortcodeRegistrar registers
|
|
// the same handle on the front end, hence the guard.
|
|
if ( ! wp_style_is( self::STYLE_HANDLE, 'registered' ) ) {
|
|
wp_register_style( self::STYLE_HANDLE, USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );
|
|
}
|
|
|
|
foreach ( $this->blocks() as $name => $config ) {
|
|
register_block_type(
|
|
$name,
|
|
[
|
|
'api_version' => '3',
|
|
'editor_script' => self::SCRIPT_HANDLE,
|
|
'style' => self::STYLE_HANDLE,
|
|
'attributes' => $config['attributes'],
|
|
'render_callback' => $config['render'],
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Block definitions: render callback plus the attribute schema. The
|
|
* schema must be declared server-side too, or the block-renderer preview
|
|
* endpoint rejects the attributes wp.serverSideRender sends.
|
|
*
|
|
* @return array<string, array{render: callable(array<string, mixed>=): string, attributes: array<string, array{type: string, default: mixed}>}>
|
|
*/
|
|
private function blocks(): array {
|
|
$redirectToggle = [
|
|
'type' => 'boolean',
|
|
'default' => false,
|
|
];
|
|
|
|
return [
|
|
'us-scheduler/booking' => [
|
|
'render' => [ $this, 'renderBooking' ],
|
|
'attributes' => [
|
|
'loginPageId' => [
|
|
'type' => 'number',
|
|
'default' => 0,
|
|
],
|
|
'autoRedirect' => $redirectToggle,
|
|
],
|
|
],
|
|
'us-scheduler/student-login' => [
|
|
'render' => [ $this, 'renderLogin' ],
|
|
'attributes' => [
|
|
'bookingPageId' => [
|
|
'type' => 'number',
|
|
'default' => 0,
|
|
],
|
|
'autoRedirect' => $redirectToggle,
|
|
],
|
|
],
|
|
'us-scheduler/student-register' => [
|
|
'render' => [ $this, 'renderRegistration' ],
|
|
'attributes' => [
|
|
'loginPageId' => [
|
|
'type' => 'number',
|
|
'default' => 0,
|
|
],
|
|
'autoRedirect' => $redirectToggle,
|
|
'inviteOnlyMessage' => [
|
|
'type' => 'string',
|
|
'default' => '',
|
|
],
|
|
],
|
|
],
|
|
'us-scheduler/group-classes' => [
|
|
'render' => [ $this, 'renderGroupClasses' ],
|
|
'attributes' => [
|
|
'offeringId' => [
|
|
'type' => 'number',
|
|
'default' => 0,
|
|
],
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Renders the booking block.
|
|
*
|
|
* @param array<string, mixed> $attributes Block attributes.
|
|
*/
|
|
public function renderBooking( array $attributes = [] ): string {
|
|
return $this->isEditorPreview() ? BlockPreview::booking() : $this->bookingPage->render( $attributes );
|
|
}
|
|
|
|
/**
|
|
* Renders the student-login block.
|
|
*
|
|
* @param array<string, mixed> $attributes Block attributes.
|
|
*/
|
|
public function renderLogin( array $attributes = [] ): string {
|
|
return $this->isEditorPreview() ? BlockPreview::login() : $this->loginPage->render( $attributes );
|
|
}
|
|
|
|
/**
|
|
* Renders the student-registration block.
|
|
*
|
|
* @param array<string, mixed> $attributes Block attributes.
|
|
*/
|
|
public function renderRegistration( array $attributes = [] ): string {
|
|
return $this->isEditorPreview() ? BlockPreview::registration() : $this->registrationPage->render( $attributes );
|
|
}
|
|
|
|
/**
|
|
* Renders the group-classes block.
|
|
*
|
|
* @param array<string, mixed> $attributes Block attributes.
|
|
*/
|
|
public function renderGroupClasses( array $attributes = [] ): string {
|
|
if ( ! $this->isEditorPreview() ) {
|
|
return $this->groupClassPage->render( $attributes );
|
|
}
|
|
|
|
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
|
|
}
|
|
|
|
/**
|
|
* 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, 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() ) {
|
|
return;
|
|
}
|
|
|
|
$post = get_post();
|
|
if ( ! $post instanceof \WP_Post ) {
|
|
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 ) ) {
|
|
return;
|
|
}
|
|
|
|
$bookingPageId = Val::int( $attrs['bookingPageId'] ?? 0 );
|
|
if ( $bookingPageId === $post->ID ) {
|
|
return; // Redirecting the page to itself would loop.
|
|
}
|
|
|
|
$url = $this->loginPage->bookingUrl( $bookingPageId );
|
|
if ( null !== $url ) {
|
|
$this->redirect( $url );
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/booking' );
|
|
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
|
|
return;
|
|
}
|
|
|
|
$loginPageId = Val::int( $attrs['loginPageId'] ?? 0 );
|
|
if ( $loginPageId === $post->ID ) {
|
|
return; // Redirecting the page to itself would loop.
|
|
}
|
|
|
|
$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
|
|
* still found. Null when the block is absent. Attributes equal to their
|
|
* schema default are omitted from the serialized block, so callers must
|
|
* apply defaults themselves.
|
|
*
|
|
* @return array<mixed>|null
|
|
*/
|
|
private function firstBlockAttrs( string $content, string $blockName ): ?array {
|
|
if ( ! has_block( $blockName, $content ) ) {
|
|
return null;
|
|
}
|
|
|
|
$queue = parse_blocks( $content );
|
|
|
|
while ( [] !== $queue ) {
|
|
$block = array_shift( $queue );
|
|
|
|
if ( ! is_array( $block ) ) {
|
|
continue;
|
|
}
|
|
|
|
if ( ( $block['blockName'] ?? null ) === $blockName ) {
|
|
$attrs = $block['attrs'] ?? null;
|
|
|
|
return is_array( $attrs ) ? $attrs : [];
|
|
}
|
|
|
|
$inner = $block['innerBlocks'] ?? null;
|
|
if ( is_array( $inner ) && [] !== $inner ) {
|
|
$queue = array_merge( $queue, array_values( $inner ) );
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Issues the redirect and stops the request. Split out so tests can
|
|
* observe redirects without the process exiting.
|
|
*/
|
|
protected function redirect( string $url ): void {
|
|
wp_safe_redirect( $url );
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Whether this render is the editor's block-renderer REST preview rather
|
|
* than a real front-end page render. Front-end template rendering never
|
|
* happens inside a REST request, so REST_REQUEST is a reliable signal.
|
|
*/
|
|
protected function isEditorPreview(): bool {
|
|
return defined( 'REST_REQUEST' ) && (bool) constant( 'REST_REQUEST' );
|
|
}
|
|
}
|