register_extension_hooks(); } public function load_textdomain(): void { // Register the bundled Arabic fallback for direct ZIP installs. Directory // language packs take precedence, and loading stays on the init hook. load_plugin_textdomain( 'b5-persona', false, dirname(B5TOOLS_WP_BASENAME) . '/languages' ); } public static function activate(): void { add_option(self::OPTION_API_BASE_URL, self::DEFAULT_API_BASE_URL); add_option(self::OPTION_API_KEY, ''); add_option(self::OPTION_BROWSER_PERSISTENCE, '1'); add_option(self::OPTION_RESULT_SHARING, '1'); add_option(self::OPTION_REGISTERED_RESULTS, '0'); add_option(self::OPTION_REGISTERED_RESULT_BEHAVIOR, 'show'); add_option(self::OPTION_SAVED_RESULT_PAGE_ID, '0'); add_option(self::OPTION_WC_ACCOUNT_RESULT_LINK, '0'); add_option(self::OPTION_DISPLAY_MODE, 'paged-radio'); add_option(self::OPTION_THEME, 'clean'); add_option(self::OPTION_STYLE_SOURCE, 'b5tools'); add_option(self::OPTION_SCHEME, 'light'); add_option(self::OPTION_DENSITY, 'comfortable'); add_option(self::OPTION_TYPE_GUIDE_LINK, '0'); add_option(self::OPTION_TYPE_LIBRARY_PAGE_ID, '0'); add_option(self::OPTION_TYPE_LIBRARY_CACHE, []); add_option(self::OPTION_PDF_SHOW_SITE_NAME, '1'); add_option(self::OPTION_PDF_SHOW_SITE_URL, '1'); add_option(self::OPTION_PDF_SHOW_FACETS, '1'); add_option(self::OPTION_PDF_SHOW_WHY, '1'); add_option(self::OPTION_PDF_FOOTER_NOTE, ''); add_option(self::OPTION_PDF_CTA_LABEL, ''); add_option(self::OPTION_PDF_CTA_URL, ''); add_option(self::OPTION_PDF_DESIGN, self::default_pdf_design_from_options()); add_option(self::OPTION_RESULT_CTAS, []); add_option(self::OPTION_PRODUCT_MAPPINGS, []); add_option(self::OPTION_COMPLETION_WEBHOOK_URL, ''); add_option(self::OPTION_LAST_COMPLETION_WEBHOOK, []); add_option(self::OPTION_LEAD_CAPTURE, self::default_lead_capture_settings()); add_option(self::OPTION_LAST_LEAD_WEBHOOK, []); add_option(self::OPTION_LAST_RESULT_EMAIL, []); add_option(self::OPTION_FLUENTCRM_SYNC, self::default_fluentcrm_settings()); add_option(self::OPTION_LAST_FLUENTCRM_SYNC, []); add_option(self::OPTION_WPFUSION_SYNC, self::default_wpfusion_settings()); add_option(self::OPTION_LAST_WPFUSION_SYNC, []); add_option(self::OPTION_ACCOUNT_CACHE, []); add_option(self::OPTION_SETUP_WIZARD_DISMISSED, '0'); update_option(self::OPTION_ACTIVATION_REDIRECT, '1', false); update_option(self::OPTION_VERSION, B5TOOLS_WP_VERSION, false); self::create_events_table(); self::create_results_table(); self::create_leads_table(); } public function maybe_redirect_after_activation(): void { if (!current_user_can('manage_options')) { return; } if ((string) get_option(self::OPTION_ACTIVATION_REDIRECT, '') !== '1') { return; } delete_option(self::OPTION_ACTIVATION_REDIRECT); if (wp_doing_ajax() || is_network_admin() || isset($_GET['activate-multi'])) { return; } wp_safe_redirect(admin_url('admin.php?page=b5tools-wp')); exit; } public function maybe_upgrade(): void { $installed_version = (string) get_option(self::OPTION_VERSION, ''); if ($installed_version === B5TOOLS_WP_VERSION) { return; } add_option(self::OPTION_API_BASE_URL, self::DEFAULT_API_BASE_URL); add_option(self::OPTION_API_KEY, ''); add_option(self::OPTION_BROWSER_PERSISTENCE, '1'); add_option(self::OPTION_RESULT_SHARING, '1'); add_option(self::OPTION_REGISTERED_RESULTS, '0'); add_option(self::OPTION_REGISTERED_RESULT_BEHAVIOR, 'show'); add_option(self::OPTION_SAVED_RESULT_PAGE_ID, '0'); add_option(self::OPTION_WC_ACCOUNT_RESULT_LINK, '0'); add_option(self::OPTION_DISPLAY_MODE, 'paged-radio'); add_option(self::OPTION_THEME, 'clean'); add_option(self::OPTION_STYLE_SOURCE, 'b5tools'); add_option(self::OPTION_SCHEME, 'light'); add_option(self::OPTION_DENSITY, 'comfortable'); add_option(self::OPTION_TYPE_GUIDE_LINK, '0'); add_option(self::OPTION_TYPE_LIBRARY_PAGE_ID, '0'); add_option(self::OPTION_TYPE_LIBRARY_CACHE, []); add_option(self::OPTION_PDF_SHOW_SITE_NAME, '1'); add_option(self::OPTION_PDF_SHOW_SITE_URL, '1'); add_option(self::OPTION_PDF_SHOW_FACETS, '1'); add_option(self::OPTION_PDF_SHOW_WHY, '1'); add_option(self::OPTION_PDF_FOOTER_NOTE, ''); add_option(self::OPTION_PDF_CTA_LABEL, ''); add_option(self::OPTION_PDF_CTA_URL, ''); add_option(self::OPTION_PDF_DESIGN, self::default_pdf_design_from_options()); add_option(self::OPTION_RESULT_CTAS, []); add_option(self::OPTION_PRODUCT_MAPPINGS, []); add_option(self::OPTION_COMPLETION_WEBHOOK_URL, ''); add_option(self::OPTION_LAST_COMPLETION_WEBHOOK, []); add_option(self::OPTION_LEAD_CAPTURE, self::default_lead_capture_settings()); add_option(self::OPTION_LAST_LEAD_WEBHOOK, []); add_option(self::OPTION_LAST_RESULT_EMAIL, []); add_option(self::OPTION_FLUENTCRM_SYNC, self::default_fluentcrm_settings()); add_option(self::OPTION_LAST_FLUENTCRM_SYNC, []); add_option(self::OPTION_WPFUSION_SYNC, self::default_wpfusion_settings()); add_option(self::OPTION_LAST_WPFUSION_SYNC, []); add_option(self::OPTION_ACCOUNT_CACHE, []); add_option(self::OPTION_SETUP_WIZARD_DISMISSED, '0'); delete_transient('b5tools_wp_type_library_retry'); update_option(self::OPTION_VERSION, B5TOOLS_WP_VERSION, false); self::create_events_table(); self::create_results_table(); self::create_leads_table(); } public static function events_table_name(): string { global $wpdb; return $wpdb->prefix . 'b5tools_events'; } public static function results_table_name(): string { global $wpdb; return $wpdb->prefix . 'b5tools_results'; } public static function leads_table_name(): string { global $wpdb; return $wpdb->prefix . 'b5tools_leads'; } public static function create_events_table(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::events_table_name(); $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, event varchar(48) NOT NULL, page_path varchar(300) DEFAULT '' NOT NULL, instance_id varchar(80) DEFAULT '' NOT NULL, attempt_id varchar(80) DEFAULT '' NOT NULL, visitor_type varchar(24) DEFAULT '' NOT NULL, visitor_hash varchar(255) DEFAULT '' NOT NULL, mode varchar(40) DEFAULT '' NOT NULL, theme varchar(40) DEFAULT '' NOT NULL, page_index int(10) unsigned DEFAULT NULL, page_count int(10) unsigned DEFAULT NULL, answered_count int(10) unsigned DEFAULT NULL, duration_seconds int(10) unsigned DEFAULT NULL, archetype_id varchar(80) DEFAULT '' NOT NULL, target_id varchar(100) DEFAULT '' NOT NULL, created_at datetime NOT NULL, PRIMARY KEY (id), KEY event_created (event, created_at), KEY created_at (created_at), KEY attempt_id (attempt_id), KEY visitor_type (visitor_type), KEY page_path (page_path(191)) ) {$charset_collate};"; dbDelta($sql); } public static function create_leads_table(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::leads_table_name(); $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, email varchar(190) DEFAULT '' NOT NULL, name varchar(190) DEFAULT '' NOT NULL, visitor_hash varchar(255) DEFAULT '' NOT NULL, api_key_prefix varchar(80) DEFAULT '' NOT NULL, instance_id varchar(80) DEFAULT '' NOT NULL, page_path varchar(300) DEFAULT '' NOT NULL, archetype_id varchar(80) DEFAULT '' NOT NULL, archetype_label varchar(120) DEFAULT '' NOT NULL, result_payload longtext NULL, custom_fields longtext NULL, consent_text text NULL, consent_version varchar(40) DEFAULT '' NOT NULL, created_at datetime NOT NULL, PRIMARY KEY (id), KEY email (email), KEY created_at (created_at), KEY archetype_id (archetype_id), KEY page_path (page_path(191)) ) {$charset_collate};"; dbDelta($sql); } public static function create_results_table(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::results_table_name(); $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, user_id bigint(20) unsigned NOT NULL, visitor_hash varchar(255) DEFAULT '' NOT NULL, api_key_prefix varchar(80) DEFAULT '' NOT NULL, instance_id varchar(80) DEFAULT '' NOT NULL, page_path varchar(300) DEFAULT '' NOT NULL, archetype_id varchar(80) DEFAULT '' NOT NULL, result_model_version varchar(100) DEFAULT '' NOT NULL, mini_ipip_model_version varchar(100) DEFAULT '' NOT NULL, archetype_model_version varchar(100) DEFAULT '' NOT NULL, result_variant varchar(32) DEFAULT '' NOT NULL, result_payload longtext NULL, created_at datetime NOT NULL, updated_at datetime NOT NULL, PRIMARY KEY (id), KEY user_created (user_id, updated_at), KEY user_instance_created (user_id, instance_id, updated_at), KEY archetype_id (archetype_id), KEY page_path (page_path(191)) ) {$charset_collate};"; dbDelta($sql); self::drop_results_unique_scope_index(); } protected static function drop_results_unique_scope_index(): void { global $wpdb; $table = self::results_table_name(); $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW INDEX FROM %i WHERE Key_name = %s', $table, 'user_result_scope' ) ); if ($exists) { $wpdb->query($wpdb->prepare('ALTER TABLE %i DROP INDEX user_result_scope', $table)); } } public function enqueue_admin_assets(string $hook): void { if ($hook !== 'toplevel_page_b5tools-wp' && $hook !== 'index.php') return; $this->enqueue_shared_fonts(); wp_enqueue_style('b5tools-wp-admin', B5TOOLS_WP_URL . 'assets/css/admin.css', [], B5TOOLS_WP_VERSION); if ($hook !== 'toplevel_page_b5tools-wp') return; wp_enqueue_script('b5tools-wp-admin', B5TOOLS_WP_URL . 'assets/js/admin.js', ['wp-i18n'], B5TOOLS_WP_VERSION, true); wp_set_script_translations('b5tools-wp-admin', 'b5-persona', B5TOOLS_WP_DIR . 'languages'); wp_enqueue_style('b5tools-wp-pro-showcase', B5TOOLS_WP_URL . 'assets/css/pro-showcase.css', ['b5tools-wp-admin'], B5TOOLS_WP_VERSION); wp_enqueue_script('b5tools-wp-pro-showcase', B5TOOLS_WP_URL . 'assets/js/pro-showcase.js', ['wp-i18n'], B5TOOLS_WP_VERSION, true); wp_set_script_translations('b5tools-wp-pro-showcase', 'b5-persona', B5TOOLS_WP_DIR . 'languages'); $this->enqueue_extension_admin_assets(); } public function enqueue_admin_menu_assets(): void { wp_register_style('b5tools-wp-admin-menu', false, [], B5TOOLS_WP_VERSION); wp_enqueue_style('b5tools-wp-admin-menu'); wp_add_inline_style( 'b5tools-wp-admin-menu', '#adminmenu #toplevel_page_b5tools-wp .wp-menu-image {' . 'background-image: url("' . $this->menu_icon() . '");' . 'background-position: center; background-repeat: no-repeat; background-size: 20px auto;' . '}' ); } public function register_dashboard_widget(): void { if (!current_user_can('manage_options')) { return; } wp_add_dashboard_widget( 'b5tools_wp_activity', __('B5Tools activity', 'b5-persona'), [$this, 'render_dashboard_widget'] ); } public function add_settings_page(): void { add_menu_page( __('B5 Persona', 'b5-persona'), __('B5 Persona', 'b5-persona'), 'manage_options', 'b5tools-wp', [$this, 'render_settings_page'], // 'none' keeps the wp-menu-image div empty and opts out of core's // svg-painter recoloring; the colored logo is applied via CSS in // enqueue_admin_menu_assets(). 'none', 99 ); } public function register_settings(): void { if ($this->manual_api_key_entry_enabled()) { register_setting('b5tools_wp_connection_settings', self::OPTION_API_KEY, [ 'type' => 'string', 'sanitize_callback' => [$this, 'sanitize_api_key'], 'default' => '', ]); } if (in_array('slider', $this->enabled_modes(), true)) { register_setting('b5tools_wp_display_settings', self::OPTION_DISPLAY_MODE, [ 'type' => 'string', 'sanitize_callback' => [$this, 'sanitize_display_mode'], 'default' => 'paged-radio', ]); } if (in_array('editorial', $this->enabled_themes(), true)) { register_setting('b5tools_wp_display_settings', self::OPTION_THEME, [ 'type' => 'string', 'sanitize_callback' => [$this, 'sanitize_theme'], 'default' => 'clean', ]); } register_setting('b5tools_wp_display_settings', self::OPTION_STYLE_SOURCE, [ 'type' => 'string', 'sanitize_callback' => [$this, 'sanitize_style_source'], 'default' => 'b5tools', ]); register_setting('b5tools_wp_display_settings', self::OPTION_SCHEME, [ 'type' => 'string', 'sanitize_callback' => [$this, 'sanitize_scheme'], 'default' => 'light', ]); if (in_array('compact', $this->enabled_densities(), true)) { register_setting('b5tools_wp_display_settings', self::OPTION_DENSITY, [ 'type' => 'string', 'sanitize_callback' => [$this, 'sanitize_density'], 'default' => 'comfortable', ]); } register_setting('b5tools_wp_display_settings', self::OPTION_TYPE_GUIDE_LINK, [ 'type' => 'string', 'sanitize_callback' => [$this, 'sanitize_checkbox'], 'default' => '0', ]); register_setting('b5tools_wp_display_settings', self::OPTION_TYPE_LIBRARY_PAGE_ID, [ 'type' => 'integer', 'sanitize_callback' => 'absint', 'default' => 0, ]); register_setting('b5tools_wp_privacy_settings', self::OPTION_BROWSER_PERSISTENCE, [ 'type' => 'string', 'sanitize_callback' => [$this, 'sanitize_checkbox'], 'default' => '1', ]); register_setting('b5tools_wp_privacy_settings', self::OPTION_RESULT_SHARING, [ 'type' => 'string', 'sanitize_callback' => [$this, 'sanitize_checkbox'], 'default' => '1', ]); register_setting('b5tools_wp_privacy_settings', self::OPTION_DISPLAY_CREDIT, [ 'type' => 'string', 'sanitize_callback' => [$this, 'sanitize_checkbox'], 'default' => '0', ]); $this->register_extension_settings(); } public function sanitize_api_base_url($value): string { $value = is_string($value) ? trim($value) : ''; if ($value === '') { return self::DEFAULT_API_BASE_URL; } return rtrim(esc_url_raw($value), '/'); } public function sanitize_api_key($value): string { $value = is_string($value) ? trim($value) : ''; if ($value === '') { return (string) get_option(self::OPTION_API_KEY, ''); } $next = sanitize_text_field($value); if ($next !== (string) get_option(self::OPTION_API_KEY, '')) { delete_option(self::OPTION_ACCOUNT_CACHE); delete_option(self::OPTION_LAST_DIAGNOSTIC); } return $next; } public function sanitize_checkbox($value): string { return $value ? '1' : '0'; } protected static function default_pdf_sections(): array { return [ ['id' => 'hero', 'on' => true], ['id' => 'lead', 'on' => true], ['id' => 'traits', 'on' => true], ['id' => 'facets', 'on' => true], ['id' => 'why', 'on' => true], ['id' => 'cta', 'on' => false], ['id' => 'footer', 'on' => true], ]; } protected static function default_pdf_design_from_options(): array { $sections = self::default_pdf_sections(); foreach ($sections as &$section) { if ($section['id'] === 'facets') { $section['on'] = (string) get_option(self::OPTION_PDF_SHOW_FACETS, '1') === '1'; } if ($section['id'] === 'why') { $section['on'] = (string) get_option(self::OPTION_PDF_SHOW_WHY, '1') === '1'; } if ($section['id'] === 'cta') { $section['on'] = (string) get_option(self::OPTION_PDF_CTA_LABEL, '') !== '' || (string) get_option(self::OPTION_PDF_CTA_URL, '') !== ''; } } unset($section); return [ 'v' => 1, 'template' => 'classic', 'background' => [ 'preset' => 'header-band', 'intensity' => 'subtle', ], 'brand' => [ 'logo' => '', 'logoPos' => 'header-center', 'logoSize' => 'm', 'colorMode' => 'archetype', 'brandColor' => '#1c2233', ], 'typography' => 'sans', 'sections' => $sections, 'footer' => [ 'siteName' => (string) get_option(self::OPTION_PDF_SHOW_SITE_NAME, '1') === '1', 'siteUrl' => (string) get_option(self::OPTION_PDF_SHOW_SITE_URL, '1') === '1', 'note' => self::text_slice(sanitize_text_field((string) get_option(self::OPTION_PDF_FOOTER_NOTE, '')), 0, 160), 'ctaLabel' => self::text_slice(sanitize_text_field((string) get_option(self::OPTION_PDF_CTA_LABEL, '')), 0, 60), 'ctaUrl' => self::text_slice(esc_url_raw((string) get_option(self::OPTION_PDF_CTA_URL, '')), 0, 300), ], ]; } protected static function default_product_mappings(): array { return [ 'default' => [ 'productIds' => [], 'categoryId' => 0, 'buttonLabel' => '', ], 'openIn' => 'same', 'overrides' => [], ]; } protected function product_mappings_use_current_schema(array $value): bool { return array_key_exists('default', $value) || array_key_exists('openIn', $value) || array_key_exists('overrides', $value); } protected function legacy_product_mappings_to_schema(array $value): array { $clean = self::default_product_mappings(); $legacy = []; $targets = []; $archetype_ids = array_keys($this->archetype_meta()); foreach ($archetype_ids as $archetype_id) { $row = isset($value[$archetype_id]) && is_array($value[$archetype_id]) ? $value[$archetype_id] : []; $ids = $this->sanitize_product_id_list($row['productIds'] ?? []); if (!$ids) { continue; } $target = isset($row['target']) ? sanitize_key((string) $row['target']) : 'same'; $target = in_array($target, ['same', 'new'], true) ? $target : 'same'; $legacy[$archetype_id] = [ 'productIds' => $ids, 'target' => $target, ]; $targets[] = $target; } if (!$legacy) { return $clean; } $target_values = array_values(array_unique($targets)); $clean['openIn'] = count($target_values) === 1 ? $target_values[0] : 'same'; $signatures = []; foreach ($legacy as $row) { $signatures[] = implode(',', $row['productIds']) . '|' . $row['target']; } $signatures = array_unique($signatures); if (count($legacy) === count($archetype_ids) && count($signatures) === 1) { $first = reset($legacy); $clean['default']['productIds'] = $first['productIds']; return $clean; } foreach ($legacy as $archetype_id => $row) { $clean['overrides'][$archetype_id] = [ 'mode' => 'custom', 'productIds' => $row['productIds'], 'target' => $row['target'], ]; } return $clean; } protected static function default_lead_capture_settings(): array { return [ 'mode' => 'off', 'title' => __('Get follow-up resources for your result', 'b5-persona'), 'description' => __('Leave your email and we will send relevant next steps for this result.', 'b5-persona'), 'buttonLabel' => __('Send me the resources', 'b5-persona'), 'consentText' => __('I agree to be contacted about this result and related resources.', 'b5-persona'), 'consentVersion' => 'v1', 'emailResult' => false, 'emailSubject' => __('Your {result} result', 'b5-persona'), 'emailIntro' => __('Here is a copy of the result you requested.', 'b5-persona'), 'fields' => [], ]; } protected static function default_fluentcrm_settings(): array { return [ 'enabled' => false, 'status' => 'subscribed', 'baseTags' => __('B5Tools lead', 'b5-persona'), 'resultTagPrefix' => __('B5Tools result', 'b5-persona'), 'resultTags' => true, 'doubleOptIn' => false, ]; } protected static function default_wpfusion_settings(): array { return [ 'enabled' => false, 'baseTags' => __('B5Tools lead', 'b5-persona'), 'resultTagPrefix' => __('B5Tools result', 'b5-persona'), 'resultTags' => true, 'updateExisting' => true, ]; } protected function sanitize_choice($value, array $allowed, string $fallback): string { $value = is_scalar($value) ? sanitize_key((string) $value) : ''; return in_array($value, $allowed, true) ? $value : $fallback; } public function sanitize_display_mode($value): string { $value = is_string($value) ? sanitize_text_field($value) : 'paged-radio'; return in_array($value, $this->enabled_modes(), true) ? $value : 'paged-radio'; } public function sanitize_theme($value): string { $value = is_string($value) ? sanitize_text_field($value) : 'clean'; return in_array($value, $this->enabled_themes(), true) ? $value : 'clean'; } public function sanitize_style_source($value): string { $value = is_string($value) ? sanitize_key($value) : 'b5tools'; return in_array($value, self::STYLE_SOURCES, true) ? $value : 'b5tools'; } public function sanitize_scheme($value): string { $value = is_string($value) ? sanitize_text_field($value) : 'light'; return in_array($value, $this->enabled_schemes(), true) ? $value : 'light'; } public function sanitize_density($value): string { $value = is_string($value) ? sanitize_text_field($value) : 'comfortable'; return in_array($value, $this->enabled_densities(), true) ? $value : 'comfortable'; } public function render_api_key_field(): void { $has_key = $this->has_api_key(); if ($has_key) { echo '

' . wp_kses($this->icon('check-circle'), self::svg_allowed_html()) . ' ' . esc_html__('Saved key:', 'b5-persona') . ' ' . esc_html($this->masked_api_key()) . '

'; } else { echo '

' . wp_kses($this->icon('key'), self::svg_allowed_html()) . ' ' . esc_html__('No API key saved yet.', 'b5-persona') . '

'; } printf( '', esc_attr(self::OPTION_API_KEY), esc_attr($has_key ? __('Paste a replacement API key', 'b5-persona') : __('Paste API key', 'b5-persona')) ); $hint = $has_key ? __('Leave blank to keep the saved key. Manual replacement is intended for development and support recovery only.', 'b5-persona') : __('Manual entry is intended for development and support recovery only. The key is stored in WordPress and sent only from the server.', 'b5-persona'); echo '

' . esc_html($hint) . '

'; } public function render_settings_page(): void { if (!current_user_can('manage_options')) { return; } $tab = $this->current_settings_tab(); $connection_notice = null; if ($tab === 'connection' && $this->has_api_key()) { $connection_notice = $this->refresh_connection_status_for_admin(); } $notice = get_transient($this->admin_notice_transient_key()); if ($notice) { delete_transient($this->admin_notice_transient_key()); } if (is_array($connection_notice)) { $notice = $connection_notice; } if (!is_array($notice) && !empty($_GET['settings-updated'])) { $notice = [ 'type' => 'success', 'message' => __('Settings saved.', 'b5-persona'), ]; } $state = $this->connection_state(); $tab_icons = [ 'connection' => 'link', 'display' => 'monitor', 'pdf' => 'file', 'convert' => 'tag', 'automate' => 'zap', 'analytics' => 'chart', 'privacy' => 'shield', 'pro' => 'layers', ]; ?>
logo_svg(), self::svg_allowed_html()); ?>

connection_state_label($state)); ?> admin_version_label()); ?>

render_connection_retry(); ?>

account_config(); ?> has_api_key() && $account !== [] && !is_array($connection_notice)) : ?> pro_addon_available() && $this->verified_pro_access($account) && !empty($account['entitlement']['proDownloadEligible'])) : ?>

pro_addon_available() && !$this->verified_pro_access($account)) : ?>

should_show_setup_wizard()) : ?> render_setup_wizard(); ?>
render_display_tab(); } elseif ($tab === 'analytics') { $this->render_analytics_tab(); } elseif ($tab === 'privacy') { $this->render_privacy_tab(); } elseif ($tab === 'connection') { $this->render_connection_tab(); } else { $this->render_extension_tab($tab); } ?>
has_api_key()) { return false; } if ($this->query_text('setup') === '1') { return true; } return (string) get_option(self::OPTION_SETUP_WIZARD_DISMISSED, '0') !== '1'; } protected function render_setup_wizard(): void { $site_host = $this->site_host(); $site_origin = $this->site_origin(); ?>

icon('check-circle'), self::svg_allowed_html()); ?>
has_api_key(); $show_manual_key = $this->manual_api_key_entry_enabled(); $state = $this->connection_state(); $account = $this->account_config(); $connected_site_host = isset($account['connectedSite']['primaryHost']) ? trim((string) $account['connectedSite']['primaryHost']) : ''; $is_site_scoped = $connected_site_host !== ''; $suspension_message = $this->account_suspension_message($account); if ($state === 'pending') { ?>
render_account_summary(); ?> render_connection_updates_card(); ?>
render_account_summary(); } ?>
icon('zap'), self::svg_allowed_html()); ?>

[b5tools_assessment]

[b5tools_personality_types]

[b5tools_assessment override_display="1" scheme="dark"]

render_extension_shortcodes(); ?>
icon('key'), self::svg_allowed_html()); ?>
render_manual_api_key_panel(); ?>
id="b5tools-system-status"> icon('activity'), self::svg_allowed_html()); ?>
render_diagnostics_tab(); ?>
has_api_key(); ?>
icon('key'), self::svg_allowed_html()); ?>

render_api_key_field(); ?>
icon('activity'), self::svg_allowed_html()); ?>

enabled_modes(); $locked_modes = $this->locked_modes(); $enabled_themes = $this->enabled_themes(); $locked_themes = $this->locked_themes(); $enabled_schemes = $this->enabled_schemes(); $locked_schemes = $this->locked_schemes(); $enabled_densities = $this->enabled_densities(); $locked_densities = $this->locked_densities(); ?>
icon('monitor'), self::svg_allowed_html()); ?>

render_mode_option( 'paged-radio', __('Paged radio', 'b5-persona'), __('Five questions per page with Likert choices. Works everywhere.', 'b5-persona'), $enabled_modes, $locked_modes ); if (in_array('slider', $enabled_modes, true)) { $this->render_mode_option( 'slider', __('Slider', 'b5-persona'), __('One statement at a time with a smooth slider control.', 'b5-persona'), $enabled_modes, $locked_modes ); } ?>
has_api_key() || $this->account_config() === []) : ?>

icon('eye'), self::svg_allowed_html()); ?>

render_theme_option( 'clean', __('Clean', 'b5-persona'), __('Bright, airy layout that fits most sites.', 'b5-persona'), $enabled_themes, $locked_themes ); if (in_array('editorial', $enabled_themes, true)) { $this->render_theme_option( 'editorial', __('Editorial', 'b5-persona'), __('Serif headings, hairline rules, and a paper feel for content-first sites.', 'b5-persona'), $enabled_themes, $locked_themes ); } ?>
icon('eye'), self::svg_allowed_html()); ?>

render_appearance_segment_row( self::OPTION_STYLE_SOURCE, $this->style_source(), __('Style source', 'b5-persona'), __('Use the B5Tools palette or borrow the site theme colors where WordPress exposes them.', 'b5-persona'), [ 'b5tools' => __('B5Tools', 'b5-persona'), 'wp-theme' => __('WP theme', 'b5-persona'), ], self::STYLE_SOURCES, [] ); $this->render_appearance_segment_row( self::OPTION_SCHEME, $this->scheme(), __('Color scheme', 'b5-persona'), __('Used with B5Tools styling. WP theme styling follows the active WordPress theme instead.', 'b5-persona'), [ 'light' => __('Light', 'b5-persona'), 'dark' => __('Dark', 'b5-persona'), ], $enabled_schemes, $locked_schemes, 'b5tools-set-row--scheme', $this->style_source() === 'wp-theme' ); if (in_array('compact', $enabled_densities, true)) { $this->render_appearance_segment_row( self::OPTION_DENSITY, $this->density(), __('Density', 'b5-persona'), __('How much room the assessment takes up.', 'b5-persona'), [ 'comfortable' => __('Comfortable', 'b5-persona'), 'compact' => __('Compact', 'b5-persona'), ], $enabled_densities, $locked_densities ); } $this->render_appearance_segment_row( self::OPTION_TYPE_GUIDE_LINK, $this->type_guide_link_enabled() ? '1' : '0', __('Personality type guide', 'b5-persona'), __('Add one small link beneath the result introduction. It opens the matching guide on your selected library page, or B5Tools.ly as a fallback.', 'b5-persona'), [ '0' => __('Hide', 'b5-persona'), '1' => __('Show', 'b5-persona'), ], ['0', '1'], [] ); ?>
self::OPTION_TYPE_LIBRARY_PAGE_ID, 'id' => 'b5tools-type-library-page', 'selected' => $this->type_library_page_id(), 'show_option_none' => __('Use B5Tools.ly guides', 'b5-persona'), 'option_none_value' => '0', 'class' => 'b5tools-input', 'echo' => 0, ]); ?>
pro_addon_available()) $this->render_pro_context(__('Give each question room.', 'b5-persona'), __('Pro adds one-question-at-a-time sliders, the Editorial style, and compact spacing.', 'b5-persona'), 'display'); } /** * Standard status chip used across cards and the integration list. * Variants: ok (green), warn (amber), off (muted outline), muted, pro. */ protected function status_chip(string $label, string $variant = 'muted'): string { $allowed = ['ok', 'warn', 'off', 'muted', 'pro']; if (!in_array($variant, $allowed, true)) { $variant = 'muted'; } return '' . '' . '' . esc_html($label) . '' . ''; } protected function render_mode_option(string $value, string $label, string $description, array $enabled, array $locked): void { $is_disabled = !in_array($value, $enabled, true); $tag = $is_disabled ? 'div' : 'label'; ?> < class="b5tools-option"> display_mode(), $value); ?> /> mode_preview_svg($value), self::svg_allowed_html()); ?> render_option_upgrade_link(); ?> > < class="b5tools-option"> theme(), $value); ?> /> theme_preview_svg($value, $this->display_mode()), self::svg_allowed_html()); ?> render_option_upgrade_link(); ?> > < class="b5tools-option"> /> render_option_upgrade_link(); ?> >
>
$choice_label) : $value = (string) $value; $is_plan_disabled = !in_array($value, $enabled, true); $is_disabled = $context_disabled || $is_plan_disabled; $is_locked = in_array($value, $locked, true); ?>
icon('lock'), self::svg_allowed_html()); ?> icon('external'), self::svg_allowed_html()); ?> render_extension_privacy(); if (!$this->pro_addon_available()) $this->render_pro_context(__('Save results for signed-in members', 'b5-persona'), __('Pro can keep result history for signed-in WordPress users, under your site’s privacy settings.', 'b5-persona'), 'privacy'); } protected function render_diagnostics_tab(): void { $diagnostic = get_option(self::OPTION_LAST_DIAGNOSTIC, null); $last_sync = get_option(self::OPTION_LAST_EVENT_SYNC, null); $account = $this->account_config(); ?>
icon('activity'), self::svg_allowed_html()); ?>

