`; the * SPA's index.tsx mounts into it. Initial route is determined client-side * from `window.location.hash` (docs/08 §1). * * @package Bookline */ declare( strict_types=1 ); namespace Bookline\Presentation\Admin; use Bookline\Application\BookingForm\BookingFormThemeCss; use Bookline\Application\Wizard\WizardService; use Bookline\Config\CatalogueCapacity; use Bookline\Config\InstalledEdition; use Bookline\Domain\Edition\FeatureGate; use Bookline\Domain\Edition\NamedFeatureGate; use Bookline\Infrastructure\Settings\SettingsStore; use Bookline\Plugin; use Bookline\Presentation\CommercialRuntime; /** * Renders the Bookline admin SPA shell. */ final class AdminSPA { /** * WordPress admin menu slug (`admin.php?page=`). */ public const PAGE_SLUG = 'buildler-plugin'; /** * Hook suffix WordPress assigns to the top-level page. */ public const HOOK_SUFFIX = 'toplevel_page_buildler-plugin'; /** * Hook registration. */ public static function register(): void { add_action( 'admin_enqueue_scripts', [ self::class, 'enqueue' ] ); add_filter( 'admin_body_class', [ self::class, 'body_class' ] ); } /** * Add a body class on the Bookline admin page so the full-bleed * critical CSS can target it without leaking to other WP screens. * * @param string $classes Current body class list (space-separated). * @return string Possibly extended body class list. */ public static function body_class( string $classes ): string { // admin_body_class runs on every admin page; only annotate ours. // We can't rely on get_current_screen() here because the screen // may not be set yet on early hooks, so check the request. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only on public param. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( (string) $_GET['page'] ) ) : ''; if ( self::PAGE_SLUG !== $page ) { return $classes; } return $classes . ' bookline-fullbleed'; } /** * Enqueue the React SPA on the Bookline admin page only. * * @param string $hook_suffix Current admin page hook suffix. */ public static function enqueue( string $hook_suffix ): void { // docs/09 §4: zero bytes on pages that don't render the SPA. if ( self::HOOK_SUFFIX !== $hook_suffix ) { return; } if ( ! ( current_user_can( 'bookline_manage_bookings' ) || current_user_can( 'manage_options' ) ) ) { return; } if ( ! CommercialRuntime::is_operational() ) { return; } // Always enqueue the design tokens so the admin's var(--…) // references resolve even when the JS bundle is missing or hasn't // been built yet. Critical CSS is attached with wp_add_inline_style, // not printed from render(). $tokens_css = BOOKLINE_PATH . 'assets/tokens.css'; $critical = self::critical_css(); if ( file_exists( $tokens_css ) ) { wp_enqueue_style( 'bookline-tokens', BOOKLINE_URL . 'assets/tokens.css', [], (string) filemtime( $tokens_css ) ); wp_add_inline_style( 'bookline-tokens', $critical ); } else { wp_register_style( 'bookline-admin-critical', false, [], BOOKLINE_VERSION ); wp_enqueue_style( 'bookline-admin-critical' ); wp_add_inline_style( 'bookline-admin-critical', $critical ); } $fonts_css = BOOKLINE_PATH . 'assets/fonts.css'; if ( file_exists( $fonts_css ) ) { wp_enqueue_style( 'bookline-fonts', BOOKLINE_URL . 'assets/fonts.css', [], (string) filemtime( $fonts_css ) ); } $asset_file = BOOKLINE_PATH . 'assets/dist/admin.asset.php'; if ( ! file_exists( $asset_file ) ) { // No built bundle yet — the enqueued critical CSS still styles // the shell. Localisation isn't useful without the JS. return; } $asset = include $asset_file; $deps = is_array( $asset ) && isset( $asset['dependencies'] ) && is_array( $asset['dependencies'] ) ? $asset['dependencies'] : [ 'wp-element', 'wp-api-fetch' ]; $ver = is_array( $asset ) && isset( $asset['version'] ) ? (string) $asset['version'] : (string) filemtime( $asset_file ); wp_enqueue_script( 'bookline-admin', BOOKLINE_URL . 'assets/dist/admin.js', $deps, $ver, true ); // Re-use the design tokens so the admin never ships duplicate // colour/spacing rules. admin.css is loaded with the asset. $css = BOOKLINE_PATH . 'assets/dist/admin.css'; if ( file_exists( $css ) ) { wp_enqueue_style( 'bookline-admin', BOOKLINE_URL . 'assets/dist/admin.css', [ 'bookline-tokens' ], (string) filemtime( $css ) ); } wp_set_script_translations( 'bookline-admin', 'buildler' ); self::enqueue_widget_preview_assets(); self::localize(); } /** * Load the front-end widget bundle for the form editor live preview. */ private static function enqueue_widget_preview_assets(): void { $asset_file = BOOKLINE_PATH . 'assets/dist/widget.asset.php'; if ( ! file_exists( $asset_file ) ) { return; } $asset = include $asset_file; $deps = is_array( $asset ) && isset( $asset['dependencies'] ) && is_array( $asset['dependencies'] ) ? $asset['dependencies'] : []; $version = is_array( $asset ) && isset( $asset['version'] ) ? (string) $asset['version'] : BOOKLINE_VERSION; wp_enqueue_style( 'bookline-widget-fonts', BOOKLINE_URL . 'assets/fonts.css', [], (string) filemtime( BOOKLINE_PATH . 'assets/fonts.css' ) ); $widget_css = BOOKLINE_PATH . 'assets/dist/widget.css'; if ( file_exists( $widget_css ) ) { wp_enqueue_style( 'bookline-widget', BOOKLINE_URL . 'assets/dist/widget.css', [ 'bookline-tokens', 'bookline-widget-fonts' ], (string) filemtime( $widget_css ) ); } wp_enqueue_script( 'bookline-widget', BOOKLINE_URL . 'assets/dist/widget.js', $deps, $version, true ); } /** * Pass the resolved admin config to the SPA. */ private static function localize(): void { $cfg = [ 'restRoot' => rest_url(), 'restNamespace' => 'bookline/v1', 'nonce' => wp_create_nonce( 'wp_rest' ), 'siteTimezone' => self::site_timezone(), 'locale' => get_user_locale(), 'userDisplayName' => wp_get_current_user()->display_name ?? '', 'userEmail' => wp_get_current_user()->user_email ?? '', 'currency' => self::site_currency(), 'capabilities' => self::capabilities(), 'wizardComplete' => self::wizard_complete(), 'widgetIframeUrl' => BOOKLINE_URL . 'assets/dist/widget-iframe.js', 'widgetHostUrl' => BOOKLINE_URL . 'assets/widget/mount/iframe/host-loader.js', 'appearance' => self::appearance_config(), 'edition' => self::edition_config(), ]; wp_add_inline_script( 'bookline-admin', 'window.booklineAdmin = ' . wp_json_encode( $cfg ) . ';', 'before' ); } /** * Accent and density for the admin shell (docs/22 §8). * * Compact is the default: the admin steps spacing down one notch from * the widget. Accent follows Settings › Appearance, not a hardcoded navy. * * @return array{accent: string, density: string} */ private static function appearance_config(): array { $accent = 'var(--bl-accent)'; $density = 'compact'; try { $container = Plugin::container(); if ( $container->has( SettingsStore::class ) ) { $settings = $container->get( SettingsStore::class ); if ( $settings instanceof SettingsStore ) { $accent = (string) $settings->get( 'appearance.accent' ); $density = (string) $settings->get( 'appearance.density' ); } } } catch ( \Throwable $ignored ) { unset( $ignored ); } if ( 'comfortable' !== $density ) { $density = 'compact'; } return [ 'accent' => $accent, 'density' => $density, ]; } /** * Catalogue ceilings for this plugin. 0 means unlimited. Not a licence payload. * * @return array */ private static function edition_config(): array { $cfg = [ 'id' => InstalledEdition::ID, 'services' => InstalledEdition::MAX_SERVICES, 'agents' => InstalledEdition::MAX_AGENTS, 'locations' => InstalledEdition::MAX_LOCATIONS, 'bookingForms' => InstalledEdition::MAX_BOOKING_FORMS, ]; try { $container = Plugin::container(); if ( $container->has( FeatureGate::class ) ) { $gate = $container->get( FeatureGate::class ); if ( $gate instanceof FeatureGate ) { $cfg = $gate->limits()->to_public_array(); } } if ( $container->has( NamedFeatureGate::class ) ) { $named = $container->get( NamedFeatureGate::class ); if ( $named instanceof NamedFeatureGate ) { $listed = $named->listed(); if ( is_array( $listed ) ) { $cfg['entitlements'] = $listed; } } } } catch ( \Throwable $ignored ) { unset( $ignored ); } return CatalogueCapacity::public_payload( $cfg ); } /** * Escaped attribute string for the SPA mount node. */ private static function appearance_root_html(): string { $cfg = self::appearance_config(); $attrs = BookingFormThemeCss::html_attributes( [ 'accent' => $cfg['accent'], 'density' => $cfg['density'], 'scheme' => 'light', ] ); $html = ''; foreach ( $attrs as $name => $value ) { $html .= sprintf( ' %s="%s"', esc_attr( $name ), esc_attr( $value ) ); } return $html; } /** * Whether the setup wizard has published a booking page. */ private static function wizard_complete(): bool { try { $container = Plugin::container(); if ( $container->has( WizardService::class ) ) { $wizard = $container->get( WizardService::class ); if ( $wizard instanceof WizardService ) { return $wizard->is_complete(); } } } catch ( \Throwable ) { return false; } return false; } /** * Site timezone string (IANA). Empty string falls back to UTC client-side. */ private static function site_timezone(): string { try { $container = Plugin::container(); if ( $container->has( SettingsStore::class ) ) { $settings = $container->get( SettingsStore::class ); if ( $settings instanceof SettingsStore ) { $tz = (string) $settings->get( 'general.timezone' ); if ( '' !== $tz ) { return $tz; } } } } catch ( \Throwable $e ) { unset( $e ); } $tz = wp_timezone_string(); return is_string( $tz ) ? $tz : 'UTC'; } /** * Site currency code (ISO 4217). Default GBP because admin copy is * British English (docs/22 §0). */ private static function site_currency(): string { try { $container = Plugin::container(); if ( $container->has( SettingsStore::class ) ) { $settings = $container->get( SettingsStore::class ); if ( $settings instanceof SettingsStore ) { $ccy = (string) $settings->get( 'general.currency' ); if ( '' !== $ccy ) { return strtoupper( $ccy ); } } } } catch ( \Throwable $e ) { unset( $e ); } return 'GBP'; } /** * Resolved Bookline capabilities for the current user. * * @return list */ private static function capabilities(): array { $caps = []; $user = wp_get_current_user(); foreach ( (array) $user->allcaps as $cap => $has ) { if ( $has && ( str_starts_with( (string) $cap, 'bookline_' ) || 'manage_options' === $cap ) ) { $caps[] = (string) $cap; } } return array_values( array_unique( $caps ) ); } /** * Page render callback — emits the SPA mount node. */ public static function render(): void { if ( ! ( current_user_can( 'bookline_manage_bookings' ) || current_user_can( 'manage_options' ) ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'buildler' ) ); } if ( ! CommercialRuntime::is_operational() ) { return; } printf( '
%s
', self::appearance_root_html(), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- each attribute is esc_attr()'d in the helper. esc_html__( 'Buildler admin requires JavaScript. Enable it in your browser.', 'buildler' ), esc_html__( 'Loading Buildler…', 'buildler' ) ); } /** * Critical CSS, token-driven, hand-written, no user input. * * Kept small on purpose. The full stylesheet ships via admin.css once * the bundle is built; this is the pre-hydration polish so the page * never looks unstyled. * * @return string */ private static function critical_css(): string { // Wrapped in a CDATA-like comment so the contents are never parsed // as PHP short tags or HTML by an intermediate filter. The string // is identical to the CSS that follows. return <<<'CSS' /* Bookline admin critical CSS — tokens via var(--…) only. */ /* Full-bleed: the Bookline SPA takes the whole WP-admin viewport, not the default `.wrap` 1200px box. Scoped to body.bookline-fullbleed so other WP screens keep their normal chrome. */ body.bookline-fullbleed #wpcontent { padding-left: 0; } body.bookline-fullbleed #wpfooter { display: none; } /* The SPA fills the remaining viewport; list screens scroll inside `#bl-admin-main`, not the WordPress document. Admin notices (test-mode banner) sit above the wrap and must not force a page scrollbar. */ html:has(body.bookline-fullbleed) { overflow: hidden; } body.bookline-fullbleed, body.bookline-fullbleed #wpwrap, body.bookline-fullbleed #wpcontent, body.bookline-fullbleed #wpbody { overflow: hidden; } body.bookline-fullbleed #wpwrap { height: calc(100vh - var(--admin-wp-bar)); } body.bookline-fullbleed #wpcontent, body.bookline-fullbleed #wpbody { height: 100%; } body.bookline-fullbleed #wpbody-content { display: flex; flex-direction: column; height: 100%; overflow: hidden; padding-bottom: 0; } body.bookline-fullbleed .wrap { flex: 1 1 auto; min-height: 0; max-width: none; margin: 0; padding: 0; overflow: hidden; } /* Match #wpadminbar height so fixed drawers clear the toolbar. */ body.admin-bar { --admin-wp-bar: 32px; } @media screen and (max-width: 782px) { body.admin-bar { --admin-wp-bar: 46px; } } /* The React SPA owns its own layout (sidebar + main grid). The PHP mount node is a plain block container so the SPA can fill the whole content area width — not just the `1fr` second column of an outer grid. Pre-hydration, only the loader card is visible, so no grid is needed. */ body.bookline-fullbleed #wpcontent, body.bookline-fullbleed #wpbody, body.bookline-fullbleed #wpbody-content { background: var(--paper-50); } #bookline-admin-root { display: block; width: 100%; height: 100%; overflow: hidden; } .bl-wrap { background: var(--paper-50); } /* Pre-hydration typography + colour so the loader card reads correctly. */ .bl-admin { font-family: var(--bl-font); color: var(--color-text); background: var(--color-background); margin: 0; font-size: var(--text-sm-size); line-height: var(--text-sm-line); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .bl-admin a { color: var(--ink-900); } .bl-admin h1, .bl-admin h2, .bl-admin h3 { color: var(--ink-900); font-family: var(--font-display); font-weight: 800; letter-spacing: var(--tracking-display); } /* Pre-hydration loader card. Shown only while data-spa="loading" on the mount node; cleared by index.tsx before the SPA renders. */ #bookline-admin-root:not( [data-spa="loading"] ) .bl-admin__loader { display: none !important; } .bl-card { background: var(--paper-0); border: 1px solid var(--color-border); border-radius: var(--r-md); } .bl-admin__loader { display: flex; align-items: center; gap: var(--sp-15); padding: var(--sp-20) var(--sp-30); max-width: 24rem; margin: var(--sp-60) var(--sp-30) 0; box-shadow: 0 1px 0 var(--color-border); background: var(--paper-0); border: 1px solid var(--color-border); border-radius: var(--r-md); } .bl-admin__loader-dot { display: inline-block; width: var(--sp-15); height: var(--sp-15); border-radius: var(--r-full); border: 2px solid var(--ink-100); border-top-color: var(--bl-accent); animation: bl-admin-spin 0.8s linear infinite; } @keyframes bl-admin-spin { to { transform: rotate(360deg); } } .bl-admin__loader-label { color: var(--ink-500); font-size: var(--text-sm-size); } @media (prefers-reduced-motion: reduce) { .bl-admin__loader-dot { animation: 0; } } CSS; } }