PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.11
ZIP AI – AI Website Builder & AI Agent v0.0.11
0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / react / react-manager.php

react-manager.php in ZIP AI – AI Website Builder & AI Agent 0.0.11, at inc/react/react-manager.php

656 lines 22.9 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'] ) && is_string( $_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(
141 'dependencies' => array(),
142 'version' => ZIPAI_MCP_VERSION,
143 );
144 $asset = is_array( $asset ) ? $asset : array();
145
146 $asset_deps = isset( $asset['dependencies'] ) && is_array( $asset['dependencies'] ) ? array_values( array_filter( $asset['dependencies'], 'is_string' ) ) : array();
147 $asset_version = isset( $asset['version'] ) && is_string( $asset['version'] ) ? $asset['version'] : ZIPAI_MCP_VERSION;
148 $react_deps = array_merge( $asset_deps, array( $this->enqueue_prefix . '-bridge-host' ) );
149
150 $this->script_operations(
151 'chat-assistant',
152 $this->build_url . 'js/dist/chat-assistant.js',
153 $react_deps,
154 array(),
155 $asset_version
156 );
157
158 // ── React app styles ──
159 // Version with the same build hash as the JS bundle (not the static
160 // plugin version) so a rebuild that doesn't bump ZIPAI_MCP_VERSION still
161 // busts the CSS cache. Otherwise `?ver=<plugin version>` stays identical
162 // across builds and browsers/CDNs serve a stale stylesheet against fresh
163 // JS — newly added utility classes go missing until the version bumps.
164 $this->style_operations(
165 'chat-assistant',
166 $this->build_url . 'css/dist/chat-assistant.css',
167 array(),
168 $asset_version
169 );
170
171 // ── Gutenberg editor plugin (native post/page editor only) ──
172 // Uses the same narrowed gate as `isBlockEditor` so the editor RPC
173 // handlers/sidebar plugin never load on custom block-editor screens
174 // (e.g. SureCart's page editor, the Site Editor).
175 if ( $this->is_block_editor_screen() ) {
176 $editor_file = $use_source ? 'js/editor/editor-plugin.js' : 'js/dist/zip-ai-editor.min.js';
177 // In production the quickedit.js sibling is concatenated into this
178 // bundle, so the handle must declare quickedit's deps too
179 // (wp-compose/wp-block-editor/wp-hooks/wp-api-fetch) — not rely on
180 // wp-editor's transitive graph. Matches the dev enqueue below.
181 $this->script_operations(
182 'editor-plugin',
183 $this->build_url . $editor_file,
184 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' )
185 );
186
187 // ZIP AI Quick Edit block-toolbar popover. In source/dev mode it is a
188 // separate sibling script; the production build concatenates it into
189 // zip-ai-editor.min.js via Gruntfile's editor/**\/*.js glob, so it is
190 // only enqueued explicitly here for $use_source.
191 if ( $use_source ) {
192 $this->script_operations(
193 'editor-quickedit',
194 $this->build_url . 'js/editor/quickedit.js',
195 array( 'wp-element', 'wp-i18n', 'wp-components', 'wp-compose', 'wp-block-editor', 'wp-hooks', 'wp-data', 'wp-api-fetch' )
196 );
197 }
198 }
199
200 // ── Localize bridge config ──
201 $this->localize_script(
202 'tool-hooks',
203 'zipwpIframeConfig',
204 array(
205 'nonce' => wp_create_nonce( 'zip_ai_iframe' ),
206 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
207 'displayMode' => $this->is_fullpage_screen() ? 'fullpage' : 'sidebar',
208 'adminHomeUrl' => admin_url(),
209 'userId' => get_current_user_id(),
210 'authUrl' => $auth_url,
211 'activeProduct' => Product_Context::detect(),
212 'isBlockEditor' => $this->is_block_editor_screen(),
213 'isPostEditScreen' => $this->is_post_edit_screen(),
214 'setupGate' => $this->get_setup_gate(),
215 )
216 );
217
218 // ── Localize React app config ──
219 // `token` — Sanctum credit token, sent as `Authorization: Bearer <token>`
220 // for server API auth.
221 //
222 // The WordPress Application Password header is INTENTIONALLY NOT
223 // included here. It used to be emitted as `wpAuthorizationHeader`
224 // (pre-built `Basic <b64>`) and forwarded by React to the server as
225 // `X-Wp-Authorization` on every chat call — which placed the raw
226 // Basic credential into the inline JS where any other script on the
227 // admin page could read `window.ZIPAI_CONFIG.*`. The credential is now
228 // delivered server-to-server and read from the issuing Sanctum token's
229 // encrypted meta at turn time.
230
231 // Brand context (business type/tone/description) so the chat's colour
232 // picker can generate ON-BRAND palettes. Stored by the design system as
233 // the `zip_ai_brand_context` option; may be a JSON string or an array.
234 $brand_context = get_option( 'zip_ai_brand_context', array() );
235 if ( is_string( $brand_context ) ) {
236 $brand_context = json_decode( $brand_context, true );
237 }
238 if ( ! is_array( $brand_context ) ) {
239 $brand_context = array();
240 }
241
242 // Free/guest temp-site expiry (ISO-8601 UTC). The zipwp-client mu-plugin
243 // stores it in the `zipwp_site_data` option and we suppress ZipWP's own
244 // admin-bar countdown when ZIP AI owns the surface, so the assistant
245 // surfaces the timer itself. Absent on permanent/paid sites, and
246 // suppressed once the site is reserved (no longer expiring).
247 $zipwp_site_data = get_option( 'zipwp_site_data' );
248 $expire_at = ( is_array( $zipwp_site_data ) && empty( $zipwp_site_data['reserve'] ) && ! empty( $zipwp_site_data['expire_at'] ) )
249 ? $zipwp_site_data['expire_at']
250 : null;
251
252 $this->localize_script(
253 'chat-assistant',
254 'ZIPAI_CONFIG',
255 array(
256 // Turns on the bridge/apply-change console mirror (wp-bridge-host.js,
257 // apply-change/handler.js). Those log lines were already written and
258 // gated on this flag, but nothing ever set it — so the whole tracing
259 // surface has been dead. Follows WP_DEBUG; override with the filter
260 // for a UAT run on a site that isn't in debug mode.
261 'debug' => (bool) apply_filters( 'zipai_frontend_debug', defined( 'WP_DEBUG' ) && WP_DEBUG ),
262 'apiUrl' => rtrim( ZIPAI_MCP_CREDIT_SERVER_API, '/' ),
263 // Server base URL for direct calls (e.g. inline-edit). Set via the
264 // ZIPAI_BRAIN_URL constant (defined in loader.php); override in
265 // wp-config.php.
266 'brainUrl' => rtrim( ZIPAI_BRAIN_URL, '/' ),
267 'token' => Helper::get_decrypted_auth_token(),
268 'isAuthenticated' => Helper::is_authorized(),
269 // Per-layout import impact, from the SAME source the MCP
270 // `zipai/import-html` ability discloses to AI clients — so the
271 // panel and an agent can never describe the same import
272 // differently. Sent at load: no per-selection request, one copy.
273 'importImpact' => \ZipAI\MCP\Classes\Core\Import_Impact::all(),
274 // The Connection screen's REST routes are `manage_options`-only, so
275 // the menu entry is hidden for anyone who would only get a
276 // permission error after clicking it.
277 'canManageConnection' => current_user_can( 'manage_options' ),
278 'displayMode' => $this->is_fullpage_screen() ? 'fullpage' : 'sidebar',
279 'adminHomeUrl' => admin_url(),
280 'userId' => get_current_user_id(),
281 'domain' => wp_parse_url( home_url(), PHP_URL_HOST ),
282 'site_url' => home_url(),
283 'user' => array(
284 'id' => get_current_user_id(),
285 'email' => Helper::get_setting( 'user_email', '' ),
286 'name' => Helper::get_setting( 'user_name', '' ),
287 ),
288 'expireAt' => $expire_at,
289 // Brand context for on-brand palette generation (colour picker).
290 'brandContext' => $brand_context,
291 'nonce' => wp_create_nonce( 'zip_ai_iframe' ),
292 // wp_rest nonce — required by browser-side code that calls WP
293 // core REST endpoints using the user's session cookie (e.g.
294 // wp-bridge-host.js). Without this, calls from admin pages
295 // that don't auto-enqueue `wp-api-request` (plugins.php,
296 // themes.php, etc.) fail with `rest_cookie_invalid_nonce`.
297 'restNonce' => wp_create_nonce( 'wp_rest' ),
298 'restUrl' => esc_url_raw( rest_url() ),
299 // 'updates' nonce + admin-ajax URL — required by the setup-gate
300 // install flow (SetupGateCard.jsx), which calls WordPress core's
301 // `install-plugin` / `install-theme` admin-ajax actions directly
302 // using the user's session. Core enqueues this nonce as
303 // `_wpUpdatesSettings.ajax_nonce` only on plugin/theme admin
304 // screens, so we localize it here for every admin page where the
305 // ZipWP chat loads. (Theme/plugin lifecycle MCP tools now run
306 // server-side and do not use this.)
307 'updatesNonce' => wp_create_nonce( 'updates' ),
308 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
309 )
310 );
311 }
312
313 /**
314 * Enqueue individual source scripts for development/debugging.
315 *
316 * @since 1.0.0
317 * @return void
318 */
319 private function enqueue_source_scripts() {
320 // Layout/appearance SSOT (window.ZIPWP_LAYOUT) — a dependency of
321 // popover-drag + bridge-host below, so it loads before every consumer
322 // (and before the React bundle, which depends on bridge-host).
323 $this->script_operations(
324 'layout-config',
325 $this->build_url . 'js/core/layout-config.js',
326 array()
327 );
328
329 $this->script_operations(
330 'tool-hooks',
331 $this->build_url . 'js/core/tool-hooks-registry.js',
332 array()
333 );
334
335 // tool-context-provider-registry.js was removed in the page-delivery refactor.
336 // Only enqueue if it still exists on disk; bridge-host's dependency on it
337 // is stripped below when missing.
338 $context_registry_path = $this->build_path . 'js/core/tool-context-provider-registry.js';
339 $has_context_registry = file_exists( $context_registry_path );
340 if ( $has_context_registry ) {
341 $this->script_operations(
342 'tool-context-registry',
343 $this->build_url . 'js/core/tool-context-provider-registry.js',
344 array()
345 );
346 }
347
348 $this->script_operations(
349 'block-context-picker',
350 $this->build_url . 'js/core/block-context-picker.js',
351 array( $this->enqueue_prefix . '-tool-hooks' )
352 );
353
354 $bridge_deps = array(
355 $this->enqueue_prefix . '-tool-hooks',
356 $this->enqueue_prefix . '-block-context-picker',
357 $this->enqueue_prefix . '-layout-config',
358 );
359 if ( $has_context_registry ) {
360 $bridge_deps[] = $this->enqueue_prefix . '-tool-context-registry';
361 }
362 $this->script_operations(
363 'popover-drag',
364 $this->build_url . 'js/core/popover-drag.js',
365 array( $this->enqueue_prefix . '-layout-config' )
366 );
367
368 $bridge_deps[] = $this->enqueue_prefix . '-popover-drag';
369
370 // Pure js_rpc dispatch-dedup decision (B-1 / P5) — a bridge dependency so
371 // the unit-tested helper (core/rpc-dedup.js) is loaded before executeTools
372 // runs. No deps of its own.
373 $this->script_operations(
374 'rpc-dedup',
375 $this->build_url . 'js/core/rpc-dedup.js',
376 array()
377 );
378 $bridge_deps[] = $this->enqueue_prefix . '-rpc-dedup';
379
380 // Tool utility modules (utils.js) define globals the bridge + React app
381 // depend on — notably window.zipwpMcpSpectraUtils, which EditorContext
382 // uses to serialize the selected block. Without it the selection carries
383 // no text and the quick-edit toolbar never renders. The grunt production
384 // bundle concatenates these ahead of the bridge (Gruntfile `main`); source
385 // mode must load them ahead of bridge-host the same way. Globbed (not a
386 // hardcoded filename) so a new tools/<ns>/utils.js auto-loads.
387 $tool_utils = glob( $this->build_path . 'js/tools/*/utils.js' );
388 $tool_utils = $tool_utils ? $tool_utils : array();
389 foreach ( $tool_utils as $utils_path ) {
390 $tool_slug = basename( dirname( $utils_path ) );
391 $this->script_operations(
392 "tool-{$tool_slug}-utils",
393 $this->build_url . "js/tools/{$tool_slug}/utils.js",
394 array( $this->enqueue_prefix . '-tool-hooks' )
395 );
396 $bridge_deps[] = $this->enqueue_prefix . "-tool-{$tool_slug}-utils";
397 }
398
399 $this->script_operations(
400 'bridge-host',
401 $this->build_url . 'js/core/wp-bridge-host.js',
402 $bridge_deps
403 );
404
405 // Vibe Editing v2 — browser-native editor tools (Pattern A: server-side
406 // tool declaration + this JS handler, no PHP ability). Each handler
407 // self-registers with the bridge via window.zipwpMcp.registerTool, so
408 // the only dependency is bridge-host. Globbed across the whole editor/
409 // namespace, so every tool added there auto-loads — no per-tool PHP
410 // edit. In production the grunt bundle (tools/**/handler.js) already
411 // includes these; this source-mode branch is the SCRIPT_DEBUG path.
412 // Shared editor utilities — loaded BEFORE the handlers that consume them.
413 // blockFingerprint (conflict-fence hash) + currentPostId live here as the
414 // single source of truth for both get-context and apply-change, so the two
415 // can never drift. (In production the grunt bundle concatenates
416 // tools/**\/*-utils.js ahead of handler.js, so order holds there too.)
417 $this->script_operations(
418 'editor-shared-utils',
419 $this->build_url . 'js/tools/editor/shared/editor-shared-utils.js',
420 array( $this->enqueue_prefix . '-bridge-host' )
421 );
422 $editor_handlers = glob( $this->build_path . 'js/tools/editor/*/handler.js' );
423 $editor_handlers = $editor_handlers ? $editor_handlers : array();
424 foreach ( $editor_handlers as $handler_path ) {
425 $tool_slug = basename( dirname( $handler_path ) );
426 $this->script_operations(
427 "editor-{$tool_slug}-handler",
428 $this->build_url . "js/tools/editor/{$tool_slug}/handler.js",
429 array(
430 $this->enqueue_prefix . '-bridge-host',
431 $this->enqueue_prefix . '-editor-shared-utils',
432 )
433 );
434 }
435 }
436
437 /**
438 * Enqueue combined minified script for production.
439 *
440 * @since 1.0.0
441 * @return void
442 */
443 private function enqueue_minified_scripts() {
444 $this->script_operations(
445 'tool-hooks',
446 $this->build_url . 'js/dist/zip-ai.min.js',
447 array()
448 );
449
450 // Tool context registry is bundled in the same file — virtual handle.
451 $this->register_script( 'tool-context-registry', '', array( $this->enqueue_prefix . '-tool-hooks' ) );
452 $this->enqueue_script( 'tool-context-registry' );
453
454 // Bridge host is also bundled — virtual handle for dependency chain.
455 $this->register_script( 'bridge-host', '', array( $this->enqueue_prefix . '-tool-hooks' ) );
456 $this->enqueue_script( 'bridge-host' );
457 }
458
459 /**
460 * Authoritative server-side check for whether the current screen is the
461 * NATIVE WordPress post/page editor (post.php / post-new.php, screen base
462 * 'post'). The JS bridge falls back to this when `wp.data` /
463 * `core/block-editor` are not yet initialized at iframe boot — without
464 * this flag the server may resolve `is_block_editor=false` on the user's
465 * first turn and surface dashboard-only tools (e.g. spawning a new page
466 * when one is already open).
467 *
468 * The `base === 'post'` guard is deliberate: custom admin screens that
469 * embed `@wordpress/block-editor` — e.g. SureCart's page editor, or the
470 * Site Editor — also report `is_block_editor() === true`, but Editor Mode
471 * tools only operate on a real WP post, so those screens must NOT count.
472 *
473 * @since 1.0.0
474 * @return bool
475 */
476 private function is_block_editor_screen() {
477 if ( ! is_admin() || ! function_exists( 'get_current_screen' ) ) {
478 return false;
479 }
480 $screen = get_current_screen();
481 if ( ! $screen || 'post' !== $screen->base ) {
482 return false;
483 }
484 return $screen->is_block_editor();
485 }
486
487 /**
488 * Whether the current screen is the classic post edit action (post.php?action=edit).
489 *
490 * @since 1.0.0
491 * @return bool
492 */
493 private function is_post_edit_screen() {
494 global $pagenow;
495 if ( ! is_admin() ) {
496 return false;
497 }
498 if ( 'post.php' !== $pagenow ) {
499 return false;
500 }
501 $action = isset( $_GET['action'] ) && is_string( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
502 return 'edit' === $action;
503 }
504
505 /**
506 * Get authentication URL with CSRF protection for ZipWP OAuth callback.
507 *
508 * @since 1.0.0
509 * @return string
510 */
511 private function get_auth_url() {
512 $transient_key = 'zip_ai_oauth_state_' . get_current_user_id();
513
514 // Reuse existing state if still valid — prevents overwriting during auth popup flow.
515 $state = get_transient( $transient_key );
516 if ( empty( $state ) ) {
517 $state = wp_generate_password( 32, false );
518 set_transient( $transient_key, $state, 10 * MINUTE_IN_SECONDS );
519 }
520
521 $redirect_url = add_query_arg(
522 array(
523 'nonce' => wp_create_nonce( 'zip_ai_auth_nonce' ),
524 'state' => $state,
525 'zip-ai-auth' => 'true',
526 ),
527 admin_url( 'options-general.php?page=zip-ai-assistant' )
528 );
529
530 $auth_middleware = ZIPAI_MCP_MIDDLEWARE;
531
532 $auth_url = add_query_arg(
533 array(
534 'type' => 'token',
535 'redirect_url' => rawurlencode( $redirect_url ),
536 'state' => $state,
537 'source' => 'zip-ai',
538 ),
539 $auth_middleware
540 );
541
542 return $auth_url;
543 }
544
545 /**
546 * Build the Spectra setup-gate descriptor for the React notice.
547 *
548 * ZIP AI runs on the Spectra Blocks plugin. When it is missing or inactive
549 * the React app shows a setup notice. Returns null when it is active.
550 *
551 * @since 1.0.0
552 * @return array<string, mixed>|null
553 */
554 private function get_setup_gate() {
555 if ( ! function_exists( 'is_plugin_active' ) || ! function_exists( 'get_plugins' ) ) {
556 require_once ABSPATH . 'wp-admin/includes/plugin.php';
557 }
558
559 $plugin = $this->get_plugin_gate_item();
560
561 if ( $plugin['active'] ) {
562 return null;
563 }
564
565 return array(
566 'items' => array( $plugin ),
567 // Inline card was dismissed (X) — keep the gate so the header icon
568 // still shows, but the React app won't auto-render the inline card.
569 'inlineDismissed' => (bool) get_user_meta( get_current_user_id(), 'zip_ai_setup_gate_dismissed', true ),
570 );
571 }
572
573 /**
574 * Spectra plugin gate item (installed / active state).
575 *
576 * @since 1.0.0
577 * @return array<string, mixed>
578 */
579 private function get_plugin_gate_item() {
580 $slug = 'spectra-blocks';
581 $file = PluginResolver::resolve_plugin_file( $slug );
582
583 return array(
584 'type' => 'plugin',
585 'slug' => $slug,
586 'label' => 'Spectra Blocks',
587 'installed' => null !== $file,
588 'active' => null !== $file && is_plugin_active( $file ),
589 );
590 }
591
592 /**
593 * Render the assistant container HTML.
594 *
595 * @since 1.0.0
596 * @return void
597 */
598 public function render_container() {
599 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
600 return;
601 }
602
603 // On dedicated full-page screen we render a different container.
604 if ( is_admin() && $this->is_fullpage_screen() ) {
605 return;
606 }
607
608 ?>
609 <div id="zip-ai-assistant-container" class="zip-ai-iframe-container" inert>
610 <!-- React app mounts here (trigger + resize handle rendered by React via portals) -->
611 <div id="chat-assistant-root"></div>
612 </div>
613 <?php
614 }
615
616 /**
617 * Render dedicated full-page assistant admin screen.
618 *
619 * @since 1.0.0
620 * @return void
621 */
622 public function render_fullpage_screen() {
623 if ( ! current_user_can( 'manage_options' ) ) {
624 return;
625 }
626 // Note: no .wrap class — we bypass WP's default margin/padding for a true full-bleed layout.
627 ?>
628 <div class="zip-ai-fullpage-screen">
629 <div id="zip-ai-fullpage-container" class="zip-ai-fullpage-container">
630 <div id="chat-assistant-root"></div>
631 </div>
632 </div>
633 <?php
634 }
635
636 /**
637 * Check if current admin page is dedicated full-page assistant.
638 *
639 * @since 1.0.0
640 * @return bool
641 */
642 private function is_fullpage_screen() {
643 if ( ! is_admin() ) {
644 return false;
645 }
646
647 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
648 if ( $screen && 'settings_page_zip-ai-assistant' === $screen->id ) {
649 return true;
650 }
651
652 $page = isset( $_GET['page'] ) && is_string( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
653 return 'zip-ai-assistant' === $page;
654 }
655 }
656