Add link-target and auto-redirect options to booking/login blocks
CI / Coding Standards (pull_request) Successful in 51s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / Tests (PHP 8.3) (pull_request) Successful in 1m2s
CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 53s
CI / PHPStan (pull_request) Successful in 1m24s

The booking block gains a loginPageId attribute choosing which page its
logged-out "log in to book a lesson" link points to (default remains the
WordPress login screen), and the student-login block gains a
bookingPageId attribute controlling the logged-in "View available
lessons" link and the post-login redirect target (default remains the
current page). Both blocks also gain an autoRedirect toggle, off by
default, that sends the visitor straight to the target page; block
rendering starts after output, so the redirect runs on
template_redirect by parsing the queried page's content for the block,
with a self-target guard against redirect loops. The link targets are
also available to the shortcodes as login_page_id/booking_page_id.

Also fixes a pre-existing fatal: WordPress passes an empty string (not
an array) to shortcode callbacks when a shortcode is used without
attributes, so bare [us_booking] etc. threw a TypeError against the
strictly-typed render(array $atts) methods. ShortcodeRegistrar now
wraps each callback to normalize non-array attribute values.

Closes #51

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-05 16:16:52 -03:00
co-authored by Claude Fable 5
parent 43497503b9
commit 9d89bc6d0e
13 changed files with 848 additions and 53 deletions
+23 -6
View File
@@ -8,23 +8,25 @@ use Unsupervised\Schedular\Val;
class LoginPage {
/**
* Renders the student login shortcode output.
* Renders the student login shortcode/block output.
*
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
* @param array<int|string, mixed> $atts Block attributes (`bookingPageId`) or
* shortcode attributes (`booking_page_id`).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
public function render( array $atts ): string {
$bookingPageId = Val::int( $atts['bookingPageId'] ?? $atts['booking_page_id'] ?? 0 );
if ( is_user_logged_in() ) {
$redirect = esc_url( (string) get_permalink() );
return sprintf(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'You are already logged in.', 'unsupervised-schedular' ),
$redirect,
esc_url( $this->bookingUrl( $bookingPageId ) ?? (string) get_permalink() ),
esc_html__( 'View available lessons', 'unsupervised-schedular' )
);
}
$error = '';
$redirect = sanitize_url( (string) get_permalink() );
$redirect = sanitize_url( $this->bookingUrl( $bookingPageId ) ?? (string) get_permalink() );
if ( isset( $_POST['us_login'] ) && check_admin_referer( 'us_student_login' ) ) {
$credentials = [
@@ -48,4 +50,19 @@ class LoginPage {
include USC_PLUGIN_DIR . 'templates/frontend/login-page.php';
return (string) ob_get_clean();
}
/**
* Permalink of the configured booking page, or null when no page is
* chosen (or the chosen page no longer exists). Logged-in visitors are
* linked (and redirected after login) there instead of the current page.
*/
public function bookingUrl( int $bookingPageId ): ?string {
if ( $bookingPageId <= 0 ) {
return null;
}
$url = get_permalink( $bookingPageId );
return is_string( $url ) ? $url : null;
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ class RegistrationPage {
/**
* Renders the student registration shortcode output.
*
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
* @param array<int|string, mixed> $atts Shortcode attributes (unused — reserved for future options).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( is_user_logged_in() ) {
+153 -22
View File
@@ -32,15 +32,17 @@ class BlockRegistrar {
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) and previews it via wp.serverSideRender.
// 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-server-side-render', 'wp-i18n' ],
[ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-core-data', 'wp-server-side-render', 'wp-i18n' ],
USC_VERSION,
true
);
@@ -52,67 +54,196 @@ class BlockRegistrar {
wp_register_style( self::STYLE_HANDLE, USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );
}
foreach ( $this->blocks() as $name => $renderCallback ) {
foreach ( $this->blocks() as $name => $config ) {
register_block_type(
$name,
[
'api_version' => '3',
'editor_script' => self::SCRIPT_HANDLE,
'style' => self::STYLE_HANDLE,
'render_callback' => $renderCallback,
'attributes' => $config['attributes'],
'render_callback' => $config['render'],
]
);
}
}
/**
* Block names mapped to their render callbacks.
* 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, callable(array<string, mixed>): string>
* @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' => [ $this, 'renderBooking' ],
'us-scheduler/student-login' => [ $this, 'renderLogin' ],
'us-scheduler/student-register' => [ $this, 'renderRegistration' ],
'us-scheduler/group-classes' => [ $this, 'renderGroupClasses' ],
'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' => [],
],
'us-scheduler/group-classes' => [
'render' => [ $this, 'renderGroupClasses' ],
'attributes' => [],
],
];
}
/**
* Renders the booking block.
*
* @param array<string, mixed> $attributes Block attributes (unused — the blocks have none yet).
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderBooking( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return $this->isEditorPreview() ? BlockPreview::booking() : $this->bookingPage->render( [] );
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 (unused — the blocks have none yet).
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderLogin( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return $this->isEditorPreview() ? BlockPreview::login() : $this->loginPage->render( [] );
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 (unused — the blocks have none yet).
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderRegistration( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return $this->isEditorPreview() ? BlockPreview::registration() : $this->registrationPage->render( [] );
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 (unused — the blocks have none yet).
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderGroupClasses( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return $this->isEditorPreview() ? BlockPreview::groupClasses() : $this->groupClassPage->render( [] );
public function renderGroupClasses( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::groupClasses() : $this->groupClassPage->render( $attributes );
}
/**
* 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.
*/
public function maybeAutoRedirect(): void {
if ( is_admin() || ! is_singular() ) {
return;
}
$post = get_post();
if ( ! $post instanceof \WP_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 ) );
}
/**
* 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;
}
/**
+26 -5
View File
@@ -4,22 +4,24 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class BookingPage {
/**
* Renders the booking shortcode output.
* Renders the booking shortcode/block output.
*
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
* shortcode attributes (`login_page_id`).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
public function render( array $atts ): string {
if ( ! is_user_logged_in() ) {
$permalink = get_permalink();
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
return sprintf(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'Please', 'unsupervised-schedular' ),
esc_url( wp_login_url( false === $permalink ? '' : $permalink ) ),
esc_url( $this->loginUrl( $loginPageId ) ),
esc_html__( 'log in to book a lesson', 'unsupervised-schedular' )
);
}
@@ -35,4 +37,23 @@ class BookingPage {
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
return (string) ob_get_clean();
}
/**
* URL the logged-out prompt sends visitors to: the chosen login page when
* one is configured (and still exists), otherwise the WordPress login
* screen with a redirect back to the current page.
*/
public function loginUrl( int $loginPageId ): string {
if ( $loginPageId > 0 ) {
$url = get_permalink( $loginPageId );
if ( is_string( $url ) ) {
return $url;
}
}
$permalink = get_permalink();
return wp_login_url( false === $permalink ? '' : $permalink );
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ class GroupClassPage {
/**
* Renders the group-class enrolment shortcode output.
*
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
* @param array<int|string, mixed> $atts Shortcode attributes (unused — reserved for future options).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( ! is_user_logged_in() ) {
+16 -4
View File
@@ -19,14 +19,26 @@ class ShortcodeRegistrar {
) {}
public function register(): void {
add_shortcode( 'us_booking', [ $this->bookingPage, 'render' ] );
add_shortcode( 'us_student_login', [ $this->loginPage, 'render' ] );
add_shortcode( 'us_student_register', [ $this->registrationPage, 'render' ] );
add_shortcode( 'us_group_classes', [ $this->groupClassPage, 'render' ] );
add_shortcode( 'us_booking', self::shortcode( [ $this->bookingPage, 'render' ] ) );
add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) );
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
}
/**
* Wraps a page renderer so bare shortcode usage is safe: WordPress passes
* an empty string, not an array, to the callback when a shortcode is used
* without attributes (`shortcode_parse_atts( '' )` returns `''`).
*
* @param callable(array<int|string, mixed>): string $render
* @return \Closure(mixed): string
*/
private static function shortcode( callable $render ): \Closure {
return static fn( mixed $atts ): string => $render( is_array( $atts ) ? $atts : [] );
}
public function enqueueAssets(): void {
wp_register_style( 'us-scheduler', USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );