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

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