render_empty_state(__('No diagnostics yet. Connect this site before running a connection test.', 'b5-persona')); ?>
icon('info'), self::svg_allowed_html()); ?>

pro_addon_available() && defined('B5TOOLS_WP_PRO_VERSION')) : ?>
get_api_base_url()); ?>
site_host()); ?>
...
allowed_domains_label($account)); ?>
icon('activity'), self::svg_allowed_html()); ?>

account_config(); $state = $this->connection_state(); $entitlement = isset($account['entitlement']) && is_array($account['entitlement']) ? $account['entitlement'] : []; $key_environment = isset($account['apiKey']['environment']) ? sanitize_key((string) $account['apiKey']['environment']) : 'live'; $is_development = $key_environment === 'test'; $quota_used = $is_development ? (isset($entitlement['developmentQuotaUsed']) ? absint($entitlement['developmentQuotaUsed']) : 0) : (isset($entitlement['quotaUsed']) ? absint($entitlement['quotaUsed']) : 0); $monthly_quota = $is_development ? (isset($entitlement['developmentMonthlyQuota']) ? absint($entitlement['developmentMonthlyQuota']) : 0) : (isset($entitlement['monthlyQuota']) ? absint($entitlement['monthlyQuota']) : 0); $quota_percent = $monthly_quota > 0 ? min(100, (int) round(($quota_used / $monthly_quota) * 100)) : 0; if ($state === 'suspended') { $title = __('Account suspended', 'b5-persona'); $message = $this->account_suspension_message($account); } elseif ($state === 'connected') { $title = $is_development ? __('Development site connected', 'b5-persona') : __('Site connected', 'b5-persona'); $message = $is_development ? __('This installation uses a separate test credential and development allowance.', 'b5-persona') : __('B5Tools is ready to use on this WordPress site.', 'b5-persona'); } elseif ($state === 'attention') { if ($this->account_connection_requires_reconnect($account)) { $title = __('Reconnect this site', 'b5-persona'); $message = __('The saved key works, but it is not secured to this WordPress domain.', 'b5-persona'); } else { $title = __('Connection needs attention', 'b5-persona'); $message = __('B5Tools could not verify the saved site connection.', 'b5-persona'); } } elseif ($state === 'pending') { $title = __('One step left', 'b5-persona'); $message = __('The connection is saved. Run a connection test to finish loading the account.', 'b5-persona'); } else { $title = __('Connect this WordPress site', 'b5-persona'); $message = __('Link this site to your B5Tools account. You will return here automatically.', 'b5-persona'); } ?>
icon($state === 'connected' ? 'check-circle' : ($state === 'suspended' ? 'shield' : 'link')), self::svg_allowed_html()); ?>

icon('globe'), self::svg_allowed_html()); ?> site_host() ); ?> 0) : ?>

analytics_summary(30); ?>

analytics_days_from_request(); $summary = $this->analytics_summary($days); echo '

' . esc_html__('Insights', 'b5-persona') . '

' . esc_html__('Activity on this site.', 'b5-persona') . '

'; $this->render_analytics_range_control($days); echo '
'; foreach ([[__('Views', 'b5-persona'),'views','eye'],[__('Starts', 'b5-persona'),'starts','play'],[__('Completions', 'b5-persona'),'completions','check-circle'],[__('Result views', 'b5-persona'),'result_views','zap']] as $metric) $this->render_metric_card($metric[0], $summary[$metric[1]], $metric[2]); $rate = $summary['starts'] ? round($summary['completions'] / $summary['starts'] * 100) : 0; $this->render_metric_card(__('Completion rate', 'b5-persona'), $rate . '%', 'percent'); echo '
'; $this->render_pro_context(__('See the patterns behind the completions.', 'b5-persona'), __('Pro adds result distribution, drop-off, click reporting and CSV exports.', 'b5-persona'), 'analytics'); } protected function render_analytics_range_control(int $days): void { $ranges = [ 7 => __('7 days', 'b5-persona'), 30 => __('30 days', 'b5-persona'), 90 => __('90 days', 'b5-persona'), 365 => __('12 months', 'b5-persona'), ]; ?>
$label) : ?> >
icon($icon), self::svg_allowed_html()); ?>

render_empty_state(__('No activity recorded yet. Stats appear once visitors open an assessment.', 'b5-persona')); return; } $max = max($views, $starts, $completions, 1); $steps = [ ['label' => __('Views', 'b5-persona'), 'count' => $views], ['label' => __('Starts', 'b5-persona'), 'count' => $starts], ['label' => __('Completions', 'b5-persona'), 'count' => $completions], ]; echo '
'; foreach ($steps as $index => $step) { $width = $step['count'] > 0 ? max(3, (int) round(($step['count'] / $max) * 100)) : 0; $share = $views > 0 ? (int) round(($step['count'] / $views) * 100) : 0; ?>
'; } protected function render_donut(array $items, string $empty_message): void { if ($items === []) { $this->render_empty_state($empty_message); return; } $total = 0; foreach ($items as $item) { $total += (int) $item['count']; } $total = max(1, $total); $colors = $this->chart_colors(); $offset = 25.0; $segments = ''; foreach ($items as $index => $item) { $dash = ((int) $item['count'] / $total) * 100; $segments .= sprintf( '', esc_attr($colors[$index % count($colors)]), esc_attr(number_format($dash, 3, '.', '')), esc_attr(number_format(100 - $dash, 3, '.', '')), esc_attr(number_format($offset, 3, '.', '')) ); $offset -= $dash; } ?>
render_empty_state($empty_message); return; } $meta = $archetype ? $this->archetype_meta() : []; $max = 1; foreach ($items as $item) { $max = max($max, (int) $item['count']); } echo '
'; foreach ($items as $item) { $count = (int) $item['count']; $width = max(3, (int) round(($count / $max) * 100)); $raw = (string) $item['label']; $accent = ''; if ($archetype) { $label = isset($meta[$raw]) ? $meta[$raw]['label'] : ucfirst($raw); $accent = isset($meta[$raw]) ? $meta[$raw]['accent'] : '#98a2b3'; } else { $label = $raw; } ?>
'; } /** * Admin-side display metadata for archetype ids. Mirrors the platform * result-presentation accents; labels derive from public archetype ids. */ protected function archetype_meta(): array { return [ 'connector' => ['label' => __('Connector', 'b5-persona'), 'accent' => '#d97706'], 'explorer' => ['label' => __('Explorer', 'b5-persona'), 'accent' => '#9333ea'], 'builder' => ['label' => __('Maker', 'b5-persona'), 'accent' => '#16a34a'], 'organizer' => ['label' => __('Anchor', 'b5-persona'), 'accent' => '#475569'], 'catalyst' => ['label' => __('Spark', 'b5-persona'), 'accent' => '#eab308'], 'harmonizer' => ['label' => __('Harmonizer', 'b5-persona'), 'accent' => '#38bdf8'], 'navigator' => ['label' => __('Wayfinder', 'b5-persona'), 'accent' => '#4f46e5'], 'synthesizer' => ['label' => __('Sensemaker', 'b5-persona'), 'accent' => '#f472b6'], ]; } protected function archetype_chip(string $archetype_id): string { if ($archetype_id === '') { return '-'; } $label = $this->archetype_label($archetype_id); $meta = $this->archetype_meta(); $accent = isset($meta[$archetype_id]) ? $meta[$archetype_id]['accent'] : '#98a2b3'; return '' . '' . esc_html($label) . ''; } protected function archetype_label(string $archetype_id): string { if ($archetype_id === '') { return ''; } $meta = $this->archetype_meta(); return isset($meta[$archetype_id]) ? (string) $meta[$archetype_id]['label'] : ucfirst($archetype_id); } protected function render_archetype_columns(array $items, string $empty_message): void { if ($items === []) { $this->render_empty_state($empty_message); return; } $meta = $this->archetype_meta(); $max = 1; foreach ($items as $item) { $max = max($max, (int) $item['count']); } echo '
'; foreach ($items as $item) { $id = (string) $item['label']; $label = isset($meta[$id]) ? $meta[$id]['label'] : ucfirst($id); $accent = isset($meta[$id]) ? $meta[$id]['accent'] : '#98a2b3'; $height = max(6, (int) round(((int) $item['count'] / $max) * 100)); ?>
'; } /** Static demo numbers for teased Pro previews on free plans. */ protected function sample_analytics(): array { return [ 'views' => 482, 'starts' => 291, 'completions' => 187, 'visitor_split' => [ ['label' => __('Anonymous', 'b5-persona'), 'count' => 132], ['label' => __('Registered', 'b5-persona'), 'count' => 55], ], 'top_archetypes' => [ ['label' => 'explorer', 'count' => 42], ['label' => 'connector', 'count' => 35], ['label' => 'builder', 'count' => 28], ['label' => 'navigator', 'count' => 24], ['label' => 'harmonizer', 'count' => 19], ['label' => 'organizer', 'count' => 15], ], 'top_pages' => [ ['label' => '/personality-test', 'count' => 84], ['label' => '/about-us', 'count' => 41], ['label' => '/careers', 'count' => 27], ], 'cta_clicks' => 48, 'product_clicks' => 23, 'top_cta_archetypes' => [ ['label' => 'explorer', 'count' => 14], ['label' => 'connector', 'count' => 10], ['label' => 'builder', 'count' => 8], ['label' => 'harmonizer', 'count' => 6], ], 'top_products' => [ ['label' => __('Coaching session', 'b5-persona'), 'count' => 11], ['label' => __('Team report bundle', 'b5-persona'), 'count' => 7], ['label' => __('Workshop ticket', 'b5-persona'), 'count' => 5], ], 'top_product_archetypes' => [ ['label' => 'explorer', 'count' => 9], ['label' => 'builder', 'count' => 6], ['label' => 'connector', 'count' => 5], ['label' => 'harmonizer', 'count' => 3], ], 'benchmark' => [ 'enabled' => true, 'total' => 187, 'minimum' => self::BENCHMARK_MIN_COMPLETIONS, 'minimumResultCount' => self::BENCHMARK_MIN_RESULT_COUNT, 'items' => [ ['id' => 'explorer', 'label' => __('Explorer', 'b5-persona'), 'count' => 42, 'share' => 22], ['id' => 'connector', 'label' => __('Connector', 'b5-persona'), 'count' => 35, 'share' => 19], ['id' => 'builder', 'label' => __('Maker', 'b5-persona'), 'count' => 28, 'share' => 15], ['id' => 'navigator', 'label' => __('Wayfinder', 'b5-persona'), 'count' => 24, 'share' => 13], ], ], 'page_progress' => [ ['label' => __('Started', 'b5-persona'), 'count' => 291, 'share' => 100, 'drop' => 0, 'dropRate' => 0], ['label' => __('Step 1 complete', 'b5-persona'), 'count' => 252, 'share' => 87, 'drop' => 39, 'dropRate' => 13], ['label' => __('Step 2 complete', 'b5-persona'), 'count' => 221, 'share' => 76, 'drop' => 31, 'dropRate' => 12], ['label' => __('Step 3 complete', 'b5-persona'), 'count' => 197, 'share' => 68, 'drop' => 24, 'dropRate' => 11], ['label' => __('Submitted', 'b5-persona'), 'count' => 187, 'share' => 64, 'drop' => 10, 'dropRate' => 5], ], 'completion_timing' => [ 'count' => 187, 'average' => 96, 'median' => 83, 'fastest' => 28, 'slowest' => 284, 'buckets' => [ ['label' => __('Under 1 minute', 'b5-persona'), 'count' => 44], ['label' => __('1-2 minutes', 'b5-persona'), 'count' => 86], ['label' => __('2-5 minutes', 'b5-persona'), 'count' => 51], ['label' => __('5+ minutes', 'b5-persona'), 'count' => 6], ], ], ]; } protected function tease_open(): void { ?>
icon('lock'), self::svg_allowed_html()); ?>
icon('lock'), self::svg_allowed_html()); ?>

icon('external'), self::svg_allowed_html()); ?>
icon('chart'), self::svg_allowed_html()); ?>

