PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
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 (Beta) 0.0.8, at inc/react/react-manager.php

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