PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.6
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.6
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / classes / react / react-manager.php

react-manager.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.6, at classes/react/react-manager.php

743 lines 24.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * React Manager - Renders chat assistant directly in WordPress (no iframe)
4 *
5 * @package zip-ai
6 */
7
8 namespace ZipAI\MCP\Classes\React;
9
10 // Exit if accessed directly.
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 use ZipAI\MCP\Classes\Abilities\Zipai\System\PluginResolver;
16 use ZipAI\MCP\Classes\Core\Helper;
17 use ZipAI\MCP\Classes\Core\Product_Context;
18 use ZipAI\MCP\Classes\Traits\Enqueue;
19
20 /**
21 * The React_Manager Class.
22 * Handles rendering of the React chat assistant directly in WordPress.
23 */
24 class React_Manager {
25
26 use Enqueue;
27
28 /**
29 * Constructor of this class.
30 *
31 * @since 1.0.0
32 * @return void
33 */
34 public function __construct() {
35 // ZIP AI assistant is admin-only — it is NOT enqueued or rendered on the
36 // public frontend, so no floating trigger appears on the live site.
37 $this->enqueue_scripts_admin();
38 add_action( 'admin_menu', array( $this, 'register_admin_page' ) );
39 add_action( 'admin_footer', array( $this, 'render_container' ) );
40
41 // Collapse WP sidebar and tag body on the dedicated full-page screen.
42 if ( is_admin() ) {
43 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
44 if ( 'zip-ai-assistant' === $page ) {
45 add_filter( 'admin_body_class', array( $this, 'add_fullpage_body_classes' ) );
46 // Remove all admin notices on the fullpage assistant screen.
47 add_action( 'in_admin_header', array( $this, 'remove_admin_notices' ) );
48 }
49 }
50 }
51
52 /**
53 * Add body classes for the dedicated full-page assistant screen.
54 * `folded` – collapses WP admin sidebar to icon-only mode.
55 * `zip-ai-fullpage-page` – lets CSS target this page precisely.
56 *
57 * @since 1.0.0
58 * @param string $classes Existing body classes.
59 * @return string
60 */
61 public function add_fullpage_body_classes( $classes ) {
62 return $classes . ' folded zip-ai-fullpage-page';
63 }
64
65 /**
66 * Remove all admin notices on the fullpage assistant screen.
67 *
68 * @since 1.0.0
69 * @return void
70 */
71 public function remove_admin_notices() {
72 remove_all_actions( 'admin_notices' );
73 remove_all_actions( 'all_admin_notices' );
74 }
75
76 /**
77 * Register dedicated full-page assistant screen in WP admin.
78 *
79 * @since 1.0.0
80 * @return void
81 */
82 public function register_admin_page() {
83 if ( ! current_user_can( 'manage_options' ) ) {
84 return;
85 }
86
87 add_options_page(
88 __( 'ZIP AI Assistant', 'zip-ai' ),
89 __( 'ZIP AI Assistant', 'zip-ai' ),
90 'manage_options',
91 'zip-ai-assistant',
92 array( $this, 'render_fullpage_screen' )
93 );
94 }
95
96 /**
97 * Check if we should use source (non-minified) scripts.
98 *
99 * @since 1.0.0
100 * @return bool
101 */
102 private function use_source_scripts() {
103 return ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ||
104 ( defined( 'ZIPAI_MCP_DEBUG' ) && ZIPAI_MCP_DEBUG );
105 }
106
107 /**
108 * Admin enqueue callback (registered by trait).
109 *
110 * @since 1.0.0
111 * @return void
112 */
113 public function admin_enqueue_scripts() {
114 $this->enqueue_all_assets();
115 }
116
117 /**
118 * Enqueue all scripts and styles for the React chat assistant.
119 *
120 * @since 1.0.0
121 * @return void
122 */
123 private function enqueue_all_assets() {
124 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
125 return;
126 }
127
128 $use_source = $this->use_source_scripts();
129 $auth_url = $this->get_auth_url();
130
131 // ── Bridge scripts (tool hooks, context, bridge host) ──
132 if ( $use_source ) {
133 $this->enqueue_source_scripts();
134 } else {
135 $this->enqueue_minified_scripts();
136 }
137
138 // ── React app bundle (read .asset.php for React dependencies) ──
139 $asset_file = $this->build_path . 'js/dist/chat-assistant.asset.php';
140 $asset = file_exists( $asset_file ) ? require $asset_file : array( 'dependencies' => array(), 'version' => ZIPAI_MCP_VERSION );
141 $react_deps = array_merge( $asset['dependencies'], array( $this->enqueue_prefix . '-bridge-host' ) );
142
143 $this->script_operations(
144 'chat-assistant',
145 $this->build_url . 'js/dist/chat-assistant.js',
146 $react_deps,
147 array(),
148 $asset['version']
149 );
150
151 // ── React app styles ──
152 $this->style_operations(
153 'chat-assistant',
154 $this->build_url . 'css/dist/chat-assistant.css'
155 );
156
157 // ── Gutenberg editor plugin (native post/page editor only) ──
158 // Uses the same narrowed gate as `isBlockEditor` so the editor RPC
159 // handlers/sidebar plugin never load on custom block-editor screens
160 // (e.g. SureCart's page editor, the Site Editor).
161 if ( $this->is_block_editor_screen() ) {
162 $editor_file = $use_source ? 'js/editor/editor-plugin.js' : 'js/dist/zip-ai-editor.min.js';
163 // In production the quickedit.js sibling is concatenated into this
164 // bundle, so the handle must declare quickedit's deps too
165 // (wp-compose/wp-block-editor/wp-hooks/wp-api-fetch) — not rely on
166 // wp-editor's transitive graph. Matches the dev enqueue below.
167 $this->script_operations(
168 'editor-plugin',
169 $this->build_url . $editor_file,
170 array( 'wp-plugins', 'wp-element', 'wp-i18n', 'wp-components', 'wp-data', 'wp-edit-post', 'wp-editor', 'wp-compose', 'wp-block-editor', 'wp-hooks', 'wp-api-fetch' )
171 );
172
173 // ZIP AI Quick Edit block-toolbar popover. In source/dev mode it is a
174 // separate sibling script; the production build concatenates it into
175 // zip-ai-editor.min.js via Gruntfile's editor/**\/*.js glob, so it is
176 // only enqueued explicitly here for $use_source.
177 if ( $use_source ) {
178 $this->script_operations(
179 'editor-quickedit',
180 $this->build_url . 'js/editor/quickedit.js',
181 array( 'wp-element', 'wp-i18n', 'wp-components', 'wp-compose', 'wp-block-editor', 'wp-hooks', 'wp-data', 'wp-api-fetch' )
182 );
183 }
184 }
185
186 // ── Localize bridge config ──
187 $this->localize_script(
188 'tool-hooks',
189 'zipwpIframeConfig',
190 array(
191 'nonce' => wp_create_nonce( 'zip_ai_iframe' ),
192 'restNonce' => wp_create_nonce( 'wp_rest' ),
193 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
194 'displayMode' => $this->is_fullpage_screen() ? 'fullpage' : 'sidebar',
195 'adminHomeUrl' => admin_url(),
196 'userId' => get_current_user_id(),
197 'authUrl' => $auth_url,
198 'websiteContext' => array(
199 'site_url' => get_site_url(),
200 'admin_url' => admin_url(),
201 'site_title' => get_bloginfo( 'name' ),
202 'site_tagline' => get_bloginfo( 'description' ),
203 'language' => get_bloginfo( 'language' ),
204 'timezone' => wp_timezone_string(),
205 'date_format' => get_option( 'date_format' ),
206 'time_format' => get_option( 'time_format' ),
207 'is_multisite' => is_multisite(),
208 ),
209 'pageContext' => $this->get_current_page_context(),
210 'activeProduct' => Product_Context::detect(),
211 'isBlockEditor' => $this->is_block_editor_screen(),
212 'isPostEditScreen' => $this->is_post_edit_screen(),
213 'themeContext' => array(
214 'color_palette' => $this->get_theme_color_palette(),
215 ),
216 'installedPlugins' => $this->get_installed_plugins_versions(),
217 'setupGate' => $this->get_setup_gate(),
218 )
219 );
220
221 // ── Localize React app config ──
222 // `token` — Sanctum credit token, sent as `Authorization: Bearer <token>`
223 // for SaaS API auth.
224 //
225 // The WordPress Application Password header is INTENTIONALLY NOT
226 // included here. It used to be emitted as `wpAuthorizationHeader`
227 // (pre-built `Basic <b64>`) and forwarded by React to SaaS as
228 // `X-Wp-Authorization` on every chat call — which placed the raw
229 // Basic credential into the iframe's inline JS where any other
230 // script on the admin page could read `window.ZIPAI_CONFIG.*`.
231 // The credential is now delivered server-to-server via
232 // `POST /api/wp-credentials/bind` (see Helper::push_app_password_to_saas)
233 // and the SaaS reads it from the issuing Sanctum token's encrypted
234 // meta column at turn time.
235 $this->localize_script(
236 'chat-assistant',
237 'ZIPAI_CONFIG',
238 array(
239 'apiUrl' => rtrim( ZIPAI_MCP_CREDIT_SERVER_API, '/' ),
240 // Brain base URL for direct-to-brain calls (e.g. inline-edit),
241 // bypassing the Laravel relay. Set via the ZIPAI_BRAIN_URL
242 // constant (defined in loader.php); override in wp-config.php.
243 'brainUrl' => rtrim( ZIPAI_BRAIN_URL, '/' ),
244 'token' => Helper::get_decrypted_auth_token(),
245 'isAuthenticated' => Helper::is_authorized(),
246 'displayMode' => $this->is_fullpage_screen() ? 'fullpage' : 'sidebar',
247 'fullPageUrl' => admin_url( 'options-general.php?page=zip-ai-assistant' ),
248 'adminHomeUrl' => admin_url(),
249 'userId' => get_current_user_id(),
250 'domain' => wp_parse_url( home_url(), PHP_URL_HOST ),
251 'site_url' => home_url(),
252 'user' => array(
253 'id' => get_current_user_id(),
254 'email' => Helper::get_setting( 'user_email', '' ),
255 'name' => Helper::get_setting( 'user_name', '' ),
256 ),
257 'isFreshSite' => (bool) get_option( 'fresh_site', false ),
258 'nonce' => wp_create_nonce( 'zip_ai_iframe' ),
259 // wp_rest nonce — required by browser-side code that calls WP
260 // core REST endpoints using the user's session cookie (e.g.
261 // wp-bridge-host.js). Without this, calls from admin pages
262 // that don't auto-enqueue `wp-api-request` (plugins.php,
263 // themes.php, etc.) fail with `rest_cookie_invalid_nonce`.
264 'restNonce' => wp_create_nonce( 'wp_rest' ),
265 'restUrl' => esc_url_raw( rest_url() ),
266 // 'updates' nonce + admin-ajax URL — required by the setup-gate
267 // install flow (SetupGateCard.jsx), which calls WordPress core's
268 // `install-plugin` / `install-theme` admin-ajax actions directly
269 // using the user's session. Core enqueues this nonce as
270 // `_wpUpdatesSettings.ajax_nonce` only on plugin/theme admin
271 // screens, so we localize it here for every admin page where the
272 // ZipWP chat loads. (Theme/plugin lifecycle MCP tools now run
273 // server-side and do not use this.)
274 'updatesNonce' => wp_create_nonce( 'updates' ),
275 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
276 'authUrl' => $auth_url,
277 )
278 );
279 }
280
281 /**
282 * Enqueue individual source scripts for development/debugging.
283 *
284 * @since 1.0.0
285 * @return void
286 */
287 private function enqueue_source_scripts() {
288 $this->script_operations(
289 'tool-hooks',
290 $this->build_url . 'js/core/tool-hooks-registry.js',
291 array()
292 );
293
294 // tool-context-provider-registry.js was removed in the page-delivery refactor.
295 // Only enqueue if it still exists on disk; bridge-host's dependency on it
296 // is stripped below when missing.
297 $context_registry_path = $this->build_path . 'js/core/tool-context-provider-registry.js';
298 $has_context_registry = file_exists( $context_registry_path );
299 if ( $has_context_registry ) {
300 $this->script_operations(
301 'tool-context-registry',
302 $this->build_url . 'js/core/tool-context-provider-registry.js',
303 array()
304 );
305 }
306
307 $this->script_operations(
308 'block-context-picker',
309 $this->build_url . 'js/core/block-context-picker.js',
310 array( $this->enqueue_prefix . '-tool-hooks' )
311 );
312
313 $bridge_deps = array(
314 $this->enqueue_prefix . '-tool-hooks',
315 $this->enqueue_prefix . '-block-context-picker',
316 );
317 if ( $has_context_registry ) {
318 $bridge_deps[] = $this->enqueue_prefix . '-tool-context-registry';
319 }
320 $this->script_operations(
321 'popover-drag',
322 $this->build_url . 'js/core/popover-drag.js',
323 array()
324 );
325
326 $bridge_deps[] = $this->enqueue_prefix . '-popover-drag';
327
328 // Pure js_rpc dispatch-dedup decision (B-1 / P5) — a bridge dependency so
329 // the unit-tested helper (core/rpc-dedup.js) is loaded before executeTools
330 // runs. No deps of its own.
331 $this->script_operations(
332 'rpc-dedup',
333 $this->build_url . 'js/core/rpc-dedup.js',
334 array()
335 );
336 $bridge_deps[] = $this->enqueue_prefix . '-rpc-dedup';
337
338 // Tool utility modules (utils.js) define globals the bridge + React app
339 // depend on — notably window.zipwpMcpSpectraUtils, which EditorContext
340 // uses to serialize the selected block. Without it the selection carries
341 // no text and the quick-edit toolbar never renders. The grunt production
342 // bundle concatenates these ahead of the bridge (Gruntfile `main`); source
343 // mode must load them ahead of bridge-host the same way. Globbed (not a
344 // hardcoded filename) so a new tools/<ns>/utils.js auto-loads.
345 $tool_utils = glob( $this->build_path . 'js/tools/*/utils.js' ) ?: array();
346 foreach ( $tool_utils as $utils_path ) {
347 $tool_slug = basename( dirname( $utils_path ) );
348 $this->script_operations(
349 "tool-{$tool_slug}-utils",
350 $this->build_url . "js/tools/{$tool_slug}/utils.js",
351 array( $this->enqueue_prefix . '-tool-hooks' )
352 );
353 $bridge_deps[] = $this->enqueue_prefix . "-tool-{$tool_slug}-utils";
354 }
355
356 $this->script_operations(
357 'bridge-host',
358 $this->build_url . 'js/core/wp-bridge-host.js',
359 $bridge_deps
360 );
361
362 // Vibe Editing v2 — browser-native editor tools (Pattern A: brain-TS
363 // tool declaration + this JS handler, no PHP ability). Each handler
364 // self-registers with the bridge via window.zipwpMcp.registerTool, so
365 // the only dependency is bridge-host. Globbed across the whole editor/
366 // namespace, so every tool added there auto-loads — no per-tool PHP
367 // edit. In production the grunt bundle (tools/**/handler.js) already
368 // includes these; this source-mode branch is the SCRIPT_DEBUG path.
369 // Shared editor utilities — loaded BEFORE the handlers that consume them.
370 // blockFingerprint (conflict-fence hash) + currentPostId live here as the
371 // single source of truth for both get-context and apply-change, so the two
372 // can never drift. (In production the grunt bundle concatenates
373 // tools/**\/*-utils.js ahead of handler.js, so order holds there too.)
374 $this->script_operations(
375 'editor-shared-utils',
376 $this->build_url . 'js/tools/editor/shared/editor-shared-utils.js',
377 array( $this->enqueue_prefix . '-bridge-host' )
378 );
379 $editor_handlers = glob( $this->build_path . 'js/tools/editor/*/handler.js' ) ?: array();
380 foreach ( $editor_handlers as $handler_path ) {
381 $tool_slug = basename( dirname( $handler_path ) );
382 $this->script_operations(
383 "editor-{$tool_slug}-handler",
384 $this->build_url . "js/tools/editor/{$tool_slug}/handler.js",
385 array(
386 $this->enqueue_prefix . '-bridge-host',
387 $this->enqueue_prefix . '-editor-shared-utils',
388 )
389 );
390 }
391 }
392
393 /**
394 * Enqueue combined minified script for production.
395 *
396 * @since 1.0.0
397 * @return void
398 */
399 private function enqueue_minified_scripts() {
400 $this->script_operations(
401 'tool-hooks',
402 $this->build_url . 'js/dist/zip-ai.min.js',
403 array()
404 );
405
406 // Tool context registry is bundled in the same file — virtual handle.
407 $this->register_script( 'tool-context-registry', false, array( $this->enqueue_prefix . '-tool-hooks' ) );
408 $this->enqueue_script( 'tool-context-registry' );
409
410 // Bridge host is also bundled — virtual handle for dependency chain.
411 $this->register_script( 'bridge-host', false, array( $this->enqueue_prefix . '-tool-hooks' ) );
412 $this->enqueue_script( 'bridge-host' );
413 }
414
415 /**
416 * Get current page/post context from PHP.
417 *
418 * @since 1.0.0
419 * @return array
420 */
421 private function get_current_page_context() {
422 global $post;
423
424 $context = array(
425 'post_id' => null,
426 'post_type' => null,
427 'post_title' => null,
428 'post_status' => null,
429 );
430
431 if ( $post instanceof \WP_Post ) {
432 $context['post_id'] = $post->ID;
433 $context['post_type'] = $post->post_type;
434 $context['post_title'] = $post->post_title;
435 $context['post_status'] = $post->post_status;
436 return $context;
437 }
438
439 if ( is_admin() ) {
440 $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
441 if ( $post_id ) {
442 $admin_post = get_post( $post_id );
443 if ( $admin_post instanceof \WP_Post ) {
444 $context['post_id'] = $admin_post->ID;
445 $context['post_type'] = $admin_post->post_type;
446 $context['post_title'] = $admin_post->post_title;
447 $context['post_status'] = $admin_post->post_status;
448 }
449 }
450 }
451
452 return $context;
453 }
454
455 /**
456 * Authoritative server-side check for whether the current screen is the
457 * NATIVE WordPress post/page editor (post.php / post-new.php, screen base
458 * 'post'). The JS bridge falls back to this when `wp.data` /
459 * `core/block-editor` are not yet initialized at iframe boot — without
460 * this flag the brain may resolve `is_block_editor=false` on the user's
461 * first turn and surface dashboard-only tools (e.g. spawning a new page
462 * when one is already open).
463 *
464 * The `base === 'post'` guard is deliberate: custom admin screens that
465 * embed `@wordpress/block-editor` — e.g. SureCart's page editor, or the
466 * Site Editor — also report `is_block_editor() === true`, but Editor Mode
467 * tools only operate on a real WP post, so those screens must NOT count.
468 *
469 * @since 1.0.0
470 * @return bool
471 */
472 private function is_block_editor_screen() {
473 if ( ! is_admin() || ! function_exists( 'get_current_screen' ) ) {
474 return false;
475 }
476 $screen = get_current_screen();
477 if ( ! $screen || 'post' !== $screen->base ) {
478 return false;
479 }
480 return $screen->is_block_editor();
481 }
482
483 private function is_post_edit_screen() {
484 global $pagenow;
485 if ( ! is_admin() ) {
486 return false;
487 }
488 if ( 'post.php' !== $pagenow ) {
489 return false;
490 }
491 $action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
492 return 'edit' === $action;
493 }
494
495 /**
496 * Get authentication URL with CSRF protection for ZipWP OAuth callback.
497 *
498 * @since 1.0.0
499 * @return string
500 */
501 private function get_auth_url() {
502 $transient_key = 'zip_ai_oauth_state_' . get_current_user_id();
503
504 // Reuse existing state if still valid — prevents overwriting during auth popup flow.
505 $state = get_transient( $transient_key );
506 if ( empty( $state ) ) {
507 $state = wp_generate_password( 32, false );
508 set_transient( $transient_key, $state, 10 * MINUTE_IN_SECONDS );
509 }
510
511 $redirect_url = add_query_arg(
512 array(
513 'nonce' => wp_create_nonce( 'zip_ai_auth_nonce' ),
514 'state' => $state,
515 'zip-ai-auth' => 'true',
516 ),
517 admin_url( 'options-general.php?page=zip-ai-assistant' )
518 );
519
520 $auth_middleware = ZIPAI_MCP_MIDDLEWARE;
521
522 $auth_url = add_query_arg(
523 array(
524 'type' => 'token',
525 'redirect_url' => rawurlencode( $redirect_url ),
526 'state' => $state,
527 ),
528 $auth_middleware
529 );
530
531 return $auth_url;
532 }
533
534 /**
535 * Get theme color palette formatted as CSS variables.
536 *
537 * @since 1.0.0
538 * @return array
539 */
540 private function get_theme_color_palette() {
541 if ( ! function_exists( 'astra_get_palette_colors' ) ) {
542 return array();
543 }
544
545 $palette_data = astra_get_palette_colors();
546 $current_palette = $palette_data['currentPalette'] ?? '';
547 $palettes = $palette_data['palettes'] ?? array();
548
549 if ( empty( $current_palette ) || empty( $palettes[ $current_palette ] ) ) {
550 return array();
551 }
552
553 $colors = $palettes[ $current_palette ];
554 $formatted_palette = array();
555
556 foreach ( $colors as $index => $color ) {
557 $formatted_palette[ '--ast-global-color-' . $index ] = $color;
558 }
559
560 return $formatted_palette;
561 }
562
563 /**
564 * Get installed plugins with their versions.
565 *
566 * @since 1.0.0
567 * @return array
568 */
569 private function get_installed_plugins_versions() {
570 if ( ! function_exists( 'get_plugins' ) ) {
571 require_once ABSPATH . 'wp-admin/includes/plugin.php';
572 }
573
574 $all_plugins = get_plugins();
575 $plugin_versions = array();
576
577 foreach ( $all_plugins as $plugin_file => $plugin_data ) {
578 $slug = dirname( $plugin_file );
579 if ( '.' === $slug ) {
580 $slug = basename( $plugin_file, '.php' );
581 }
582 $plugin_versions[ $slug ] = $plugin_data['Version'] ?? '0.0.0';
583 }
584
585 return $plugin_versions;
586 }
587
588 /**
589 * Build the Spectra setup-gate descriptor for the React notice.
590 *
591 * ZIP AI builds on top of the Spectra plugin + Spectra One theme. When either
592 * is missing or inactive the React app shows an install notice. Returns null
593 * when both are active.
594 *
595 * @since 1.0.0
596 * @return array<string, mixed>|null
597 */
598 private function get_setup_gate() {
599 if ( ! function_exists( 'is_plugin_active' ) || ! function_exists( 'get_plugins' ) ) {
600 require_once ABSPATH . 'wp-admin/includes/plugin.php';
601 }
602
603 $plugin = $this->get_plugin_gate_item();
604 $theme = $this->get_theme_gate_item();
605 $permalink = $this->get_permalink_gate_item();
606
607 if ( $plugin['active'] && $theme['active'] && $permalink['active'] ) {
608 return null;
609 }
610
611 return array(
612 'items' => array( $plugin, $theme, $permalink ),
613 // Inline card was dismissed (X) — keep the gate so the header icon
614 // still shows, but the React app won't auto-render the inline card.
615 'inlineDismissed' => (bool) get_user_meta( get_current_user_id(), 'zip_ai_setup_gate_dismissed', true ),
616 );
617 }
618
619 /**
620 * Spectra plugin gate item (installed / active state).
621 *
622 * @since 1.0.0
623 * @return array<string, mixed>
624 */
625 private function get_plugin_gate_item() {
626 $slug = 'spectra-blocks';
627 $file = PluginResolver::resolve_plugin_file( $slug );
628
629 return array(
630 'type' => 'plugin',
631 'slug' => $slug,
632 'label' => 'Spectra',
633 'installed' => null !== $file,
634 'active' => null !== $file && is_plugin_active( $file ),
635 );
636 }
637
638 /**
639 * Spectra One theme gate item (installed / active state).
640 *
641 * @since 1.0.0
642 * @return array<string, mixed>
643 */
644 private function get_theme_gate_item() {
645 $slug = 'spectra-one';
646
647 // Accept the template too — a child theme of Spectra One counts as active.
648 // ZIP AI only needs Spectra One as the base; otherwise "Set up" would
649 // switch_theme away from the user's child theme (destructive).
650 return array(
651 'type' => 'theme',
652 'slug' => $slug,
653 'label' => 'Spectra One',
654 'installed' => wp_get_theme( $slug )->exists(),
655 'active' => get_stylesheet() === $slug || get_template() === $slug,
656 );
657 }
658
659 /**
660 * Permalink gate item (pretty permalinks enabled).
661 *
662 * The REST API the assistant talks to is unreachable under the default
663 * "Plain" permalink structure (empty option). Nothing to install — the
664 * "Set up" action sets a postname structure and flushes rewrite rules.
665 *
666 * @since 1.0.0
667 * @return array<string, mixed>
668 */
669 private function get_permalink_gate_item() {
670 return array(
671 'type' => 'permalink',
672 'slug' => 'permalink',
673 'label' => 'Pretty Permalinks',
674 'installed' => true,
675 'active' => Helper::has_pretty_permalinks(),
676 );
677 }
678
679 /**
680 * Render the assistant container HTML.
681 *
682 * @since 1.0.0
683 * @return void
684 */
685 public function render_container() {
686 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
687 return;
688 }
689
690 // On dedicated full-page screen we render a different container.
691 if ( is_admin() && $this->is_fullpage_screen() ) {
692 return;
693 }
694
695 ?>
696 <div id="zip-ai-assistant-container" class="zip-ai-iframe-container" inert>
697 <!-- React app mounts here (trigger + resize handle rendered by React via portals) -->
698 <div id="chat-assistant-root"></div>
699 </div>
700 <?php
701 }
702
703 /**
704 * Render dedicated full-page assistant admin screen.
705 *
706 * @since 1.0.0
707 * @return void
708 */
709 public function render_fullpage_screen() {
710 if ( ! current_user_can( 'manage_options' ) ) {
711 return;
712 }
713 // Note: no .wrap class — we bypass WP's default margin/padding for a true full-bleed layout.
714 ?>
715 <div class="zip-ai-fullpage-screen">
716 <div id="zip-ai-fullpage-container" class="zip-ai-fullpage-container">
717 <div id="chat-assistant-root"></div>
718 </div>
719 </div>
720 <?php
721 }
722
723 /**
724 * Check if current admin page is dedicated full-page assistant.
725 *
726 * @since 1.0.0
727 * @return bool
728 */
729 private function is_fullpage_screen() {
730 if ( ! is_admin() ) {
731 return false;
732 }
733
734 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
735 if ( $screen && 'settings_page_zip-ai-assistant' === $screen->id ) {
736 return true;
737 }
738
739 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
740 return 'zip-ai-assistant' === $page;
741 }
742 }
743