';
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 '
';
}
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
{
?>
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 '
' . $path . ' ';
}
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 = '
';
$svg .= ' ';
if ($is_editorial) {
$svg .= ' ';
$svg .= ' ';
$svg .= ' ';
} else {
$svg .= ' ';
}
if ($mode === 'slider') {
$svg .= ' ';
$svg .= ' ';
foreach ([16, 38, 60, 82, 104] as $cx) {
$svg .= ' ';
}
$svg .= ' ';
} else {
foreach ([30, 44] as $i => $y) {
$svg .= ' ';
$svg .= ' ';
}
$svg .= ' ';
}
$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 = '
';
$svg .= ' ';
if ($density === 'compact') {
$svg .= ' ';
foreach ([14, 22, 30, 38, 46] as $y) {
$svg .= ' ';
}
$svg .= ' ';
} else {
$svg .= ' ';
$svg .= ' ';
$svg .= ' ';
}
$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(
'
',
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();
?>
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('Previous type', $locale)); ?>
visitor_text('Next type', $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 {}
}