Files
unsupervised-scheduler/src/BlockRegistrar.php
T
thatguygriffandClaude Opus 5 6e3affb1cb Add an account block showing who is signed in
[us_account], or the Account block: the signed-in visitor's name, their
email, a Sign out link, and — only when the account books for someone
besides itself — the students it books for. A parent's first question on
seeing "signed in as Grace" is whether this is the account their children's
lessons are on.

Two decisions worth naming.

Signed out with no login page chosen, the block renders nothing. Its whole
subject is the person signed in, which a stranger is not, and a bare "you
are not signed in" in a site header is noise with no way to act on it. With
a login page chosen it offers a Sign in link instead. The editor preview is
populated regardless, so the block is never an invisible box to the person
placing it.

Signing out returns to the chosen login page, or to the current page when
there is none. A block meant for a header should not also navigate someone
somewhere when they use it; the login page wins when configured, because the
page they were on may well be members-only.

The name comes from UserName::format(), so the block never exposes a
username the way display_name can.

Also brings docs/features/editor-blocks.md back in step: it still described
"four shortcodes" and had never listed the family block.

Closes #142

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:42:25 -03:00

377 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Guardian\FamilyPage;
/**
* 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,
private FamilyPage $familyPage,
private AccountPage $accountPage,
) {}
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,
'lessonTypeId' => [
'type' => 'number',
'default' => 0,
],
'showTypeFilter' => [
'type' => 'boolean',
'default' => true,
],
'displayMode' => [
'type' => 'string',
'default' => BookingPage::MODE_BOTH,
],
],
],
'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,
],
],
],
'us-scheduler/family' => [
'render' => [ $this, 'renderFamily' ],
'attributes' => [
'loginPageId' => [
'type' => 'number',
'default' => 0,
],
],
],
'us-scheduler/account' => [
'render' => [ $this, 'renderAccount' ],
'attributes' => [
'loginPageId' => [
'type' => 'number',
'default' => 0,
],
],
],
];
}
/**
* Renders the booking block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderBooking( array $attributes = [] ): string {
if ( ! $this->isEditorPreview() ) {
return $this->bookingPage->render( $attributes );
}
return BlockPreview::booking( Val::string( $attributes['displayMode'] ?? BookingPage::MODE_BOTH ) );
}
/**
* 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 );
}
/**
* Renders the account (who is signed in) block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderAccount( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::account() : $this->accountPage->render( $attributes );
}
/**
* Renders the family (manage-children) block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderFamily( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::family() : $this->familyPage->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, 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' );
}
}