has_api_key()) { return 'disconnected'; } $account = $this->account_config(); if ($account === []) { return 'pending'; } if (!empty($account['entitlement']['suspended'])) { return 'suspended'; } if ($this->account_connection_requires_reconnect($account)) { return 'attention'; } $diagnostic = get_option(self::OPTION_LAST_DIAGNOSTIC, []); $diagnostic_status = is_array($diagnostic) && isset($diagnostic['status']) ? sanitize_key((string) $diagnostic['status']) : ''; return in_array($diagnostic_status, ['warning', 'error'], true) ? 'attention' : 'connected'; } protected function connection_state_label(string $state): string { if ($state === 'connected') { return __('Connected', 'b5-persona'); } if ($state === 'pending') { return __('Test needed', 'b5-persona'); } if ($state === 'attention') { return __('Needs attention', 'b5-persona'); } if ($state === 'suspended') { return __('Account suspended', 'b5-persona'); } return __('Not connected', 'b5-persona'); } protected function account_connection_requires_reconnect(array $account): bool { $site = isset($account['connectedSite']) && is_array($account['connectedSite']) ? $account['connectedSite'] : []; return $site === [] || trim((string) ($site['primaryHost'] ?? '')) === ''; } protected const LOGO_FLAG_PATH = 'M0,10.84v-6.47C0,1.96,1.96,0,4.37,0h19.04v4.61c0,2.42-1.96,4.37-4.37,4.37H5.73c-.83,0-1.66.11-2.46.33-1.01.27-1.87.92-2.46,1.78h0c-.25.36-.81.19-.81-.25Z'; protected const LOGO_BODY_PATH = 'M11.11,13.33l-1.91.62c-2.32.96-5.01,1.12-7.48.19,0,0-.01,0-.02,0-.82-.31-1.69.28-1.69,1.15v13.41c.02,1.01-.01,10.83,10.78,15.39,8.37,3.54,18.31-1.68,20.95-10.46,3.83-12.74-8.07-24.29-20.62-20.3ZM21.13,35.37c-3.53,2.66-12.23,2.24-13.4-6.51v-.14c.02-1.49,0-1.86.24-2.65,1.82-5.83,10.18-7.42,14.25-2.95,3.32,3.64,2.85,9.29-1.1,12.25Z'; protected function logo_svg(): string { return ''; } protected function menu_icon(): string { $svg = '' . '' . '' . '' . '' . ''; // URL-encoded (NOT base64) on purpose: core's svg-painter.js detects // menu icons by searching computed background-image values for // "data:image/svg+xml;base64" and recolors them to the admin scheme. // A URL-encoded data URI never matches, so the logo keeps its colors. return 'data:image/svg+xml,' . rawurlencode($svg); } /** Allowed markup for the bundled SVG illustrations and interface icons. */ protected static function svg_allowed_html(): array { $attributes = array_fill_keys([ 'xmlns', 'viewbox', 'class', 'width', 'height', 'fill', 'fill-rule', 'clip-rule', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'opacity', 'fill-opacity', 'stroke-opacity', 'aria-hidden', 'aria-label', 'role', 'focusable', 'd', 'points', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'transform', 'id', ], true); return array_fill_keys(['svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'title'], $attributes); } protected function icon(string $name): string { $paths = [ 'link' => '', 'monitor' => '', 'chart' => '', 'shield' => '', 'activity' => '', 'eye' => '', 'play' => '', 'check-circle' => '', 'percent' => '', 'clock' => '', 'users' => '', 'copy' => '', 'tag' => '', 'key' => '', 'lock' => '', 'globe' => '', 'arrow-right' => '', 'refresh' => '', 'external' => '', 'shopping-bag' => '', 'zap' => '', 'file' => '', 'info' => '', 'layers' => '', 'sun' => '', 'moon' => '', 'mail' => '', 'x' => '', ]; $path = $paths[$name] ?? $paths['info']; return ''; } protected function mode_preview_svg(string $mode): string { if ($mode === 'slider') { return ''; } return ''; } protected function theme_preview_svg(string $theme, string $mode = 'paged-radio'): string { $is_editorial = $theme === 'editorial'; $bg = $is_editorial ? '#faf7f1' : '#ffffff'; $rx = $is_editorial ? '2' : '6'; $bar_rx = $is_editorial ? '0.5' : '2.5'; $accent = '#dd0006'; $ink = '#101418'; $svg = ''; return $svg; } protected function scheme_preview_svg(string $scheme): string { $dark = $scheme === 'dark'; $bg = $dark ? '#101418' : '#ffffff'; $fg = $dark ? '#ffffff' : '#101418'; return ''; } protected function density_preview_svg(string $density): string { $svg = ''; return $svg; } protected function analytics_summary(int $days): array { global $wpdb; $totals = ['views' => 0, 'starts' => 0, 'completions' => 0, 'result_views' => 0, 'completion_rate' => 0]; if (!$this->events_table_exists()) return $totals; $since = gmdate('Y-m-d H:i:s', time() - DAY_IN_SECONDS * max(1, $days)); $rows = $wpdb->get_results($wpdb->prepare('SELECT event, COUNT(*) AS total FROM %i WHERE created_at >= %s GROUP BY event', $this->events_table_name(), $since), ARRAY_A); $names = ['assessment_viewed' => 'views', 'assessment_started' => 'starts', 'assessment_completed' => 'completions', 'result_viewed' => 'result_views']; foreach ($rows as $row) if (isset($names[$row['event']])) $totals[$names[$row['event']]] = (int) $row['total']; $totals['completion_rate'] = $totals['starts'] ? round($totals['completions'] / $totals['starts'] * 100, 1) : 0; return $totals; } protected function dimension_counts(string $column, string $since, bool $visitor_labels = false, string $event = 'assessment_completed', int $limit = 6): array { $allowed_columns = ['visitor_type', 'archetype_id', 'page_path', 'target_id']; if (!in_array($column, $allowed_columns, true) || !in_array($event, self::EVENTS, true) || !$this->events_table_exists()) { return []; } global $wpdb; $table = self::events_table_name(); $limit = max(1, min(25, $limit)); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT %i AS label, COUNT(*) AS total FROM %i WHERE created_at >= %s AND event = %s AND %i <> '' GROUP BY %i ORDER BY total DESC LIMIT %d", $column, $table, $since, $event, $column, $column, $limit ), ARRAY_A ); $items = []; foreach ((array) $rows as $row) { $label = (string) $row['label']; $items[] = [ 'label' => $visitor_labels ? $this->visitor_type_label($label) : $label, 'count' => (int) $row['total'], ]; } return $items; } protected function events_table_exists(): bool { global $wpdb; $table = self::events_table_name(); return $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table; } protected function results_table_exists(): bool { global $wpdb; $table = self::results_table_name(); return $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table; } protected function leads_table_exists(): bool { global $wpdb; $table = self::leads_table_name(); return $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table; } protected function visitor_type_label(string $type): string { if ($type === 'wp_registered') { return __('User', 'b5-persona'); } if ($type === 'anonymous') { return __('Visitor', 'b5-persona'); } return $type !== '' ? $type : __('Unknown', 'b5-persona'); } protected function format_duration($seconds): string { if ($seconds === null || $seconds === '') { return __('n/a', 'b5-persona'); } $seconds = max(0, (int) $seconds); $minutes = intdiv($seconds, 60); $rest = $seconds % 60; if ($minutes > 0) { return sprintf('%dm %02ds', $minutes, $rest); } return sprintf('%ds', $rest); } protected function account_config(): array { $config = get_option(self::OPTION_ACCOUNT_CACHE, []); return is_array($config) ? $config : []; } protected function is_test_environment(): bool { $account = $this->account_config(); $environment = sanitize_key((string) ($account['apiKey']['environment'] ?? '')); if ($environment !== '') { return $environment === 'test'; } return strpos((string) get_option(self::OPTION_API_KEY, ''), 'b5t_test_') === 0; } protected function frontend_environment_notice(string $scheme, string $density, string $locale = ''): string { if (!$this->is_test_environment()) { return ''; } return sprintf( '
%s%s
', esc_attr($this->sanitize_scheme($scheme)), esc_attr($this->sanitize_density($density)), esc_html($this->visitor_text('Development site', $locale)), esc_html($this->visitor_text('This assessment is using a test credential and is not intended for a live site.', $locale)) ); } protected function account_feature_enabled(string $feature): bool { if (!in_array($feature, ['telemetry', 'browserPersistence'], true) && !$this->pro_addon_available()) return false; $account = $this->account_config(); if (!in_array($feature, ['telemetry', 'browserPersistence'], true) && !$this->verified_pro_access($account)) return false; return !empty($account['features'][$feature]); } protected function registered_results_available(): bool { return $this->account_feature_enabled('registeredUserPersistence'); } protected function registered_results_enabled(): bool { return $this->registered_results_available() && $this->registered_results_setting_enabled(); } protected function registered_results_setting_enabled(): bool { return (string) get_option(self::OPTION_REGISTERED_RESULTS, '0') === '1'; } protected function registered_result_behavior(): string { if (!$this->registered_results_enabled()) { return 'show'; } $behavior = sanitize_key((string) get_option(self::OPTION_REGISTERED_RESULT_BEHAVIOR, 'show')); return in_array($behavior, ['show', 'prompt', 'fresh'], true) ? $behavior : 'show'; } protected function saved_result_page_id(): int { $page_id = absint(get_option(self::OPTION_SAVED_RESULT_PAGE_ID, '0')); if ($page_id <= 0) { return 0; } $post = get_post($page_id); if (!$post || $post->post_type !== 'page' || $post->post_status === 'trash') { return 0; } return $page_id; } protected function saved_result_page_url(): string { $page_id = $this->saved_result_page_id(); if ($page_id <= 0) { return ''; } $url = get_permalink($page_id); return is_string($url) ? $url : ''; } protected function woocommerce_account_result_link_enabled(): bool { return (string) get_option(self::OPTION_WC_ACCOUNT_RESULT_LINK, '0') === '1'; } protected function should_show_woocommerce_result_account_link(): bool { return $this->woocommerce_available() && $this->registered_results_enabled() && $this->woocommerce_account_result_link_enabled() && $this->saved_result_page_url() !== ''; } protected function advanced_analytics_enabled(): bool { return $this->account_feature_enabled('advancedAnalytics'); } protected function pdf_export_available(): bool { return $this->account_feature_enabled('pdfExport'); } protected function conversion_tools_available(): bool { return $this->account_feature_enabled('conversionTools'); } protected function lead_capture_available(): bool { return $this->account_feature_enabled('leadCapture'); } protected function woocommerce_available(): bool { return function_exists('wc_get_product'); } protected function analytics_days_from_request(): int { $days = absint($this->query_text('analytics_days', '30')); $allowed = [7, 30, 90, 365]; return in_array($days, $allowed, true) ? $days : 30; } protected function query_text(string $key, string $default = ''): string { if (!isset($_GET[$key]) || !is_scalar($_GET[$key])) { return $default; } return sanitize_text_field(wp_unslash($_GET[$key])); } protected function user_label(int $user_id): string { $user = get_userdata($user_id); if (!$user) { return sprintf(/* translators: %d: WordPress user ID. */ __('User #%d', 'b5-persona'), $user_id); } $name = trim((string) $user->display_name); return $name !== '' ? $name : (string) $user->user_login; } protected function cache_account_config(array $payload): void { $config = isset($payload['config']) && is_array($payload['config']) ? $payload['config'] : []; if ($config === []) { return; } $safe = [ '_verified_at' => time(), 'plan' => [ 'id' => isset($config['plan']['id']) ? sanitize_key((string) $config['plan']['id']) : '', 'name' => isset($config['plan']['name']) ? sanitize_text_field((string) $config['plan']['name']) : '', ], 'apiKey' => [ 'environment' => isset($config['apiKey']['environment']) ? sanitize_key((string) $config['apiKey']['environment']) : '', 'prefix' => isset($config['apiKey']['prefix']) ? sanitize_text_field((string) $config['apiKey']['prefix']) : '', 'allowedDomains' => $this->sanitize_string_list($config['apiKey']['allowedDomains'] ?? []), ], 'connectedSite' => $this->sanitize_connected_site_config($config['connectedSite'] ?? null), 'entitlement' => $this->sanitize_entitlement_config($config['entitlement'] ?? null), 'features' => [ 'telemetry' => !empty($config['features']['telemetry']), 'browserPersistence' => !empty($config['features']['browserPersistence']), 'registeredUserPersistence' => !empty($config['features']['registeredUserPersistence']), 'advancedAnalytics' => !empty($config['features']['advancedAnalytics']), 'conversionTools' => !empty($config['features']['conversionTools']), 'leadCapture' => !empty($config['features']['leadCapture']), 'pdfExport' => !empty($config['features']['pdfExport']), ], 'display' => [ 'enabledModes' => $this->sanitize_string_list($config['display']['enabledModes'] ?? ['paged-radio']), 'lockedModes' => $this->sanitize_string_list($config['display']['lockedModes'] ?? []), 'enabledThemes' => $this->sanitize_string_list($config['display']['enabledThemes'] ?? ['clean']), 'lockedThemes' => $this->sanitize_string_list($config['display']['lockedThemes'] ?? []), ], ]; if (isset($config['display']['enabledSchemes'])) { $safe['display']['enabledSchemes'] = $this->sanitize_string_list($config['display']['enabledSchemes']); $safe['display']['lockedSchemes'] = $this->sanitize_string_list($config['display']['lockedSchemes'] ?? []); } if (isset($config['display']['enabledDensities'])) { $safe['display']['enabledDensities'] = $this->sanitize_string_list($config['display']['enabledDensities']); $safe['display']['lockedDensities'] = $this->sanitize_string_list($config['display']['lockedDensities'] ?? []); } $safe += [ 'checkedAt' => gmdate('c'), ]; update_option(self::OPTION_ACCOUNT_CACHE, $safe, false); } protected function sanitize_connected_site_config($value): ?array { if (!is_array($value)) { return null; } return [ 'id' => isset($value['id']) ? sanitize_text_field((string) $value['id']) : '', 'primaryDomain' => isset($value['primaryDomain']) ? sanitize_text_field((string) $value['primaryDomain']) : '', 'primaryHost' => isset($value['primaryHost']) ? sanitize_text_field((string) $value['primaryHost']) : '', 'aliases' => $this->sanitize_string_list($value['aliases'] ?? []), 'status' => isset($value['status']) ? sanitize_key((string) $value['status']) : '', 'credentialPrefix' => isset($value['credentialPrefix']) ? sanitize_text_field((string) $value['credentialPrefix']) : '', ]; } protected function sanitize_entitlement_config($value): ?array { if (!is_array($value)) { return null; } return [ 'billingStatus' => isset($value['billingStatus']) ? sanitize_key((string) $value['billingStatus']) : '', 'monthlyQuota' => isset($value['monthlyQuota']) ? absint($value['monthlyQuota']) : 0, 'quotaUsed' => isset($value['quotaUsed']) ? absint($value['quotaUsed']) : 0, 'quotaRemaining' => isset($value['quotaRemaining']) ? absint($value['quotaRemaining']) : 0, 'quotaPeriod' => isset($value['quotaPeriod']) ? sanitize_text_field((string) $value['quotaPeriod']) : '', 'quotaResetsAt' => isset($value['quotaResetsAt']) ? sanitize_text_field((string) $value['quotaResetsAt']) : '', 'developmentMonthlyQuota' => isset($value['developmentMonthlyQuota']) ? absint($value['developmentMonthlyQuota']) : 0, 'developmentQuotaUsed' => isset($value['developmentQuotaUsed']) ? absint($value['developmentQuotaUsed']) : 0, 'developmentQuotaRemaining' => isset($value['developmentQuotaRemaining']) ? absint($value['developmentQuotaRemaining']) : 0, 'maxConnectedSites' => isset($value['maxConnectedSites']) ? absint($value['maxConnectedSites']) : 0, 'connectedSitesUsed' => isset($value['connectedSitesUsed']) ? absint($value['connectedSitesUsed']) : 0, 'extraSiteSlots' => isset($value['extraSiteSlots']) ? absint($value['extraSiteSlots']) : 0, 'suspended' => !empty($value['suspended']), 'proDownloadEligible' => !empty($value['proDownloadEligible']), 'proAccessExpiresAt' => sanitize_text_field((string) ($value['proAccessExpiresAt'] ?? '')), 'graceEndsAt' => sanitize_text_field((string) ($value['graceEndsAt'] ?? '')), 'suspensionMessage' => isset($value['suspensionMessage']) ? sanitize_text_field((string) $value['suspensionMessage']) : '', ]; } protected function sanitize_string_list($value): array { if (!is_array($value)) { return []; } $items = []; foreach ($value as $item) { if (is_scalar($item)) { $items[] = sanitize_text_field((string) $item); } } return array_values(array_unique(array_filter($items))); } protected function allowed_domains_label(array $account): string { $domains = isset($account['apiKey']['allowedDomains']) && is_array($account['apiKey']['allowedDomains']) ? $account['apiKey']['allowedDomains'] : []; return $domains === [] ? __('Unrestricted', 'b5-persona') : implode(', ', $domains); } protected function enabled_modes(): array { if (!$this->pro_addon_available() || !$this->verified_pro_access($this->account_config())) return ['paged-radio']; $account = $this->account_config(); $modes = isset($account['display']['enabledModes']) && is_array($account['display']['enabledModes']) ? $account['display']['enabledModes'] : ['paged-radio']; return array_values(array_intersect(self::MODES, $modes)) ?: ['paged-radio']; } protected function locked_modes(): array { $account = $this->account_config(); $modes = isset($account['display']['lockedModes']) && is_array($account['display']['lockedModes']) ? $account['display']['lockedModes'] : ['slider']; return array_values(array_intersect(self::MODES, $modes)); } protected function enabled_themes(): array { if (!$this->pro_addon_available() || !$this->verified_pro_access($this->account_config())) return ['clean']; $account = $this->account_config(); $themes = isset($account['display']['enabledThemes']) && is_array($account['display']['enabledThemes']) ? $account['display']['enabledThemes'] : ['clean']; return array_values(array_intersect(self::THEMES, $themes)) ?: ['clean']; } protected function locked_themes(): array { $account = $this->account_config(); $themes = isset($account['display']['lockedThemes']) && is_array($account['display']['lockedThemes']) ? $account['display']['lockedThemes'] : ['compact', 'dark', 'editorial']; return array_values(array_intersect(self::THEMES, $themes)); } protected function enabled_schemes(): array { return ['light', 'dark']; } protected function locked_schemes(): array { return []; } protected function enabled_densities(): array { if (!$this->pro_addon_available() || !$this->verified_pro_access($this->account_config())) return ['comfortable']; $account = $this->account_config(); if (isset($account['display']['enabledDensities']) && is_array($account['display']['enabledDensities'])) { return array_values(array_intersect(self::DENSITIES, $account['display']['enabledDensities'])) ?: ['comfortable']; } // Older cached configs: compact density derives from the compact theme. return in_array('compact', $this->enabled_themes(), true) ? ['comfortable', 'compact'] : ['comfortable']; } protected function locked_densities(): array { $account = $this->account_config(); if (isset($account['display']['lockedDensities']) && is_array($account['display']['lockedDensities'])) { return array_values(array_intersect(self::DENSITIES, $account['display']['lockedDensities'])); } return in_array('compact', $this->enabled_densities(), true) ? [] : ['compact']; } protected function normalize_display_settings_for_account(): void { // Effective access is calculated at render time. Downgrades never erase a saved Pro choice. } protected function clear_account_connection(): void { delete_option(self::OPTION_API_KEY); delete_option(self::OPTION_ACCOUNT_CACHE); delete_option(self::OPTION_LAST_DIAGNOSTIC); delete_option(self::OPTION_LAST_EVENT_SYNC); delete_option(self::OPTION_LAST_COMPLETION_WEBHOOK); delete_option(self::OPTION_LAST_LEAD_WEBHOOK); delete_option(self::OPTION_LAST_RESULT_EMAIL); } protected function refresh_connection_status_for_admin(): ?array { $response = $this->api_request('GET', '/api/v1/plugin-config', null, 6); if ($response['ok']) { $this->cache_account_config($response['payload']); $this->normalize_display_settings_for_account(); update_option(self::OPTION_LAST_DIAGNOSTIC, [ 'at' => gmdate('c'), 'status' => 'ok', 'message' => __('Connection verified.', 'b5-persona'), ], false); return null; } $notice = $this->connection_failure_notice($response); update_option(self::OPTION_LAST_DIAGNOSTIC, [ 'at' => gmdate('c'), 'status' => $this->is_account_suspended_response($response) ? 'suspended' : $notice['type'], 'message' => $notice['message'], 'details' => $notice['details'], ], false); return $notice; } // Shared by automatic verification and the owner's explicit retry. protected function connection_failure_notice(array $response): array { $invalid = $this->is_invalid_credential_response($response); $suspended = $this->is_account_suspended_response($response); $message = $suspended ? __('Your B5Tools account is suspended. Open your B5Tools account to review its status.', 'b5-persona') : ($invalid ? __('This site connection was revoked or is no longer valid. Connect this site again.', 'b5-persona') : __('We couldn’t reach B5Tools right now. Your connection details are saved. Please try again shortly.', 'b5-persona')); return [ 'type' => $invalid ? 'error' : 'warning', 'message' => $message, 'details' => $this->proxy_error_message($response['payload']), 'retry' => !$invalid && !$suspended && $this->has_api_key(), ]; } protected function render_connection_retry(): void { ?>
api_request('GET', '/api/v1/plugin-config'); if ($response['ok']) { $this->cache_account_config($response['payload']); $this->normalize_display_settings_for_account(); } $account = $this->account_config(); $plan_name = isset($account['plan']['name']) ? (string) $account['plan']['name'] : ''; $requires_reconnect = $response['ok'] && $this->account_connection_requires_reconnect($account); if ($requires_reconnect) { $message = __('The saved key works, but it is not secured to this WordPress domain. Reconnect this site to finish the upgrade.', 'b5-persona'); } elseif ($response['ok']) { $message = sprintf(/* translators: %s: Connected plan name. */ __('Connection successful. Connected plan: %s.', 'b5-persona'), $plan_name !== '' ? $plan_name : __('Unknown', 'b5-persona')); } else { $message = $this->connection_failure_notice($response)['message']; } $diagnostic_status = $response['ok'] && !$requires_reconnect ? 'ok' : ($this->is_account_suspended_response($response) ? 'suspended' : ($requires_reconnect ? 'warning' : 'error')); update_option(self::OPTION_LAST_DIAGNOSTIC, [ 'at' => gmdate('c'), 'status' => $diagnostic_status, 'message' => $message, 'details' => $response['ok'] ? '' : $this->proxy_error_message($response['payload']), ], false); set_transient($this->admin_notice_transient_key(), $response['ok'] ? [ 'type' => $response['ok'] && !$requires_reconnect ? 'success' : ($requires_reconnect ? 'warning' : 'error'), 'message' => $message, ] : $this->connection_failure_notice($response), 60); wp_safe_redirect(admin_url('admin.php?page=b5tools-wp&tab=connection')); exit; } public function handle_start_connect(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to manage B5 Persona settings.', 'b5-persona')); } check_admin_referer('b5tools_wp_start_connect'); if ($this->connection_state() === 'suspended') { set_transient($this->admin_notice_transient_key(), [ 'type' => 'warning', 'message' => __('This B5Tools account is suspended. Open the B5Tools account to review its status; replacing the credential will not resolve a suspension.', 'b5-persona'), ], 60); wp_safe_redirect(admin_url('admin.php?page=b5tools-wp&tab=connection')); exit; } $state = wp_generate_password(32, false, false); $token = wp_generate_password(48, false, false); set_transient($this->connect_transient_key($state), [ 'token' => $token, 'created_at' => gmdate('c'), ], 10 * MINUTE_IN_SECONDS); $account = $this->account_config(); $connected_site_id = isset($account['connectedSite']['id']) ? sanitize_text_field((string) $account['connectedSite']['id']) : ''; $connect_url = add_query_arg([ 'state' => $state, 'token' => $token, 'site_url' => home_url('/'), 'site_host' => $this->site_host(), 'callback_url' => admin_url('admin-post.php?action=b5tools_wp_connect_callback'), 'plugin_version' => B5TOOLS_WP_VERSION, 'connected_site_id' => $connected_site_id, ], $this->get_api_base_url() . '/connect/wordpress'); $connect_host = wp_parse_url($this->get_api_base_url(), PHP_URL_HOST); $allow_service_host = static function (array $hosts) use ($connect_host): array { if (is_string($connect_host) && $connect_host !== '') { $hosts[] = $connect_host; } return array_unique($hosts); }; add_filter('allowed_redirect_hosts', $allow_service_host); wp_safe_redirect(esc_url_raw($connect_url)); remove_filter('allowed_redirect_hosts', $allow_service_host); exit; } public function handle_connect_callback(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to manage B5 Persona settings.', 'b5-persona')); } $state = sanitize_text_field($this->query_text('state')); $error_code = sanitize_key($this->query_text('b5tools_connect_error')); if ($error_code !== '') { set_transient($this->admin_notice_transient_key(), [ 'type' => 'error', 'message' => $this->connect_error_message($error_code), ], 60); wp_safe_redirect(admin_url('admin.php?page=b5tools-wp&tab=connection')); exit; } $code = sanitize_text_field($this->query_text('code')); $stored = $state !== '' ? get_transient($this->connect_transient_key($state)) : false; if ($state === '' || $code === '' || !is_array($stored) || empty($stored['token'])) { set_transient($this->admin_notice_transient_key(), [ 'type' => 'error', 'message' => __('B5Tools connect expired or was not started from this WordPress admin.', 'b5-persona'), ], 60); wp_safe_redirect(admin_url('admin.php?page=b5tools-wp&tab=connection')); exit; } delete_transient($this->connect_transient_key($state)); $response = $this->connect_exchange_request($code, $state, (string) $stored['token']); if (!$response['ok']) { $message = __('We couldn’t finish connecting your site. Start the connection again.', 'b5-persona'); set_transient($this->admin_notice_transient_key(), [ 'type' => 'error', 'message' => $message, 'details' => $this->proxy_error_message($response['payload']), ], 60); update_option(self::OPTION_LAST_DIAGNOSTIC, [ 'at' => gmdate('c'), 'status' => 'error', 'message' => $message, 'details' => $this->proxy_error_message($response['payload']), ], false); wp_safe_redirect(admin_url('admin.php?page=b5tools-wp&tab=connection')); exit; } $api_key = isset($response['payload']['credential']['apiKey']) && is_string($response['payload']['credential']['apiKey']) ? sanitize_text_field($response['payload']['credential']['apiKey']) : ''; if ($api_key === '') { set_transient($this->admin_notice_transient_key(), [ 'type' => 'error', 'message' => __('B5Tools connected but did not return a site credential.', 'b5-persona'), ], 60); wp_safe_redirect(admin_url('admin.php?page=b5tools-wp&tab=connection')); exit; } update_option(self::OPTION_API_KEY, $api_key, false); update_option(self::OPTION_SETUP_WIZARD_DISMISSED, '1', false); delete_option(self::OPTION_ACCOUNT_CACHE); $config_response = $this->api_request('GET', '/api/v1/plugin-config'); if ($config_response['ok']) { $this->cache_account_config($config_response['payload']); $this->normalize_display_settings_for_account(); } $message = $config_response['ok'] ? __('B5Tools connected. This WordPress site now has a secure site key.', 'b5-persona') : $this->connection_failure_notice($config_response)['message']; update_option(self::OPTION_LAST_DIAGNOSTIC, [ 'at' => gmdate('c'), 'status' => $config_response['ok'] ? 'ok' : 'warning', 'message' => $message, 'details' => $config_response['ok'] ? '' : $this->proxy_error_message($config_response['payload']), ], false); set_transient($this->admin_notice_transient_key(), $config_response['ok'] ? [ 'type' => $config_response['ok'] ? 'success' : 'warning', 'message' => $message, ] : $this->connection_failure_notice($config_response), 60); wp_safe_redirect(admin_url('admin.php?page=b5tools-wp&tab=connection')); exit; } public function handle_skip_setup_wizard(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to manage B5 Persona settings.', 'b5-persona')); } check_admin_referer('b5tools_wp_skip_setup_wizard'); update_option(self::OPTION_SETUP_WIZARD_DISMISSED, '1', false); set_transient($this->admin_notice_transient_key(), [ 'type' => 'success', 'message' => __('Setup skipped. Connect this site whenever you are ready.', 'b5-persona'), ], 60); wp_safe_redirect(admin_url('admin.php?page=b5tools-wp&tab=connection')); exit; } public function handle_disconnect_account(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to manage B5 Persona settings.', 'b5-persona')); } check_admin_referer('b5tools_wp_disconnect_account'); $account = $this->account_config(); $site_scoped = !empty($account['connectedSite']['id']); if ($site_scoped) { $response = $this->api_request('POST', '/api/v1/connect/wordpress/disconnect', []); if (!$response['ok'] && !$this->is_invalid_credential_response($response)) { set_transient($this->admin_notice_transient_key(), [ 'type' => 'error', 'message' => __('We couldn’t disconnect this site right now. Your connection is still saved. Please try again shortly.', 'b5-persona'), 'details' => $this->proxy_error_message($response['payload']), ], 60); wp_safe_redirect(admin_url('admin.php?page=b5tools-wp&tab=connection')); exit; } } $this->clear_account_connection(); set_transient($this->admin_notice_transient_key(), [ 'type' => 'success', 'message' => __('This WordPress site was disconnected from B5Tools.', 'b5-persona'), ], 60); wp_safe_redirect(admin_url('admin.php?page=b5tools-wp&tab=connection')); exit; } public function register_blocks(): void { if (!function_exists('register_block_type')) { return; } wp_register_script( 'b5tools-wp-block-editor', B5TOOLS_WP_URL . 'assets/js/block-editor.js', ['wp-blocks', 'wp-block-editor', 'wp-components', 'wp-element', 'wp-i18n'], B5TOOLS_WP_VERSION, true ); wp_localize_script('b5tools-wp-block-editor', 'B5ToolsBlockCapabilities', ['proEnabled' => $this->registered_results_available(), 'conversionEnabled' => $this->conversion_tools_available(), 'modes' => $this->enabled_modes(), 'themes' => $this->enabled_themes(), 'densities' => $this->enabled_densities()]); wp_set_script_translations( 'b5tools-wp-block-editor', 'b5-persona', B5TOOLS_WP_DIR . 'languages' ); wp_register_style( 'b5tools-wp-block-editor', B5TOOLS_WP_URL . 'assets/css/block-editor.css', [], B5TOOLS_WP_VERSION ); wp_register_style( 'b5tools-wp-type-library', B5TOOLS_WP_URL . 'assets/css/type-library.css', [], B5TOOLS_WP_VERSION ); wp_register_style( 'b5tools-wp-personality-characters', B5TOOLS_WP_URL . 'assets/css/personality-characters.css', [], B5TOOLS_WP_VERSION ); register_block_type('b5tools/assessment', [ 'api_version' => 2, 'editor_script' => 'b5tools-wp-block-editor', 'editor_style' => 'b5tools-wp-block-editor', 'render_callback' => [$this, 'render_assessment_block'], 'attributes' => [ 'kind' => [ 'type' => 'string', 'default' => 'assessment', ], 'savedResultView' => [ 'type' => 'string', 'default' => 'latest', ], 'instance' => [ 'type' => 'string', 'default' => 'default', ], 'locale' => [ 'type' => 'string', 'default' => '', ], 'overrideDisplay' => [ 'type' => 'boolean', 'default' => false, ], 'showProducts' => [ 'type' => 'boolean', 'default' => true, ], 'mode' => [ 'type' => 'string', 'default' => 'paged-radio', ], 'theme' => [ 'type' => 'string', 'default' => 'clean', ], 'styleSource' => [ 'type' => 'string', 'default' => 'b5tools', ], 'scheme' => [ 'type' => 'string', 'default' => 'light', ], 'density' => [ 'type' => 'string', 'default' => 'comfortable', ], 'assessmentUrl' => [ 'type' => 'string', 'default' => '', ], ], 'supports' => [ 'html' => false, ], ]); register_block_type('b5tools/personality-types', [ 'api_version' => 2, 'editor_script' => 'b5tools-wp-block-editor', 'editor_style' => 'b5tools-wp-block-editor', 'style' => 'b5tools-wp-type-library', 'render_callback' => [$this, 'render_personality_types_block'], 'attributes' => ['locale' => ['type' => 'string', 'default' => '']], 'supports' => [ 'html' => false, ], ]); } public function render_personality_types_block(array $attributes = []): string { return $this->render_personality_types_shortcode(['locale' => (string) ($attributes['locale'] ?? '')]); } public function render_assessment_block(array $attributes): string { $kind = sanitize_key((string) ($attributes['kind'] ?? 'assessment')); $instance = sanitize_key((string) ($attributes['instance'] ?? 'default')) ?: 'default'; $locale = $this->visitor_locale((string) ($attributes['locale'] ?? '')); $override_display = !empty($attributes['overrideDisplay']) ? '1' : '0'; $show_products = !array_key_exists('showProducts', $attributes) || !empty($attributes['showProducts']) ? '1' : '0'; $theme = $this->sanitize_theme((string) ($attributes['theme'] ?? 'clean')); $style_source = $this->sanitize_style_source((string) ($attributes['styleSource'] ?? 'b5tools')); $scheme = $this->sanitize_scheme((string) ($attributes['scheme'] ?? 'light')); $density = $this->sanitize_density((string) ($attributes['density'] ?? 'comfortable')); if ($kind === 'saved_result') { $view = sanitize_key((string) ($attributes['savedResultView'] ?? 'latest')); if (!in_array($view, ['latest', 'dashboard', 'history'], true)) { $view = 'latest'; } return $this->render_saved_result_shortcode([ 'locale' => $locale, 'theme' => $theme, 'style_source' => $style_source, 'scheme' => $scheme, 'density' => $density, 'instance' => $instance, 'override_display' => $override_display, 'show_products' => $show_products, 'assessment_url' => esc_url_raw((string) ($attributes['assessmentUrl'] ?? '')), 'view' => $view, ]); } return $this->render_assessment_shortcode([ 'locale' => $locale, 'mode' => $this->sanitize_display_mode((string) ($attributes['mode'] ?? 'paged-radio')), 'theme' => $theme, 'style_source' => $style_source, 'scheme' => $scheme, 'density' => $density, 'instance' => $instance, 'override_display' => $override_display, 'show_products' => $show_products, ]); } protected function visitor_locale(string $requested = ''): string { $locale = $requested !== '' ? $requested : get_locale(); return strtolower((string) preg_split('/[-_]/', $locale)[0]) === 'ar' ? 'ar' : 'en'; } protected static function text_slice(string $value, int $start, int $length): string { // Limits count characters, matching HTML maxlength. Never cut a UTF-8 // Arabic character halfway through a byte sequence. if (function_exists('mb_substr')) { return mb_substr($value, $start, $length, 'UTF-8'); } preg_match_all('/./us', $value, $characters); return implode('', array_slice($characters[0], $start, $length)); } protected function visitor_text(string $text, string $locale): string { if ($this->visitor_locale($locale) !== 'ar') { return $text; } static $arabic = null; if ($arabic === null) { require_once ABSPATH . WPINC . '/pomo/mo.php'; $arabic = new MO(); $arabic->import_from_file(B5TOOLS_WP_DIR . 'languages/b5-persona-ar.mo'); } return $arabic->translate($text); } protected function enqueue_shared_fonts(): void { wp_enqueue_style('b5tools-wp-fonts', B5TOOLS_WP_URL . 'assets/css/fonts.css', [], B5TOOLS_WP_VERSION); } protected function enqueue_assessment_runtime(): array { $this->enqueue_shared_fonts(); wp_enqueue_script('b5tools-wp-visitor-i18n', B5TOOLS_WP_URL . 'assets/js/visitor-i18n.js', [], B5TOOLS_WP_VERSION, true); wp_enqueue_style('b5tools-wp-assessment', B5TOOLS_WP_URL . 'assets/css/assessment.css', [], B5TOOLS_WP_VERSION); $this->enqueue_extension_runtime(); wp_enqueue_style('b5tools-wp-personality-characters'); $deps = ['wp-i18n', 'b5tools-wp-visitor-i18n']; if ($this->pro_addon_available()) $deps[] = 'b5tools-wp-pro-assessment'; wp_enqueue_script('b5tools-wp-assessment', B5TOOLS_WP_URL . 'assets/js/assessment.js', $deps, B5TOOLS_WP_VERSION, true); wp_set_script_translations('b5tools-wp-assessment', 'b5-persona', B5TOOLS_WP_DIR . 'languages'); $settings = [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce(self::NONCE_ACTION), 'persistenceEnabled' => $this->browser_persistence_enabled(), 'credit' => ['enabled' => (string) get_option(self::OPTION_DISPLAY_CREDIT, '0') === '1'], 'sharing' => ['enabled' => $this->result_sharing_enabled(), 'siteName' => get_bloginfo('name') ?: $this->site_host(), 'siteUrl' => home_url('/')], 'typeGuide' => ['enabled' => $this->type_guide_link_enabled(), 'baseUrl' => $this->type_library_page_url()], 'isLoggedIn' => is_user_logged_in(), 'personalityCharacters' => $this->personality_character_svgs(), ]; $extra = $this->extension_runtime_settings(); wp_localize_script('b5tools-wp-assessment', 'B5ToolsWP', array_merge($settings, $extra)); return ['conversion_ctas' => $extra['conversion']['ctas'] ?? [], 'conversion_available' => !empty($extra['conversion']['enabled'])]; } public function render_assessment_shortcode($atts): string { $atts = shortcode_atts([ 'locale' => '', 'mode' => $this->display_mode(), 'theme' => $this->theme(), 'style_source' => $this->style_source(), 'scheme' => $this->scheme(), 'density' => $this->density(), 'instance' => 'default', 'override_display' => '0', 'show_products' => '1', ], $atts, 'b5tools_assessment'); if (!$this->has_api_key()) { return ''; } $locale = $this->visitor_locale((string) $atts['locale']); $runtime = $this->enqueue_assessment_runtime(); $override_display = $this->truthy_shortcode_value((string) $atts['override_display']); $mode = $override_display ? $this->sanitize_display_mode((string) $atts['mode']) : $this->display_mode(); $theme = $override_display ? $this->sanitize_theme((string) $atts['theme']) : $this->theme(); $style_source = $override_display ? $this->sanitize_style_source((string) $atts['style_source']) : $this->style_source(); $scheme = $override_display ? $this->sanitize_scheme((string) $atts['scheme']) : $this->scheme(); $density = $override_display ? $this->sanitize_density((string) $atts['density']) : $this->density(); $show_products = $this->truthy_shortcode_value((string) $atts['show_products']); return sprintf( '
%s
%s
', esc_attr($locale), $locale === 'ar' ? 'rtl' : 'ltr', esc_attr($locale), esc_attr(sanitize_key((string) $atts['instance']) ?: 'default'), esc_attr($mode), esc_attr($theme), esc_attr($style_source), esc_attr($scheme), esc_attr($density), esc_attr($runtime['conversion_available'] ? '1' : '0'), esc_attr(wp_json_encode($runtime['conversion_ctas'])), esc_attr($show_products ? '1' : '0'), $this->frontend_environment_notice($scheme, $density, $locale), esc_html($locale === 'ar' ? 'جارٍ تحميل التقييم...' : 'Loading assessment...') ); } public function render_saved_result_shortcode($atts): string { return '

' . esc_html($this->visitor_text('Result history is not active for this site right now.', $this->visitor_locale($atts['locale'] ?? 'en'))) . '

'; } public function render_personality_types_shortcode($atts): string { $atts = shortcode_atts([ 'type' => '', 'title' => '', 'locale' => '', ], $atts, 'b5tools_personality_types'); $locale = $this->visitor_locale((string) $atts['locale']); if ($atts['title'] === '') { $atts['title'] = $this->visitor_text('Personality types', $locale); } wp_enqueue_style('b5tools-wp-type-library'); wp_enqueue_style('b5tools-wp-personality-characters'); $this->enqueue_shared_fonts(); $types = $this->personality_type_library($locale); if ($types === []) { $message = current_user_can('manage_options') ? $this->visitor_text('Connect or test B5Tools once to load the personality type library.', $locale) : $this->visitor_text('The personality type guides are temporarily unavailable. Please try again shortly.', $locale); return '

' . esc_html($message) . '

'; } $requested_slug = sanitize_key((string) $atts['type']); if ($requested_slug === '') { $requested_slug = sanitize_key($this->query_text('b5tools_type')); } $base_url = get_permalink(get_queried_object_id()); if (!is_string($base_url) || $base_url === '') { $base_url = home_url('/'); } $base_url = remove_query_arg('b5tools_type', $base_url); foreach ($types as $type) { if ($type['slug'] === $requested_slug) { return $this->render_personality_type_detail($type, $types, $base_url, $locale); } } $heading_id = wp_unique_id('b5tools-type-library-'); ob_start(); ?>

visitor_text('B5Tools guide library', $locale)); ?>

