PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.12
ZIP AI – AI Website Builder & AI Agent v0.0.12
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.12, at inc/react/react-manager.php

657 lines 23.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'] ) && 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 'fullPageUrl' => admin_url( 'options-general.php?page=zip-ai-assistant' ),
281 'userId' => get_current_user_id(),
282 'domain' => wp_parse_url( home_url(), PHP_URL_HOST ),
283 'site_url' => home_url(),
284 'user' => array(
285 'id' => get_current_user_id(),
286 'email' => Helper::get_setting( 'user_email', '' ),
287 'name' => Helper::get_setting( 'user_name', '' ),
288 ),
289 'expireAt' => $expire_at,
290 // Brand context for on-brand palette generation (colour picker).
291 'brandContext' => $brand_context,
292 'nonce' => wp_create_nonce( 'zip_ai_iframe' ),
293 // wp_rest nonce — required by browser-side code that calls WP
294 // core REST endpoints using the user's session cookie (e.g.
295 // wp-bridge-host.js). Without this, calls from admin pages
296 // that don't auto-enqueue `wp-api-request` (plugins.php,
297 // themes.php, etc.) fail with `rest_cookie_invalid_nonce`.
298 'restNonce' => wp_create_nonce( 'wp_rest' ),
299 'restUrl' => esc_url_raw( rest_url() ),
300 // 'updates' nonce + admin-ajax URL — required by the setup-gate
301 // install flow (SetupGateCard.jsx), which calls WordPress core's
302 // `install-plugin` / `install-theme` admin-ajax actions directly
303 // using the user's session. Core enqueues this nonce as
304 // `_wpUpdatesSettings.ajax_nonce` only on plugin/theme admin
305 // screens, so we localize it here for every admin page where the
306 // ZipWP chat loads. (Theme/plugin lifecycle MCP tools now run
307 // server-side and do not use this.)
308 'updatesNonce' => wp_create_nonce( 'updates' ),
309 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
310 )
311 );
312 }
313
314 /**
315 * Enqueue individual source scripts for development/debugging.
316 *
317 * @since 1.0.0
318 * @return void
319 */
320 private function enqueue_source_scripts() {
321 // Layout/appearance SSOT (window.ZIPWP_LAYOUT) — a dependency of
322 // popover-drag + bridge-host below, so it loads before every consumer
323 // (and before the React bundle, which depends on bridge-host).
324 $this->script_operations(
325 'layout-config',
326 $this->build_url . 'js/core/layout-config.js',
327 array()
328 );
329
330 $this->script_operations(
331 'tool-hooks',
332 $this->build_url . 'js/core/tool-hooks-registry.js',
333 array()
334 );
335
336 // tool-context-provider-registry.js was removed in the page-delivery refactor.
337 // Only enqueue if it still exists on disk; bridge-host's dependency on it
338 // is stripped below when missing.
339 $context_registry_path = $this->build_path . 'js/core/tool-context-provider-registry.js';
340 $has_context_registry = file_exists( $context_registry_path );
341 if ( $has_context_registry ) {
342 $this->script_operations(
343 'tool-context-registry',
344 $this->build_url . 'js/core/tool-context-provider-registry.js',
345 array()
346 );
347 }
348
349 $this->script_operations(
350 'block-context-picker',
351 $this->build_url . 'js/core/block-context-picker.js',
352 array( $this->enqueue_prefix . '-tool-hooks' )
353 );
354
355 $bridge_deps = array(
356 $this->enqueue_prefix . '-tool-hooks',
357 $this->enqueue_prefix . '-block-context-picker',
358 $this->enqueue_prefix . '-layout-config',
359 );
360 if ( $has_context_registry ) {
361 $bridge_deps[] = $this->enqueue_prefix . '-tool-context-registry';
362 }
363 $this->script_operations(
364 'popover-drag',
365 $this->build_url . 'js/core/popover-drag.js',
366 array( $this->enqueue_prefix . '-layout-config' )
367 );
368
369 $bridge_deps[] = $this->enqueue_prefix . '-popover-drag';
370
371 // Pure js_rpc dispatch-dedup decision (B-1 / P5) — a bridge dependency so
372 // the unit-tested helper (core/rpc-dedup.js) is loaded before executeTools
373 // runs. No deps of its own.
374 $this->script_operations(
375 'rpc-dedup',
376 $this->build_url . 'js/core/rpc-dedup.js',
377 array()
378 );
379 $bridge_deps[] = $this->enqueue_prefix . '-rpc-dedup';
380
381 // Tool utility modules (utils.js) define globals the bridge + React app
382 // depend on — notably window.zipwpMcpSpectraUtils, which EditorContext
383 // uses to serialize the selected block. Without it the selection carries
384 // no text and the quick-edit toolbar never renders. The grunt production
385 // bundle concatenates these ahead of the bridge (Gruntfile `main`); source
386 // mode must load them ahead of bridge-host the same way. Globbed (not a
387 // hardcoded filename) so a new tools/<ns>/utils.js auto-loads.
388 $tool_utils = glob( $this->build_path . 'js/tools/*/utils.js' );
389 $tool_utils = $tool_utils ? $tool_utils : array();
390 foreach ( $tool_utils as $utils_path ) {
391 $tool_slug = basename( dirname( $utils_path ) );
392 $this->script_operations(
393 "tool-{$tool_slug}-utils",
394 $this->build_url . "js/tools/{$tool_slug}/utils.js",
395 array( $this->enqueue_prefix . '-tool-hooks' )
396 );
397 $bridge_deps[] = $this->enqueue_prefix . "-tool-{$tool_slug}-utils";
398 }
399
400 $this->script_operations(
401 'bridge-host',
402 $this->build_url . 'js/core/wp-bridge-host.js',
403 $bridge_deps
404 );
405
406 // Vibe Editing v2 — browser-native editor tools (Pattern A: server-side
407 // tool declaration + this JS handler, no PHP ability). Each handler
408 // self-registers with the bridge via window.zipwpMcp.registerTool, so
409 // the only dependency is bridge-host. Globbed across the whole editor/
410 // namespace, so every tool added there auto-loads — no per-tool PHP
411 // edit. In production the grunt bundle (tools/**/handler.js) already
412 // includes these; this source-mode branch is the SCRIPT_DEBUG path.
413 // Shared editor utilities — loaded BEFORE the handlers that consume them.
414 // blockFingerprint (conflict-fence hash) + currentPostId live here as the
415 // single source of truth for both get-context and apply-change, so the two
416 // can never drift. (In production the grunt bundle concatenates
417 // tools/**\/*-utils.js ahead of handler.js, so order holds there too.)
418 $this->script_operations(
419 'editor-shared-utils',
420 $this->build_url . 'js/tools/editor/shared/editor-shared-utils.js',
421 array( $this->enqueue_prefix . '-bridge-host' )
422 );
423 $editor_handlers = glob( $this->build_path . 'js/tools/editor/*/handler.js' );
424 $editor_handlers = $editor_handlers ? $editor_handlers : array();
425 foreach ( $editor_handlers as $handler_path ) {
426 $tool_slug = basename( dirname( $handler_path ) );
427 $this->script_operations(
428 "editor-{$tool_slug}-handler",
429 $this->build_url . "js/tools/editor/{$tool_slug}/handler.js",
430 array(
431 $this->enqueue_prefix . '-bridge-host',
432 $this->enqueue_prefix . '-editor-shared-utils',
433 )
434 );
435 }
436 }
437
438 /**
439 * Enqueue combined minified script for production.
440 *
441 * @since 1.0.0
442 * @return void
443 */
444 private function enqueue_minified_scripts() {
445 $this->script_operations(
446 'tool-hooks',
447 $this->build_url . 'js/dist/zip-ai.min.js',
448 array()
449 );
450
451 // Tool context registry is bundled in the same file — virtual handle.
452 $this->register_script( 'tool-context-registry', '', array( $this->enqueue_prefix . '-tool-hooks' ) );
453 $this->enqueue_script( 'tool-context-registry' );
454
455 // Bridge host is also bundled — virtual handle for dependency chain.
456 $this->register_script( 'bridge-host', '', array( $this->enqueue_prefix . '-tool-hooks' ) );
457 $this->enqueue_script( 'bridge-host' );
458 }
459
460 /**
461 * Authoritative server-side check for whether the current screen is the
462 * NATIVE WordPress post/page editor (post.php / post-new.php, screen base
463 * 'post'). The JS bridge falls back to this when `wp.data` /
464 * `core/block-editor` are not yet initialized at iframe boot — without
465 * this flag the server may resolve `is_block_editor=false` on the user's
466 * first turn and surface dashboard-only tools (e.g. spawning a new page
467 * when one is already open).
468 *
469 * The `base === 'post'` guard is deliberate: custom admin screens that
470 * embed `@wordpress/block-editor` — e.g. SureCart's page editor, or the
471 * Site Editor — also report `is_block_editor() === true`, but Editor Mode
472 * tools only operate on a real WP post, so those screens must NOT count.
473 *
474 * @since 1.0.0
475 * @return bool
476 */
477 private function is_block_editor_screen() {
478 if ( ! is_admin() || ! function_exists( 'get_current_screen' ) ) {
479 return false;
480 }
481 $screen = get_current_screen();
482 if ( ! $screen || 'post' !== $screen->base ) {
483 return false;
484 }
485 return $screen->is_block_editor();
486 }
487
488 /**
489 * Whether the current screen is the classic post edit action (post.php?action=edit).
490 *
491 * @since 1.0.0
492 * @return bool
493 */
494 private function is_post_edit_screen() {
495 global $pagenow;
496 if ( ! is_admin() ) {
497 return false;
498 }
499 if ( 'post.php' !== $pagenow ) {
500 return false;
501 }
502 $action = isset( $_GET['action'] ) && is_string( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
503 return 'edit' === $action;
504 }
505
506 /**
507 * Get authentication URL with CSRF protection for ZipWP OAuth callback.
508 *
509 * @since 1.0.0
510 * @return string
511 */
512 private function get_auth_url() {
513 $transient_key = 'zip_ai_oauth_state_' . get_current_user_id();
514
515 // Reuse existing state if still valid — prevents overwriting during auth popup flow.
516 $state = get_transient( $transient_key );
517 if ( empty( $state ) ) {
518 $state = wp_generate_password( 32, false );
519 set_transient( $transient_key, $state, 10 * MINUTE_IN_SECONDS );
520 }
521
522 $redirect_url = add_query_arg(
523 array(
524 'nonce' => wp_create_nonce( 'zip_ai_auth_nonce' ),
525 'state' => $state,
526 'zip-ai-auth' => 'true',
527 ),
528 admin_url( 'options-general.php?page=zip-ai-assistant' )
529 );
530
531 $auth_middleware = ZIPAI_MCP_MIDDLEWARE;
532
533 $auth_url = add_query_arg(
534 array(
535 'type' => 'token',
536 'redirect_url' => rawurlencode( $redirect_url ),
537 'state' => $state,
538 'source' => 'zip-ai',
539 ),
540 $auth_middleware
541 );
542
543 return $auth_url;
544 }
545
546 /**
547 * Build the Spectra setup-gate descriptor for the React notice.
548 *
549 * ZIP AI runs on the Spectra Blocks plugin. When it is missing or inactive
550 * the React app shows a setup notice. Returns null when it is active.
551 *
552 * @since 1.0.0
553 * @return array<string, mixed>|null
554 */
555 private function get_setup_gate() {
556 if ( ! function_exists( 'is_plugin_active' ) || ! function_exists( 'get_plugins' ) ) {
557 require_once ABSPATH . 'wp-admin/includes/plugin.php';
558 }
559
560 $plugin = $this->get_plugin_gate_item();
561
562 if ( $plugin['active'] ) {
563 return null;
564 }
565
566 return array(
567 'items' => array( $plugin ),
568 // Inline card was dismissed (X) — keep the gate so the header icon
569 // still shows, but the React app won't auto-render the inline card.
570 'inlineDismissed' => (bool) get_user_meta( get_current_user_id(), 'zip_ai_setup_gate_dismissed', true ),
571 );
572 }
573
574 /**
575 * Spectra plugin gate item (installed / active state).
576 *
577 * @since 1.0.0
578 * @return array<string, mixed>
579 */
580 private function get_plugin_gate_item() {
581 $slug = 'spectra-blocks';
582 $file = PluginResolver::resolve_plugin_file( $slug );
583
584 return array(
585 'type' => 'plugin',
586 'slug' => $slug,
587 'label' => 'Spectra Blocks',
588 'installed' => null !== $file,
589 'active' => null !== $file && is_plugin_active( $file ),
590 );
591 }
592
593 /**
594 * Render the assistant container HTML.
595 *
596 * @since 1.0.0
597 * @return void
598 */
599 public function render_container() {
600 if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
601 return;
602 }
603
604 // On dedicated full-page screen we render a different container.
605 if ( is_admin() && $this->is_fullpage_screen() ) {
606 return;
607 }
608
609 ?>
610 <div id="zip-ai-assistant-container" class="zip-ai-iframe-container" inert>
611 <!-- React app mounts here (trigger + resize handle rendered by React via portals) -->
612 <div id="chat-assistant-root"></div>
613 </div>
614 <?php
615 }
616
617 /**
618 * Render dedicated full-page assistant admin screen.
619 *
620 * @since 1.0.0
621 * @return void
622 */
623 public function render_fullpage_screen() {
624 if ( ! current_user_can( 'manage_options' ) ) {
625 return;
626 }
627 // Note: no .wrap class — we bypass WP's default margin/padding for a true full-bleed layout.
628 ?>
629 <div class="zip-ai-fullpage-screen">
630 <div id="zip-ai-fullpage-container" class="zip-ai-fullpage-container">
631 <div id="chat-assistant-root"></div>
632 </div>
633 </div>
634 <?php
635 }
636
637 /**
638 * Check if current admin page is dedicated full-page assistant.
639 *
640 * @since 1.0.0
641 * @return bool
642 */
643 private function is_fullpage_screen() {
644 if ( ! is_admin() ) {
645 return false;
646 }
647
648 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
649 if ( $screen && 'settings_page_zip-ai-assistant' === $screen->id ) {
650 return true;
651 }
652
653 $page = isset( $_GET['page'] ) && is_string( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
654 return 'zip-ai-assistant' === $page;
655 }
656 }
657