CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 3m6s
CI / Build Plugin Zip (pull_request) Skipped
Three registration fixes reported from live use:
- Accepting an invite now keeps the student signed in. The form was
processed inside render() during the_content, so wp_set_auth_cookie()
ran after headers were sent and the cookie never persisted — the new
student was bounced back to the logged-out registration page. The
submission is now handled on template_redirect (before output) with a
post/redirect/get, so the cookie sticks and the student lands logged in.
- The "registration is by invitation only" message is now customisable via
a new block attribute (inviteOnlyMessage / shortcode invite_only_message),
falling back to the default wording when blank.
- Account-registration questions save again. dbDelta does not reliably
relax a column from NOT NULL to NULL, so sites created before account-
scope questions kept us_questions.offering_id NOT NULL and rejected
account inserts ("Column 'offering_id' cannot be null"). A one-time,
self-healing migration (guarded by its own option, not the version gate)
re-applies the nullable definition on next load.
composer test, composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
272 lines
8.0 KiB
PHP
272 lines
8.0 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,
|
|
],
|
|
'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 {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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' );
|
|
}
|
|
}
|