visitor_text('B5Tools uses your Big Five trait results to suggest a memorable personality type that highlights your strengths. These guides explain common tendencies, while recognising that people with the same type can differ.', $locale)); ?>

visitor_text('Use your type as a starting point.', $locale)); ?> visitor_text('Each type summarises a broad combination of five personality traits. Two people with the same result can still have different habits, needs, and preferences. Use the descriptions to help explain your own tendencies and understand others, without assuming that a type explains everything about a person.', $locale)); ?>

type_library_page_id()) { return $content; } $page = get_post($page_id); if ($page instanceof WP_Post) { $source = (string) $page->post_content; if (has_block('b5tools/personality-types', $source) || has_shortcode($source, 'b5tools_personality_types')) { return $content; } } return $content . $this->render_personality_types_shortcode([]); } protected function render_personality_type_detail(array $type, array $types, string $base_url, string $locale = ''): string { $index = 0; foreach ($types as $position => $candidate) { if ($candidate['slug'] === $type['slug']) { $index = (int) $position; break; } } $previous = $types[($index - 1 + count($types)) % count($types)]; $next = $types[($index + 1) % count($types)]; $heading_id = wp_unique_id('b5tools-type-guide-'); ob_start(); ?>
visitor_text('All personality types', $locale)); ?>

visitor_text('Personality type', $locale)); ?>

visitor_text('About the %s', $locale), $type['label'])); ?>

visitor_text('Strengths in everyday life', $locale)); ?>

visitor_text('What can get in the way', $locale)); ?>

visitor_text('Understanding each other', $locale)); ?>

visitor_text('Questions worth asking', $locale)); ?>

visitor_locale($requested_locale); $cache_key = self::OPTION_TYPE_LIBRARY_CACHE . '_' . $locale; $retry_key = 'b5tools_wp_type_library_retry_' . $locale; $cached = get_option($cache_key, $locale === 'en' ? get_option(self::OPTION_TYPE_LIBRARY_CACHE, []) : []); $cached_types = is_array($cached) && isset($cached['types']) ? $this->sanitize_personality_type_library($cached['types']) : []; $fetched_at = is_array($cached) ? absint($cached['fetchedAt'] ?? 0) : 0; $max_age = defined('DAY_IN_SECONDS') ? DAY_IN_SECONDS : 86400; $cache_version = is_array($cached) ? (string) ($cached['pluginVersion'] ?? '') : ''; if ($cached_types !== [] && $cache_version === B5TOOLS_WP_VERSION && $fetched_at > time() - $max_age) { return $cached_types; } if (!$this->has_api_key() || get_transient($retry_key)) { return $cached_types; } $response = $this->api_request('GET', '/api/v1/personality-types?locale=' . rawurlencode($locale), null, 6); $types = !empty($response['ok']) ? $this->sanitize_personality_type_library($response['payload']['types'] ?? []) : []; if ($types !== []) { update_option($cache_key, [ 'fetchedAt' => time(), 'pluginVersion' => B5TOOLS_WP_VERSION, 'types' => $types, ], false); delete_transient($retry_key); return $types; } $retry_after = defined('MINUTE_IN_SECONDS') ? 15 * MINUTE_IN_SECONDS : 900; set_transient($retry_key, '1', $retry_after); return $cached_types; } protected function sanitize_personality_type_library($value): array { if (!is_array($value)) { return []; } $allowed = [ 'connector' => 'connector', 'explorer' => 'explorer', 'builder' => 'maker', 'organizer' => 'anchor', 'catalyst' => 'spark', 'harmonizer' => 'harmonizer', 'navigator' => 'wayfinder', 'synthesizer' => 'sensemaker', ]; $sanitized = []; $sanitize_paragraphs = static function ($items): array { if (!is_array($items)) { return []; } return array_values(array_filter(array_map( static fn($item) => sanitize_text_field((string) $item), $items ))); }; foreach ($value as $type) { if (!is_array($type)) { continue; } $id = sanitize_key((string) ($type['id'] ?? '')); $slug = sanitize_key((string) ($type['slug'] ?? '')); if (!isset($allowed[$id]) || $allowed[$id] !== $slug) { continue; } $strengths = []; foreach ((is_array($type['strengths'] ?? null) ? $type['strengths'] : []) as $strength) { if (!is_array($strength)) { continue; } $strengths[] = [ 'title' => sanitize_text_field((string) ($strength['title'] ?? '')), 'body' => sanitize_text_field((string) ($strength['body'] ?? '')), ]; } $accent = sanitize_hex_color((string) ($type['accent'] ?? '')) ?: '#475569'; $sanitized[$id] = [ 'id' => $id, 'slug' => $slug, 'label' => sanitize_text_field((string) ($type['label'] ?? '')), 'accent' => $accent, 'icon' => sanitize_key((string) ($type['icon'] ?? '')), 'tagline' => sanitize_text_field((string) ($type['tagline'] ?? '')), 'introduction' => $sanitize_paragraphs($type['introduction'] ?? []), 'strengths' => $strengths, 'watchFor' => $sanitize_paragraphs($type['watchFor'] ?? []), 'workingTogether' => $sanitize_paragraphs($type['workingTogether'] ?? []), 'questions' => $sanitize_paragraphs($type['questions'] ?? []), 'guideUrl' => esc_url_raw((string) ($type['guideUrl'] ?? '')), ]; } if (count($sanitized) !== count($allowed)) { return []; } $ordered = []; foreach ($allowed as $id => $slug) { if (!isset($sanitized[$id]) || $sanitized[$id]['slug'] !== $slug) { return []; } $ordered[] = $sanitized[$id]; } return $ordered; } /** Trusted, decorative vectors shared by guides, results and social exports. */ protected function personality_character_svgs(): array { static $vectors = null; if ($vectors !== null) return $vectors; $vectors = []; $files = [ 'waypoints' => 'connector', 'telescope' => 'explorer', 'blocks' => 'maker', 'anchor' => 'anchor', 'zap' => 'spark', 'refresh' => 'harmonizer', 'compass' => 'wayfinder', 'diameter' => 'sensemaker', ]; foreach ($files as $icon => $slug) { $path = B5TOOLS_WP_DIR . 'assets/images/personality-characters/' . $slug . '.svg'; if (is_readable($path)) { $vector = file_get_contents($path); if ($vector !== false) $vectors[$icon] = $vector; } } return $vectors; } protected function personality_type_icon_svg(string $icon): string { $vectors = $this->personality_character_svgs(); if (isset($vectors[$icon])) return $vectors[$icon]; $paths = [ 'waypoints' => '', 'telescope' => '', 'blocks' => '', 'anchor' => '', 'zap' => '', 'refresh' => '', 'compass' => '', 'diameter' => '', ]; $content = $paths[$icon] ?? $paths['compass']; return '' . $content . ''; } public function render_archetype_shortcode($atts): string { return ''; } public function render_if_shortcode($atts, $content = null): string { return ''; } public function ajax_config(): void { check_ajax_referer(self::NONCE_ACTION, 'nonce'); $locale = $this->visitor_locale($this->posted_text('locale', '')); $response = $this->api_request('GET', '/api/v1/plugin-config?locale=' . rawurlencode($locale)); if (!empty($response['ok'])) { $this->cache_account_config($response['payload']); } if (!empty($response['ok']) && !$this->pro_addon_available()) { $config =& $response['payload']['config']; foreach (['registeredUserPersistence','advancedAnalytics','conversionTools','leadCapture','pdfExport'] as $feature) $config['features'][$feature] = false; $config['display']['enabledModes'] = ['paged-radio']; $config['display']['enabledThemes'] = ['clean']; $config['display']['enabledDensities'] = ['comfortable']; $config['resultPresentation']['variant'] = 'basic'; $config['resultPresentation']['actions']['pdfExport'] = false; } $response['payload']['config']['display']['enabledSchemes'] = ['light', 'dark']; $response['payload']['config']['display']['lockedSchemes'] = []; $this->send_proxy_response($response); } public function ajax_items(): void { check_ajax_referer(self::NONCE_ACTION, 'nonce'); $locale = $this->posted_text('locale', 'en'); $response = $this->api_request('GET', '/api/v1/items?locale=' . rawurlencode($locale)); $this->send_proxy_response($response); } public function ajax_score(): void { check_ajax_referer(self::NONCE_ACTION, 'nonce'); $decoded_answers = json_decode($this->posted_raw('answers', '[]'), true); if (!is_array($decoded_answers)) { wp_send_json_error(['message' => __('Invalid answers payload.', 'b5-persona')], 400); } $answers = []; foreach ($decoded_answers as $answer) { if (!is_array($answer)) { continue; } $item_id = isset($answer['itemId']) ? sanitize_text_field((string) $answer['itemId']) : ''; $score = isset($answer['score']) ? absint($answer['score']) : 0; if ($item_id !== '' && $score >= 1 && $score <= 5) { $answers[] = [ 'itemId' => $item_id, 'score' => $score, ]; } } if ($answers === []) { wp_send_json_error(['message' => __('No valid answers were submitted.', 'b5-persona')], 400); } $locale = $this->posted_text('locale', 'en'); $response = $this->api_request('POST', '/api/v1/score', [ 'answers' => $answers, 'locale' => $locale, ]); $this->send_proxy_response($response); } public function ajax_event(): void { check_ajax_referer(self::NONCE_ACTION, 'nonce'); $body = [ 'event' => $this->posted_text('event'), 'pageUrl' => $this->posted_text('pageUrl'), 'instanceId' => $this->posted_text('instanceId'), 'attemptId' => $this->posted_text('attemptId'), 'visitorType' => is_user_logged_in() ? 'wp_registered' : 'anonymous', 'visitorId' => is_user_logged_in() ? wp_hash('b5tools-user-' . get_current_user_id()) : $this->posted_text('visitorId'), 'mode' => $this->posted_text('mode'), 'theme' => $this->posted_text('theme'), 'pageIndex' => $this->posted_int('pageIndex'), 'pageCount' => $this->posted_int('pageCount'), 'answeredCount' => $this->posted_int('answeredCount'), 'durationSeconds' => $this->posted_int('durationSeconds'), 'archetypeId' => $this->posted_text('archetypeId'), 'targetId' => $this->posted_text('targetId'), ]; $body = array_filter($body, static function ($value) { return $value !== '' && $value !== null; }); $recorded = $this->record_local_event($body); $response = $this->api_request('POST', '/api/v1/events', $body); update_option(self::OPTION_LAST_EVENT_SYNC, [ 'at' => gmdate('c'), 'status' => $response['ok'] ? 'forwarded' : 'not forwarded', 'message' => $response['ok'] ? __('Remote telemetry accepted.', 'b5-persona') : $this->proxy_error_message($response['payload']), ], false); wp_send_json_success([ 'recorded' => $recorded, 'forwarded' => (bool) $response['ok'], ]); } public function ajax_complete_result(): void { check_ajax_referer(self::NONCE_ACTION, 'nonce'); $payload = json_decode($this->posted_raw('payload', '{}'), true); if (!is_array($payload)) { wp_send_json_error(['message' => __('Invalid completion payload.', 'b5-persona')], 400); } $summary = $this->sanitize_result_summary($payload); if ($summary === null) { wp_send_json_error(['message' => __('The completion result summary was invalid.', 'b5-persona')], 400); } $instance_id = self::text_slice(sanitize_key($this->posted_text('instanceId', 'default')), 0, 80) ?: 'default'; $page_url = $this->posted_text('pageUrl'); $context = $this->completion_payload( $summary, $instance_id, $page_url, self::text_slice(sanitize_key($this->posted_text('resultVariant')), 0, 32), $this->posted_int('durationSeconds'), is_user_logged_in() ? 'b5tools-user-' . get_current_user_id() : $this->posted_text('visitorId') ); do_action( 'b5tools_assessment_completed', $summary['archetype_id'], $instance_id, is_user_logged_in() ? get_current_user_id() : null, $page_url, $context ); $extra = $this->complete_extension_result($summary, $context); wp_send_json_success(['completed' => true] + $extra); } protected function record_local_event(array $body): bool { $event = isset($body['event']) ? sanitize_key((string) $body['event']) : ''; if (!in_array($event, self::EVENTS, true)) { return false; } if (!$this->events_table_exists()) { self::create_events_table(); } global $wpdb; $visitor_id = isset($body['visitorId']) ? (string) $body['visitorId'] : ''; $inserted = $wpdb->insert( self::events_table_name(), [ 'event' => $event, 'page_path' => $this->page_path_from(isset($body['pageUrl']) ? (string) $body['pageUrl'] : ''), 'instance_id' => isset($body['instanceId']) ? self::text_slice(sanitize_key((string) $body['instanceId']), 0, 80) : '', 'attempt_id' => isset($body['attemptId']) ? self::text_slice(sanitize_key((string) $body['attemptId']), 0, 80) : '', 'visitor_type' => isset($body['visitorType']) ? self::text_slice(sanitize_key((string) $body['visitorType']), 0, 24) : '', 'visitor_hash' => $visitor_id !== '' ? wp_hash($visitor_id) : '', 'mode' => isset($body['mode']) ? self::text_slice(sanitize_key((string) $body['mode']), 0, 40) : '', 'theme' => isset($body['theme']) ? self::text_slice(sanitize_key((string) $body['theme']), 0, 40) : '', 'page_index' => $this->nullable_int($body['pageIndex'] ?? null), 'page_count' => $this->nullable_int($body['pageCount'] ?? null), 'answered_count' => $this->nullable_int($body['answeredCount'] ?? null), 'duration_seconds' => $this->nullable_int($body['durationSeconds'] ?? null), 'archetype_id' => isset($body['archetypeId']) ? self::text_slice(sanitize_key((string) $body['archetypeId']), 0, 80) : '', 'target_id' => isset($body['targetId']) ? self::text_slice(sanitize_text_field((string) $body['targetId']), 0, 100) : '', 'created_at' => current_time('mysql', true), ], [ '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s', ] ); return $inserted !== false; } protected function page_path_from(string $url): string { if ($url === '') { return ''; } $parts = wp_parse_url($url); if (is_array($parts) && !empty($parts['path'])) { return self::text_slice(sanitize_text_field($parts['path']), 0, 300); } if (strpos($url, '/') === 0) { return self::text_slice(sanitize_text_field($url), 0, 300); } return ''; } protected function sanitize_result_summary(array $payload): ?array { $result = isset($payload['result']) && is_array($payload['result']) ? $payload['result'] : $payload; $primary = isset($result['primary']) && is_array($result['primary']) ? $result['primary'] : []; $archetype_id = isset($primary['id']) ? self::text_slice(sanitize_key((string) $primary['id']), 0, 80) : ''; if ($archetype_id === '') { return null; } $clean = [ 'locale' => $this->visitor_locale((string) ($result['locale'] ?? $this->posted_text('locale', ''))), 'modelVersion' => isset($result['modelVersion']) ? self::text_slice(sanitize_text_field((string) $result['modelVersion']), 0, 100) : '', 'miniIpipModelVersion' => isset($result['miniIpipModelVersion']) ? self::text_slice(sanitize_text_field((string) $result['miniIpipModelVersion']), 0, 100) : '', 'archetypeModelVersion' => isset($result['archetypeModelVersion']) ? self::text_slice(sanitize_text_field((string) $result['archetypeModelVersion']), 0, 100) : '', 'primary' => [ 'id' => $archetype_id, 'label' => isset($primary['label']) ? self::text_slice(sanitize_text_field((string) $primary['label']), 0, 120) : '', 'description' => isset($primary['description']) ? self::text_slice(sanitize_textarea_field((string) $primary['description']), 0, 600) : '', 'workStyle' => isset($primary['workStyle']) ? self::text_slice(sanitize_textarea_field((string) $primary['workStyle']), 0, 600) : '', ], ]; $secondary = $this->sanitize_result_secondary($result['secondary'] ?? null, $archetype_id); if ($secondary !== null) { $clean['secondary'] = $secondary; } $traits = $this->sanitize_result_traits($result['traits'] ?? null); if ($traits !== []) { $clean['traits'] = $traits; } $email_details = $this->sanitize_result_email_details($result['emailDetails'] ?? null); if ($email_details !== []) { $clean['emailDetails'] = $email_details; } return [ 'archetype_id' => $archetype_id, 'result_model_version' => $clean['modelVersion'], 'mini_ipip_model_version' => $clean['miniIpipModelVersion'], 'archetype_model_version' => $clean['archetypeModelVersion'], 'payload' => $clean, ]; } protected function sanitize_result_secondary($value, string $primary_id): ?array { if (!is_array($value)) { return null; } $id = isset($value['id']) ? self::text_slice(sanitize_key((string) $value['id']), 0, 80) : ''; $label = isset($value['label']) ? self::text_slice(sanitize_text_field((string) $value['label']), 0, 120) : ''; if ($id === '' || $id === $primary_id || $label === '') { return null; } return [ 'id' => $id, 'label' => $label, ]; } protected function sanitize_result_traits($value): array { if (!is_array($value)) { return []; } $allowed = [ 'extraversion', 'agreeableness', 'conscientiousness', 'emotionalStability', 'openness', ]; $traits = []; foreach ($allowed as $key) { if (!array_key_exists($key, $value) || !is_numeric($value[$key])) { continue; } $traits[$key] = max(0, min(1, (float) $value[$key])); } return $traits; } protected function sanitize_result_email_details($value): array { if (!is_array($value)) { return []; } $clean = []; $text_fields = [ 'headline' => 220, 'depth' => 800, 'secondaryLabel' => 120, 'whyTitle' => 120, 'why' => 700, 'traitTitle' => 120, 'traitIntro' => 260, ]; foreach ($text_fields as $key => $limit) { if (!isset($value[$key])) { continue; } $text = self::text_slice(sanitize_textarea_field((string) $value[$key]), 0, $limit); if ($text !== '') { $clean[$key] = $text; } } if (isset($value['facets']) && is_array($value['facets'])) { $facets = []; foreach ($value['facets'] as $facet) { if (!is_array($facet)) { continue; } $label = isset($facet['label']) ? self::text_slice(sanitize_text_field((string) $facet['label']), 0, 100) : ''; $text = isset($facet['value']) ? self::text_slice(sanitize_textarea_field((string) $facet['value']), 0, 700) : ''; if ($label === '' || $text === '') { continue; } $facets[] = [ 'label' => $label, 'value' => $text, ]; if (count($facets) >= 4) { break; } } if ($facets !== []) { $clean['facets'] = $facets; } } if (isset($value['traitItems']) && is_array($value['traitItems'])) { $trait_items = []; foreach ($value['traitItems'] as $item) { if (!is_array($item) || !isset($item['value']) || !is_numeric($item['value'])) { continue; } $label = isset($item['label']) ? self::text_slice(sanitize_text_field((string) $item['label']), 0, 100) : ''; if ($label === '') { continue; } $trait_items[] = [ 'key' => isset($item['key']) ? self::text_slice(sanitize_key((string) $item['key']), 0, 80) : '', 'label' => $label, 'leftPole' => isset($item['leftPole']) ? self::text_slice(sanitize_text_field((string) $item['leftPole']), 0, 80) : '', 'rightPole' => isset($item['rightPole']) ? self::text_slice(sanitize_text_field((string) $item['rightPole']), 0, 80) : '', 'value' => max(0, min(1, (float) $item['value'])), ]; if (count($trait_items) >= 5) { break; } } if ($trait_items !== []) { $clean['traitItems'] = $trait_items; } } return $clean; } protected function nullable_int($value): ?int { if ($value === null || $value === '') { return null; } return max(0, absint($value)); } protected function truthy_shortcode_value(string $value): bool { return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true); } protected function send_proxy_response(array $response): void { $status = isset($response['status']) ? max(200, (int) $response['status']) : 500; if (!empty($response['ok'])) { wp_send_json_success($response['payload'], $status); } if ($this->is_account_suspended_response($response)) { $status = 503; } // Public assessment requests never forward provider messages or details. // Admin connection diagnostics continue to use the original response. wp_send_json_error($this->visitor_proxy_error($response), $status); } protected function visitor_proxy_error(array $response): array { $status = (int) ($response['status'] ?? 500); $source_code = $response['payload']['error']['code'] ?? ''; $code = 'assessment_unavailable'; $message = 'The assessment is temporarily unavailable. Please try again shortly.'; if ($status === 400 && $source_code === 'missing_answers') { $code = 'answers_incomplete'; $message = 'Please answer every statement before viewing your result.'; } elseif ($status === 400 && in_array($source_code, ['invalid_answers_payload', 'invalid_score', 'unknown_item_id', 'duplicate_item_id'], true)) { $code = 'answers_invalid'; $message = 'We couldn’t read your answers. Please review them and try again.'; } elseif ($status === 429 && $source_code !== 'quota_exceeded') { $code = 'rate_limited'; $message = 'Please wait a moment before trying again.'; } $locale = $this->visitor_locale($this->posted_text('locale', '')); return ['error' => ['code' => $code, 'message' => $this->visitor_text($message, $locale)]]; } protected function connect_exchange_request(string $code, string $state, string $token): array { $args = [ 'method' => 'POST', 'timeout' => 20, 'headers' => [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'Origin' => $this->site_origin(), 'Referer' => home_url('/'), ], 'body' => wp_json_encode([ 'code' => $code, 'state' => $state, 'token' => $token, 'siteHost' => $this->site_host(), 'pluginVersion' => B5TOOLS_WP_VERSION, ]), ]; $response = wp_remote_request($this->get_api_base_url() . '/api/v1/connect/wordpress/exchange', $args); if (is_wp_error($response)) { return [ 'ok' => false, 'status' => 500, 'payload' => [ 'error' => [ 'code' => 'wordpress_http_error', 'message' => $response->get_error_message(), ], ], ]; } $status = (int) wp_remote_retrieve_response_code($response); $payload = json_decode((string) wp_remote_retrieve_body($response), true); if (!is_array($payload)) { return [ 'ok' => false, 'status' => 502, 'payload' => [ 'error' => [ 'code' => 'invalid_api_response', 'message' => __('B5Tools returned an invalid connect response.', 'b5-persona'), ], ], ]; } return [ 'ok' => $status >= 200 && $status < 300, 'status' => $status > 0 ? $status : 500, 'payload' => $payload, ]; } protected function api_request(string $method, string $path, ?array $body = null, int $timeout = 20): array { $api_key = (string) get_option(self::OPTION_API_KEY, ''); if ($api_key === '') { return [ 'ok' => false, 'status' => 400, 'payload' => ['message' => __('Missing B5Tools API key.', 'b5-persona')], ]; } $headers = [ 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $api_key, 'Origin' => $this->site_origin(), 'Referer' => home_url('/'), ]; $args = [ 'method' => $method, 'timeout' => max(1, $timeout), 'headers' => $headers, ]; if ($body !== null) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode($body); } $response = wp_remote_request($this->get_api_base_url() . $path, $args); if (is_wp_error($response)) { return [ 'ok' => false, 'status' => 500, 'payload' => [ 'error' => [ 'code' => 'wordpress_http_error', 'message' => $response->get_error_message(), ], ], ]; } $status = (int) wp_remote_retrieve_response_code($response); $raw_body = (string) wp_remote_retrieve_body($response); $payload = json_decode($raw_body, true); if (!is_array($payload)) { return [ 'ok' => false, 'status' => 502, 'payload' => [ 'error' => [ 'code' => 'invalid_api_response', 'message' => __('B5Tools returned an invalid response.', 'b5-persona'), ], ], ]; } $result = [ 'ok' => $status >= 200 && $status < 300, 'status' => $status > 0 ? $status : 500, 'payload' => $payload, ]; if ($this->is_invalid_credential_response($result)) { delete_option(self::OPTION_API_KEY); delete_option(self::OPTION_ACCOUNT_CACHE); } elseif ($this->is_account_suspended_response($result)) { $this->mark_account_suspended($this->proxy_error_message($payload)); } return $result; } protected function is_invalid_credential_response(array $response): bool { if ((int) ($response['status'] ?? 0) !== 401) { return false; } $payload = isset($response['payload']) && is_array($response['payload']) ? $response['payload'] : []; $code = isset($payload['error']['code']) && is_string($payload['error']['code']) ? sanitize_key($payload['error']['code']) : ''; return in_array($code, ['invalid_api_key', 'key_revoked', 'unauthorized'], true); } protected function is_account_suspended_response(array $response): bool { if ((int) ($response['status'] ?? 0) !== 403) { return false; } $payload = isset($response['payload']) && is_array($response['payload']) ? $response['payload'] : []; $code = isset($payload['error']['code']) && is_string($payload['error']['code']) ? sanitize_key($payload['error']['code']) : ''; return $code === 'account_suspended'; } protected function mark_account_suspended(string $message): void { $account = get_option(self::OPTION_ACCOUNT_CACHE, []); if (!is_array($account)) { $account = []; } if (!isset($account['entitlement']) || !is_array($account['entitlement'])) { $account['entitlement'] = []; } $account['entitlement']['suspended'] = true; $account['entitlement']['suspensionMessage'] = sanitize_text_field($message); update_option(self::OPTION_ACCOUNT_CACHE, $account, false); } protected function account_suspension_message(array $account): string { return __('Your B5Tools account is suspended. Open your B5Tools account to review its status.', 'b5-persona'); } protected function proxy_error_message(array $payload): string { if (isset($payload['error']['message']) && is_string($payload['error']['message'])) { return $payload['error']['message']; } if (isset($payload['message']) && is_string($payload['message'])) { return $payload['message']; } return __('B5Tools API request failed.', 'b5-persona'); } protected function connect_error_message(string $code): string { $messages = [ 'site_limit_reached' => __('This B5Tools account has no available live-site slots. Open Sites or Billing in your B5Tools account, then try again.', 'b5-persona'), 'quota_exceeded' => __('This B5Tools account has used its monthly live assessment allowance. Review usage or billing in your B5Tools account.', 'b5-persona'), 'site_not_connected' => __('This WordPress site is no longer connected to B5Tools. Connect the site again to continue.', 'b5-persona'), 'domain_mismatch' => __('This WordPress domain does not match the saved B5Tools site connection. Reconnect the site to verify this domain.', 'b5-persona'), 'billing_past_due' => __('Billing for this B5Tools account needs attention. Open Billing in B5Tools, then try again.', 'b5-persona'), 'key_revoked' => __('This site credential has been revoked. Connect the site again to create a new credential.', 'b5-persona'), 'account_suspended' => __('This B5Tools account is suspended. Open the B5Tools account to review its status.', 'b5-persona'), ]; return $messages[$code] ?? __('B5Tools could not connect this site. Try again or open your B5Tools account for details.', 'b5-persona'); } protected function get_api_base_url(): string { // Explicit loopback override for isolated local integration QA only. if (wp_get_environment_type() === 'local' && defined('B5TOOLS_WP_LOCAL_API_BASE_URL')) { $local = rtrim((string) B5TOOLS_WP_LOCAL_API_BASE_URL, '/'); if (in_array(wp_parse_url($local, PHP_URL_HOST), ['127.0.0.1', 'localhost'], true) && in_array(wp_parse_url($local, PHP_URL_SCHEME), ['http', 'https'], true)) { return $local; } } return self::DEFAULT_API_BASE_URL; } protected function has_api_key(): bool { return (string) get_option(self::OPTION_API_KEY, '') !== ''; } protected function manual_api_key_entry_enabled(): bool { return defined('B5TOOLS_WP_ENABLE_MANUAL_KEY') && B5TOOLS_WP_ENABLE_MANUAL_KEY === true; } protected function browser_persistence_enabled(): bool { return (string) get_option(self::OPTION_BROWSER_PERSISTENCE, '1') === '1'; } protected function result_sharing_enabled(): bool { return (string) get_option(self::OPTION_RESULT_SHARING, '1') === '1'; } protected function type_guide_link_enabled(): bool { return (string) get_option(self::OPTION_TYPE_GUIDE_LINK, '0') === '1'; } protected function type_library_page_id(): int { return absint(get_option(self::OPTION_TYPE_LIBRARY_PAGE_ID, 0)); } protected function type_library_page_url(): string { $page_id = $this->type_library_page_id(); if ($page_id <= 0 || get_post_status($page_id) !== 'publish') { return ''; } $url = get_permalink($page_id); return is_string($url) ? esc_url_raw($url) : ''; } protected function display_mode(): string { $value = $this->sanitize_display_mode((string) get_option(self::OPTION_DISPLAY_MODE, 'paged-radio')); return in_array($value, $this->enabled_modes(), true) ? $value : 'paged-radio'; } protected function theme_raw(): string { return (string) get_option(self::OPTION_THEME, 'clean'); } protected function style_source(): string { return $this->sanitize_style_source((string) get_option(self::OPTION_STYLE_SOURCE, 'b5tools')); } protected function theme(): string { $value = $this->sanitize_theme($this->theme_raw()); // Legacy values: "dark" and "compact" were themes before the // theme/scheme/density split; they fold into the Clean identity. if ($value === 'dark' || $value === 'compact') { return 'clean'; } $value = $value; return in_array($value, $this->enabled_themes(), true) ? $value : 'clean'; } protected function scheme(): string { $stored = (string) get_option(self::OPTION_SCHEME, ''); if ($stored !== '') { return $this->sanitize_scheme($stored); } // Legacy: a stored "dark" theme implies the dark scheme. return $this->theme_raw() === 'dark' ? $this->sanitize_scheme('dark') : 'light'; } protected function density(): string { $stored = (string) get_option(self::OPTION_DENSITY, ''); if ($stored !== '') { return $this->sanitize_density($stored); } // Legacy: a stored "compact" theme implies compact density. $value = $this->theme_raw() === 'compact' ? $this->sanitize_density('compact') : 'comfortable'; return in_array($value, $this->enabled_densities(), true) ? $value : 'comfortable'; } protected function masked_api_key(): string { $api_key = (string) get_option(self::OPTION_API_KEY, ''); if ($api_key === '') { return __('none', 'b5-persona'); } $environment = strpos($api_key, 'b5t_live_') === 0 ? 'live' : (strpos($api_key, 'b5t_test_') === 0 ? 'test' : 'unknown'); $prefix = self::text_slice($api_key, 0, 16); return sprintf('%s key - %s...', $environment, $prefix); } protected function api_key_prefix(): string { $account = $this->account_config(); if (!empty($account['apiKey']['prefix'])) { return self::text_slice(sanitize_text_field((string) $account['apiKey']['prefix']), 0, 80); } $api_key = (string) get_option(self::OPTION_API_KEY, ''); return $api_key !== '' ? self::text_slice(sanitize_text_field($api_key), 0, 16) : 'unknown'; } protected function site_origin(): string { $parts = wp_parse_url(home_url('/')); if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { return home_url('/'); } $origin = $parts['scheme'] . '://' . $parts['host']; if (!empty($parts['port'])) { $origin .= ':' . $parts['port']; } return $origin; } protected function site_host(): string { $parts = wp_parse_url(home_url('/')); if (!is_array($parts) || empty($parts['host'])) { return home_url('/'); } $host = $parts['host']; if (!empty($parts['port'])) { $host .= ':' . $parts['port']; } return $host; } protected function posted_text(string $key, string $default = ''): string { if (!isset($_POST[$key]) || is_array($_POST[$key])) { return $default; } return sanitize_text_field(wp_unslash($_POST[$key])); } protected function posted_raw(string $key, string $default = ''): string { // Preserve JSON syntax; AJAX callers verify the nonce, decode the JSON // and sanitize each field before it reaches storage or the scoring API. if (!isset($_POST[$key]) || is_array($_POST[$key])) { return $default; } return (string) wp_unslash($_POST[$key]); } protected function posted_int(string $key): ?int { if (!isset($_POST[$key]) || is_array($_POST[$key]) || $_POST[$key] === '') { return null; } return absint($_POST[$key]); } protected function settings_tabs(): array { return [ 'connection' => __('Connection', 'b5-persona'), 'display' => __('Display', 'b5-persona'), 'analytics' => __('Insights', 'b5-persona'), 'privacy' => __('Data & Privacy', 'b5-persona'), 'pro' => __('Pro', 'b5-persona'), ]; } protected function current_settings_tab(): string { $tab = isset($_GET['tab']) && !is_array($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'connection'; // Backward-compatible deep links for tabs that were renamed or merged. $legacy = [ 'results' => 'privacy', // saved results now live in Data & Privacy 'growth' => 'convert', // Growth split; land on Convert 'diagnostics' => 'connection', // folded into Connect ]; if (isset($legacy[$tab])) { $tab = $legacy[$tab]; } return array_key_exists($tab, $this->settings_tabs()) ? $tab : 'connection'; } protected function admin_notice_transient_key(): string { return 'b5tools_wp_admin_notice_' . get_current_user_id(); } protected function connect_transient_key(string $state): string { return 'b5tools_wp_connect_' . md5($state); } protected function register_extension_hooks(): void { } protected function register_extension_settings(): void { } protected function pro_addon_available(): bool { return false; } protected function admin_version_label(): string { if ($this->pro_addon_available() && defined('B5TOOLS_WP_PRO_VERSION')) { return sprintf(/* translators: %s: Installed Pro plugin version. */ __('Pro %s', 'b5-persona'), 'v' . B5TOOLS_WP_PRO_VERSION); } return sprintf(/* translators: %s: Installed Free plugin version. */ __('Free %s', 'b5-persona'), 'v' . B5TOOLS_WP_VERSION); } protected function admin_version_details(): string { $core = sprintf(/* translators: %s: Installed Free plugin version. */ __('B5 Persona %s', 'b5-persona'), 'v' . B5TOOLS_WP_VERSION); if ($this->pro_addon_available() && defined('B5TOOLS_WP_PRO_VERSION')) { return sprintf(/* translators: %s: Installed Pro plugin version. */ __('B5Tools Pro %s', 'b5-persona'), 'v' . B5TOOLS_WP_PRO_VERSION) . ' · ' . $core; } return $core; } protected function extension_runtime_settings(): array { return []; } protected function enqueue_extension_runtime(): void { } protected function enqueue_extension_admin_assets(): void { } protected function render_extension_tab($tab): void { $this->render_pro_tab(); } protected function render_extension_privacy(): void { } protected function complete_extension_result(array $result, array $context): array { return ['savedToAccount' => false, 'webhookDelivered' => false]; } protected function pro_url(): string { return admin_url('admin.php?page=b5tools-wp&tab=pro'); } protected function sanitize_product_id_list($value): array { $raw_ids = is_array($value) ? $value : (preg_split('/[\s,]+/', (string) $value) ?: []); $ids = []; foreach ($raw_ids as $raw_id) { $product_id = absint($raw_id); if ($product_id <= 0 || in_array($product_id, $ids, true)) { continue; } $ids[] = $product_id; if (count($ids) >= 3) { break; } } return $ids; } protected function completion_payload( array $summary, string $instance_id, string $page_url, string $result_variant, ?int $duration_seconds, string $visitor_id ): array { $result = isset($summary['payload']) && is_array($summary['payload']) ? $summary['payload'] : []; $primary = isset($result['primary']) && is_array($result['primary']) ? $result['primary'] : []; $payload = [ 'event' => 'b5tools_assessment_completed', 'occurredAt' => gmdate('c'), 'site' => [ 'url' => home_url('/'), 'domain' => $this->site_host(), ], 'plugin' => [ 'name' => 'b5tools-wp', 'version' => B5TOOLS_WP_VERSION, ], 'instanceId' => $instance_id, 'pageUrl' => $page_url, 'pagePath' => $this->page_path_from($page_url), 'visitorType' => is_user_logged_in() ? 'wp_registered' : 'anonymous', 'visitorHash' => $visitor_id !== '' ? wp_hash($visitor_id) : '', 'archetypeId' => $summary['archetype_id'], 'archetypeLabel' => isset($primary['label']) ? (string) $primary['label'] : '', 'resultVariant' => $result_variant !== '' ? $result_variant : 'basic', 'result' => $result, ]; if ($duration_seconds !== null) { $payload['durationSeconds'] = $duration_seconds; } if (is_user_logged_in()) { $payload['user'] = [ 'id' => get_current_user_id(), ]; } return $payload; } protected function render_pro_context(string $title, string $description, string $context = 'display'): void { // Installed Pro owners use the access/recovery screen, not upgrade previews. if ($this->pro_addon_available()) return; require B5TOOLS_WP_DIR . 'includes/pro-context.php'; } protected function render_pro_tab(): void { if ($this->pro_addon_available()) { $pro_access = $this->verified_pro_access($this->account_config()); require B5TOOLS_WP_DIR . 'includes/pro-status.php'; return; } $locale = $this->visitor_locale(determine_locale()); $call = function ($name, ...$args) { return $this->$name(...$args); }; $sample = $this->sample_analytics(); $asset_url = B5TOOLS_WP_URL . 'assets/img/pro/'; $paid = $this->verified_pro_access($this->account_config()) && !empty($this->account_config()['entitlement']['proDownloadEligible']); $plans_url = $this->get_api_base_url() . ($paid ? '/dashboard/downloads' : '/pricing'); $pro_action_label = $paid ? __('Download B5Tools Pro', 'b5-persona') : __('View Pro plans', 'b5-persona'); require B5TOOLS_WP_DIR . 'includes/pro-showcase.php'; } protected function verified_pro_access(array $account): bool { if (!$this->has_api_key() || ($account['plan']['id'] ?? '') !== 'pro' || !empty($account['entitlement']['suspended'])) return false; $verified = (int) ($account['_verified_at'] ?? 0); // Legacy caches get one bounded migration window, never a rolling one. if (!$verified) { $verified = (int) get_option('b5tools_wp_cache_migration_at', 0); if (!$verified) { $verified = time(); add_option('b5tools_wp_cache_migration_at', $verified, '', false); } } if (time() - $verified >= 2 * DAY_IN_SECONDS) return false; $boundary = (string) ($account['entitlement']['proAccessExpiresAt'] ?? ''); return $boundary === '' || (strtotime($boundary) !== false && strtotime($boundary) > time()); } // Server-side connection bridge for dependent extensions. Never sent to JS. public function extension_service_context(): array { return ['base_url' => $this->get_api_base_url(), 'api_key' => (string) get_option(self::OPTION_API_KEY, ''), 'origin' => $this->site_origin(), 'pro_access' => $this->verified_pro_access($this->account_config())]; } protected function render_extension_shortcodes(): void {} }