Go Pro ⭐', 'manage_options', 'awbseo-go-pro', [ __CLASS__, 'render_go_pro' ] ); } // ── AI Agents (MCP) - its own visible sidebar entry, not a hidden // tool page, since site owners need to find it to grab the // connection key. Registered unconditionally (free and Pro alike). add_submenu_page( 'awbseo', __( 'AI Agents (MCP)', 'adworksbid-seo' ), __( '🤖 AI Agents (MCP)', 'adworksbid-seo' ), 'manage_options', 'awbseo-mcp', [ __CLASS__, 'render_mcp' ] ); // ── Hidden tool pages - registered for direct URL access, removed from sidebar below ── add_submenu_page( 'awbseo', __( 'Bulk Editor', 'adworksbid-seo' ), __( 'Bulk Editor', 'adworksbid-seo' ), 'manage_options', 'awbseo-bulk', [ __CLASS__, 'render_bulk_editor' ] ); add_submenu_page( 'awbseo', __( 'Rank Tracker', 'adworksbid-seo' ), __( 'Rank Tracker', 'adworksbid-seo' ), 'edit_posts', 'awbseo-rank-tracker', [ __CLASS__, 'render_rank_tracker' ] ); add_submenu_page( 'awbseo', __( 'Sitemap', 'adworksbid-seo' ), __( 'Sitemap', 'adworksbid-seo' ), 'manage_options', 'awbseo-sitemap', [ __CLASS__, 'render_sitemap' ] ); add_submenu_page( 'awbseo', __( 'Instant Indexing', 'adworksbid-seo' ), __( 'Instant Indexing', 'adworksbid-seo' ), 'manage_options', 'awbseo-instant-indexing',[ __CLASS__, 'render_instant_indexing' ] ); add_submenu_page( 'awbseo', __( 'Robots.txt', 'adworksbid-seo' ), __( 'Robots.txt', 'adworksbid-seo' ), 'manage_options', 'awbseo-robots-txt', [ __CLASS__, 'render_robots_txt' ] ); add_submenu_page( 'awbseo', __( 'Redirects', 'adworksbid-seo' ), __( 'Redirects', 'adworksbid-seo' ), 'manage_options', 'awbseo-redirects', [ __CLASS__, 'render_redirects' ] ); add_submenu_page( 'awbseo', __( 'Competing Pages', 'adworksbid-seo' ), __( 'Competing Pages', 'adworksbid-seo' ), 'edit_posts', 'awbseo-cannibalization', [ __CLASS__, 'render_cannibalization' ] ); add_submenu_page( 'awbseo', __( 'Fading Content', 'adworksbid-seo' ), __( 'Fading Content', 'adworksbid-seo' ), 'edit_posts', 'awbseo-decay', [ __CLASS__, 'render_decay' ] ); add_submenu_page( 'awbseo', __( 'Schema', 'adworksbid-seo' ), __( 'Schema', 'adworksbid-seo' ), 'manage_options', 'awbseo-schema', [ __CLASS__, 'render_schema' ] ); // WooCommerce SEO is toggleable in Feature Manager (Pro) too - not // every WooCommerce site wants CitedSEO managing product SEO. if ( class_exists( 'WooCommerce' ) && self::is_feature_enabled( 'woocommerce_seo' ) ) { add_submenu_page( 'awbseo', __( 'WooCommerce SEO', 'adworksbid-seo' ), __( 'WooCommerce SEO', 'adworksbid-seo' ), 'manage_options', 'awbseo-woo', [ __CLASS__, 'render_woocommerce_seo' ] ); } add_submenu_page( 'awbseo', __( 'Local SEO', 'adworksbid-seo' ), __( 'Local SEO', 'adworksbid-seo' ), 'manage_options', 'awbseo-local', [ __CLASS__, 'render_local_seo' ] ); AWBSEO_Arabic_Suite::register_pages(); add_submenu_page( 'awbseo', __( 'Broken Links', 'adworksbid-seo' ), __( 'Broken Links', 'adworksbid-seo' ), 'manage_options', 'awbseo-broken-links', [ __CLASS__, 'render_broken_links' ] ); // White Label is now reached as a card on Agency Fleet, not its own // sidebar row - the settings form itself is still gated behind Pro, // free users see a locked upsell card instead (see render_white_label()). // Registered/unregistered together with the Agency Fleet category // page above, via the same Feature Manager toggle. if ( $agency_fleet_enabled ) { add_submenu_page( 'awbseo', __( 'White Label', 'adworksbid-seo' ), __( 'White Label', 'adworksbid-seo' ), 'manage_options', 'awbseo-white-label', [ __CLASS__, 'render_white_label' ] ); } /** * Fires after the free AWBSEO admin menu is registered. * Pro plugin uses this hook to add its own submenu pages under awbseo. * * @since 1.0.0 */ do_action( 'awbseo_admin_menu' ); } /** * Enqueue the menu icon fix on every admin page so the sidebar icon * renders at the correct 20×20 px size regardless of which page is open. * Uses a virtual (src-less) stylesheet handle so no HTTP request is made. * * Tool pages (rank tracker, sitemap, etc.) are hidden from the sidebar via * CSS :has() selectors rather than remove_submenu_page(). Using * remove_submenu_page() strips the entry from $submenu, causing WordPress * to compute a different hookname on the next request and reject the URL * with "you are not allowed to access this page." * * Pro adds its own hidden slugs via the awbseo_hidden_tool_slugs filter. */ public static function enqueue_menu_icon_css() { wp_register_style( 'awbseo-menu-icon', false, [], AWBSEO_VERSION ); wp_enqueue_style( 'awbseo-menu-icon' ); $hidden_slugs = apply_filters( 'awbseo_hidden_tool_slugs', [ 'awbseo-rank-tracker', 'awbseo-sitemap', 'awbseo-instant-indexing', 'awbseo-robots-txt', 'awbseo-redirects', 'awbseo-cannibalization', 'awbseo-decay', 'awbseo-schema', 'awbseo-woo', 'awbseo-local', 'awbseo-broken-links', 'awbseo-bulk', 'awbseo-portability', 'awbseo-white-label', ] ); $hidden_slugs = array_merge( $hidden_slugs, AWBSEO_Arabic_Suite::hidden_slugs() ); $hide_css = ''; foreach ( $hidden_slugs as $slug ) { $hide_css .= '#toplevel_page_awbseo .wp-submenu li:has(>a[href*="page=' . esc_attr( $slug ) . '"]){display:none!important}'; } // Hide the duplicate "CitedSEO" entry WordPress auto-generates for the // parent slug. Uses href$= (ends-with) so it matches only page=awbseo // and not page=awbseo-analytics or any other prefixed slug. $hide_css .= '#toplevel_page_awbseo .wp-submenu li:has(>a[href$="page=awbseo"]){display:none!important}'; $wl_css = AWBSEO_White_Label::inline_css(); wp_add_inline_style( 'awbseo-menu-icon', '#toplevel_page_awbseo .wp-menu-image img{width:20px!important;height:20px!important;padding:6px 0 0!important;filter:none!important;opacity:1!important;}' . '#toplevel_page_awbseo .wp-submenu li a.awbseo-plain-item::before{content:"\2799";margin-right:6px;font-style:normal;}' . '#toplevel_page_awbseo .wp-submenu li.awbseo-has-flyout > a::after{content:"\276f";float:right;opacity:.45;font-size:10px;margin-top:3px;}' . '.awbseo-goal-flyout{position:fixed;display:none;z-index:100000;background:#1d2327;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,.35);padding:6px 0;min-width:190px;}' . '.awbseo-goal-flyout__item{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 14px;color:#f0f0f1;font-size:13px;line-height:1.4;text-decoration:none;white-space:nowrap;}' . '.awbseo-goal-flyout__item:hover,.awbseo-goal-flyout__item:focus{background:#2271b1;color:#fff;}' . '.awbseo-goal-flyout__pro{font-size:9px;font-weight:700;background:#6d3fc0;color:#fff;padding:1px 5px;border-radius:3px;}' . $hide_css . $wl_css ); // Add the arrow only to submenu items whose text doesn't already start // with a non-ASCII character (emoji, icon). Self-adapting as Pro adds items. // Also builds the sidebar's hover flyout: each goal's sub-tools stay out // of the sidebar itself (see $hidden_slugs above) and only appear in a // small flyout menu while hovering that goal, so the sidebar itself // never shows the old messy nested list. wp_register_script( 'awbseo-menu-arrow', false, [], AWBSEO_VERSION, true ); wp_enqueue_script( 'awbseo-menu-arrow' ); wp_localize_script( 'awbseo-menu-arrow', 'AWBSEO_FLYOUT', [ 'goals' => self::sidebar_flyout_map(), 'proBadge' => __( 'PRO', 'adworksbid-seo' ), ] ); wp_add_inline_script( 'awbseo-menu-arrow', " (function(){ function awbseoMarkArrows(){ document.querySelectorAll('#toplevel_page_awbseo .wp-submenu li a').forEach(function(a){ var first = (a.textContent.trim() || '').codePointAt(0); if(first !== undefined && first <= 127){ a.classList.add('awbseo-plain-item'); } }); } function awbseoBuildFlyouts(){ if (typeof window.AWBSEO_FLYOUT === 'undefined') return; var map = window.AWBSEO_FLYOUT.goals || {}; var proLabel = window.AWBSEO_FLYOUT.proBadge || 'PRO'; var submenu = document.querySelector('#toplevel_page_awbseo .wp-submenu'); if (!submenu) return; var flyout = document.createElement('div'); flyout.className = 'awbseo-goal-flyout'; document.body.appendChild(flyout); var hideTimer = null; function fill(tools){ flyout.innerHTML = ''; tools.forEach(function(t){ var a = document.createElement('a'); a.href = t.url; a.className = 'awbseo-goal-flyout__item'; a.appendChild(document.createTextNode(t.name)); if (t.pro) { var badge = document.createElement('span'); badge.className = 'awbseo-goal-flyout__pro'; badge.textContent = proLabel; a.appendChild(badge); } flyout.appendChild(a); }); } function show(li, tools){ clearTimeout(hideTimer); fill(tools); var rect = li.getBoundingClientRect(); flyout.style.top = Math.round(rect.top) + 'px'; flyout.style.left = Math.round(rect.right) + 'px'; flyout.style.display = 'block'; } function scheduleHide(){ clearTimeout(hideTimer); hideTimer = setTimeout(function(){ flyout.style.display = 'none'; }, 250); } flyout.addEventListener('mouseenter', function(){ clearTimeout(hideTimer); }); flyout.addEventListener('mouseleave', scheduleHide); Object.keys(map).forEach(function(slug){ var tools = map[slug]; if (!tools || !tools.length) return; var link = submenu.querySelector('a[href*=\"page=' + slug + '\"]'); if (!link) return; var li = link.closest('li'); if (!li) return; li.classList.add('awbseo-has-flyout'); li.addEventListener('mouseenter', function(){ show(li, tools); }); li.addEventListener('mouseleave', scheduleHide); }); window.addEventListener('scroll', function(){ flyout.style.display = 'none'; }, true); window.addEventListener('resize', function(){ flyout.style.display = 'none'; }); } function awbseoMenuInit(){ awbseoMarkArrows(); awbseoBuildFlyouts(); } if(document.readyState === 'loading'){ document.addEventListener('DOMContentLoaded', awbseoMenuInit); } else { awbseoMenuInit(); } })(); " ); } public static function enqueue_assets( $hook ) { if ( strpos( $hook, 'adworksbid-seo' ) === false && strpos( $hook, 'awbseo' ) === false && strpos( $hook, 'awb-setup-wizard' ) === false && $hook !== 'post.php' && $hook !== 'post-new.php' && $hook !== 'edit.php' ) return; wp_enqueue_style( 'awbseo-admin', AWBSEO_PLUGIN_URL . 'admin/assets/css/admin.css', [], AWBSEO_VERSION ); wp_style_add_data( 'awbseo-admin', 'rtl', 'replace' ); // loads admin-rtl.css on RTL sites wp_enqueue_style( 'awbseo-admin-inline', AWBSEO_PLUGIN_URL . 'admin/assets/css/admin-inline.css', [ 'awbseo-admin' ], AWBSEO_VERSION ); wp_enqueue_script( 'awbseo-chart', AWBSEO_PLUGIN_URL . 'admin/assets/js/chart.umd.min.js', [], '4.4.0', true ); wp_enqueue_script( 'awbseo-admin', AWBSEO_PLUGIN_URL . 'admin/assets/js/admin.js', [ 'jquery', 'awbseo-chart' ], AWBSEO_VERSION, true ); wp_localize_script( 'awbseo-admin', 'AWBSEO', [ 'ajax_url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'awbseo_admin' ), 'pro_active' => AWBSEO_PRO_ACTIVE, 'strings' => [ 'saving' => __( 'Saving…', 'adworksbid-seo' ), 'saved' => __( 'Saved!', 'adworksbid-seo' ), 'error' => __( 'Error - please try again.', 'adworksbid-seo' ), 'confirm' => __( 'Are you sure?', 'adworksbid-seo' ), ], ] ); } /** * Unlike enqueue_assets() above, this runs on every wp-admin screen, * not just CitedSEO's own pages - that's the whole point of it. The * "Run SEO Analysis" scan (class-post-columns.php) now runs in the * background and can finish while the admin is on a completely * different wp-admin page (Plugins, Posts, whatever they clicked over * to), so the only reliable way to tell them it's done is something * that's running everywhere: WordPress's own Heartbeat API. This just * asks Heartbeat, on every tick, whether a scan finished since the * last check (see AWBSEO_Post_Columns::heartbeat_received()) and pops * a notification if so. */ public static function enqueue_analysis_notifier( $hook ) { if ( ! current_user_can( 'manage_options' ) ) { return; } wp_enqueue_script( 'heartbeat' ); wp_register_script( 'awbseo-analysis-notifier', false, [ 'jquery', 'heartbeat' ], AWBSEO_VERSION, true ); wp_enqueue_script( 'awbseo-analysis-notifier' ); wp_localize_script( 'awbseo-analysis-notifier', 'AWBSEO_ANALYSIS', [ 'dashboard_url' => admin_url( 'admin.php?page=awbseo' ), 'icon' => AWBSEO_PLUGIN_URL . 'admin/assets/icon-256x256.png', 'strings' => [ 'title' => __( 'CitedSEO', 'adworksbid-seo' ), 'body' => __( 'The SEO Analysis scan has finished. Click to view the results.', 'adworksbid-seo' ), 'toast' => __( 'CitedSEO: SEO Analysis complete.', 'adworksbid-seo' ), 'view' => __( 'View dashboard', 'adworksbid-seo' ), ], ] ); $notifier_js = <<<'AWBSEONOTIFIER' (function($){ $(document).on('heartbeat-send', function(e, data){ data.awbseo_check_analysis = 1; }); $(document).on('heartbeat-tick', function(e, data){ if (!data || !data.awbseo_analysis || data.awbseo_analysis.status !== 'done') { return; } var s = AWBSEO_ANALYSIS.strings; var url = AWBSEO_ANALYSIS.dashboard_url; if (window.Notification && Notification.permission === 'granted') { try { var n = new Notification(s.title, { body: s.body, icon: AWBSEO_ANALYSIS.icon }); n.onclick = function(){ window.focus(); window.location.href = url; }; } catch (err) {} } var $toast = $('
', { style: 'position:fixed;bottom:20px;right:20px;z-index:99999;background:#1e3a5f;color:#fff;padding:14px 18px;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.25);font-size:13px;max-width:300px' }).text(s.toast); var $link = $('', { href: url, style: 'display:block;margin-top:8px;color:#a8d0ff;font-weight:600;text-decoration:none' }).text(s.view); $toast.append($link); $('body').append($toast); setTimeout(function(){ $toast.fadeOut(400, function(){ $(this).remove(); }); }, 12000); }); })(jQuery); AWBSEONOTIFIER; wp_add_inline_script( 'awbseo-analysis-notifier', $notifier_js ); } /** * First-run onboarding tour, shown on any CitedSEO admin screen until * dismissed. Frames the goal-based sidebar and the plugin's real * differentiators (AI-search readiness, native Arabic/RTL support) * before the user starts clicking around. Vanilla-JS overlay, no * markup added to any page template - see admin/assets/js/onboarding-tour.js. */ public static function enqueue_onboarding_tour( $hook ) { if ( strpos( $hook, 'adworksbid-seo' ) === false && strpos( $hook, 'awbseo' ) === false ) return; // The plugin is still new and being rolled out to a lot of client // sites, so instead of hiding the tour for good after one skip/finish, // it comes back once a day - a rolling 24h window since the last // dismissal - so admins keep seeing it until they've actually taken it in. $dismissed_at = (int) get_option( 'awbseo_onboarding_tour_dismissed', 0 ); if ( $dismissed_at && ( time() - $dismissed_at ) < DAY_IN_SECONDS ) return; wp_enqueue_script( 'awbseo-onboarding-tour', AWBSEO_PLUGIN_URL . 'admin/assets/js/onboarding-tour.js', [], AWBSEO_VERSION, true ); wp_localize_script( 'awbseo-onboarding-tour', 'AWBSEO_TOUR', [ 'ajax_url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'awbseo_onboarding_tour' ), 'steps' => [ [ 'target' => '', 'title' => __( 'A different kind of SEO plugin', 'adworksbid-seo' ), 'body' => __( 'What are you trying to do today?', 'adworksbid-seo' ), ], [ 'target' => '#toplevel_page_awbseo .wp-submenu', 'title' => __( 'Pick a goal, not a feature list', 'adworksbid-seo' ), 'body' => __( 'Track Your Growth, Optimize Your Pages, Fix & Maintain, and the rest are goals, not menu clutter. Click one and you only see the tools for it - nothing else to dig through.', 'adworksbid-seo' ), ], [ 'target' => '#toplevel_page_awbseo .wp-submenu a[href*="page=awbseo-cat-ai"]', 'title' => __( 'Built for AI-era search', 'adworksbid-seo' ), 'body' => __( 'Enhance AI Search is where CitedSEO goes further than Yoast or Rank Math: structured schema, clean entity signals, and content foundations that ChatGPT, Perplexity, Gemini, and Google AI Overviews can actually read and cite.', 'adworksbid-seo' ), ], [ 'target' => '', 'title' => __( 'Native Arabic and RTL, not translated', 'adworksbid-seo' ), 'body' => __( 'Running an Arabic or GCC site? The whole admin switches itself to a native right-to-left layout automatically, with keyword tools for 9 Arabic dialects built in. Pick a goal above whenever you\'re ready.', 'adworksbid-seo' ), ], ], 'strings' => [ 'next' => __( 'Next', 'adworksbid-seo' ), 'back' => __( 'Back', 'adworksbid-seo' ), 'skip' => __( 'Skip tour', 'adworksbid-seo' ), 'finish' => __( 'Got it, let\'s go', 'adworksbid-seo' ), ], ] ); } public static function ajax_dismiss_onboarding_tour() { check_ajax_referer( 'awbseo_onboarding_tour', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error(); update_option( 'awbseo_onboarding_tour_dismissed', time() ); wp_send_json_success(); } public static function render_footer_credit( $text ) { $screen = get_current_screen(); if ( ! $screen || ( strpos( $screen->id, 'adworksbid-seo' ) === false && strpos( $screen->id, 'awbseo' ) === false ) ) return $text; $wl = AWBSEO_White_Label::get(); if ( $wl['enabled'] && $wl['hide_footer'] ) { return ''; } if ( $wl['enabled'] && ! empty( $wl['agency_name'] ) ) { return sprintf( esc_html__( 'By %s', 'adworksbid-seo' ), '' . esc_html( AWBSEO_White_Label::brand_name() ) . '' ); } return sprintf( esc_html__( 'By %s', 'adworksbid-seo' ), 'AdWorks.Bid' ); } public static function add_action_links( $links ) { $wizard_link = '' . esc_html__( 'Setup Wizard', 'adworksbid-seo' ) . ''; array_unshift( $links, $wizard_link ); return $links; } // ── Page renderers ──────────────────────────────────────────────────────── public static function render_dashboard() { require AWBSEO_PLUGIN_DIR . 'admin/views/dashboard.php'; } public static function render_bulk_editor() { require AWBSEO_PLUGIN_DIR . 'admin/views/bulk-editor.php'; } public static function render_schema() { require AWBSEO_PLUGIN_DIR . 'admin/views/schema.php'; } public static function render_redirects() { require AWBSEO_PLUGIN_DIR . 'admin/views/redirects.php'; } public static function render_decay() { require AWBSEO_PLUGIN_DIR . 'admin/views/content-decay.php'; } public static function render_cannibalization() { require AWBSEO_PLUGIN_DIR . 'admin/views/cannibalization.php'; } public static function render_broken_links() { require AWBSEO_PLUGIN_DIR . 'admin/views/broken-links.php'; } public static function render_instant_indexing() { require AWBSEO_PLUGIN_DIR . 'admin/views/instant-indexing.php'; } public static function render_sitemap() { require AWBSEO_PLUGIN_DIR . 'admin/views/sitemap.php'; } public static function render_settings() { require AWBSEO_PLUGIN_DIR . 'admin/views/settings.php'; } public static function render_mcp() { require AWBSEO_PLUGIN_DIR . 'admin/views/mcp.php'; } public static function render_white_label() { if ( ! AWBSEO_PRO_ACTIVE ) { require AWBSEO_PLUGIN_DIR . 'admin/views/white-label-upsell.php'; return; } require AWBSEO_PLUGIN_DIR . 'admin/views/white-label.php'; } public static function render_portability() { require AWBSEO_PLUGIN_DIR . 'admin/views/data-portability.php'; } public static function render_rank_tracker() { require AWBSEO_PLUGIN_DIR . 'admin/views/rank-tracker.php'; } public static function render_local_seo() { require AWBSEO_PLUGIN_DIR . 'admin/views/local-seo.php'; } public static function render_woocommerce_seo() { require AWBSEO_PLUGIN_DIR . 'admin/views/woocommerce-seo.php'; } public static function render_robots_txt() { require AWBSEO_PLUGIN_DIR . 'admin/views/robots-txt.php'; } public static function render_analytics() { require AWBSEO_PLUGIN_DIR . 'admin/views/analytics.php'; } // ── Category landing pages ──────────────────────────────────────────────── /** * Shared category page renderer. Outputs a card grid of tool links. * * @param array $config { * @type string $title Page heading. * @type string $desc Optional subtitle. * @type array[] $tools Each tool: icon, name, desc, url, pro (bool). * } */ /** * Maps the emoji glyphs already stored in every category's $tools array * (both the free plugin's own and PRO's, injected via the * 'awbseo_category_tools' filter) to an inline SVG icon, so neither this * plugin's tool arrays nor PRO's add_pro_category_tools() need to change * their data shape - only how a card renders. Anything not in the map * still renders (as the raw glyph) so a future tool never disappears. */ private static function tool_icon_svg( $glyph ) { $s = '%s'; $paths = [ '📈' => '', '📉' => '', '⚠️' => '', '🗺' => '', '⚡' => '', '🔀' => '', '🤖' => '', '✏️' => '', '📍' => '', '🧩' => '', '🛒' => '', '🔗' => '', '✅' => '', '✎' => '', '✦' => '', '🌍' => '', '🌐' => '', '👁️' => '', '💰' => '', '🚀' => '', '📊' => '', '🏢' => '', '🏷️' => '', '👥' => '', ]; if ( isset( $paths[ $glyph ] ) ) { return sprintf( $s, $paths[ $glyph ] ); } return esc_html( $glyph ); } private static function render_category_page( array $config ) { $tools = $config['tools'] ?? []; $accent = $config['accent'] ?? 'var(--awb-primary)'; $tint = $config['tint'] ?? '30,58,95'; ?> [ 'name' => $t['name'], 'url' => $t['url'] ], AWBSEO_Arabic_Suite::tools() ); } $base = [ 'growth' => [ [ 'name' => __( 'Rank Tracker', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-rank-tracker' ), 'pro' => true ], ], 'visibility' => [ [ 'name' => __( 'Sitemap', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-sitemap' ) ], [ 'name' => __( 'Instant Indexing', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-instant-indexing' ) ], [ 'name' => __( 'Robots.txt', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-robots-txt' ) ], [ 'name' => __( 'Redirects', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-redirects' ) ], [ 'name' => __( 'Bulk Editor', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-bulk' ) ], ], 'optimize' => [ [ 'name' => __( 'Bulk Editor', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-bulk' ) ], [ 'name' => __( 'Fading Content', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-decay' ) ], [ 'name' => __( 'Competing Pages', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-cannibalization' ) ], ], 'local' => [ [ 'name' => __( 'Local SEO', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-local' ) ], ], 'ai' => [ [ 'name' => __( 'Schema Markup', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-schema' ) ], ], 'fix' => [ [ 'name' => __( 'Broken Links', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-broken-links' ) ], ], 'agency' => [], ]; // Both conditional on the same Feature Manager toggles their // register_menus() registration uses, so the flyout never lists a // tool whose page isn't actually registered. if ( class_exists( 'WooCommerce' ) && self::is_feature_enabled( 'woocommerce_seo' ) ) { $base['ai'][] = [ 'name' => __( 'WooCommerce SEO', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-woo' ) ]; } if ( self::is_feature_enabled( 'agency_fleet' ) ) { $base['agency'][] = [ 'name' => __( 'White Label', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-white-label' ) ]; } return $base[ $key ] ?? []; } /** * Maps each goal's sidebar slug to the {name, url, pro} list shown in * its sidebar hover flyout (see enqueue_menu_icon_css()) - runs the * tools through the same 'awbseo_category_tools' filter Pro already * hooks to add its own cards, so the flyout mirrors the goal's actual * page automatically, module toggles included. */ public static function sidebar_flyout_map(): array { $slug_to_key = [ 'awbseo-cat-growth' => 'growth', 'awbseo-cat-visibility' => 'visibility', 'awbseo-cat-optimize' => 'optimize', 'awbseo-cat-local' => 'local', 'awbseo-cat-ai' => 'ai', 'awbseo-cat-arabic' => 'arabic', 'awbseo-cat-fix' => 'fix', 'awbseo-cat-agency' => 'agency', ]; $map = []; foreach ( $slug_to_key as $slug => $key ) { $tools = apply_filters( 'awbseo_category_tools', self::flyout_base_tools( $key ), $key ); $map[ $slug ] = array_map( function( $tool ) { return [ 'name' => $tool['name'], 'url' => $tool['url'], 'pro' => ! empty( $tool['pro'] ), ]; }, $tools ); } return $map; } /** * Maps each of the 6 SEO goal keys to every admin page slug that belongs * to it (the category page itself, plus every tool card's page, Pro's * included) - built from the exact same 'awbseo_category_tools' filter * data as sidebar_flyout_map() above, so it never drifts out of sync as * tools are added or moved between goals. * * Used by Pro's Roles Manager to work out which goal(s) a given * admin.php?page=... slug belongs to, so per-user goal restrictions can * hide the right sidebar entries and block direct URL access - without * Pro needing to hardcode or duplicate this plugin's own tool/goal map. * Deliberately excludes 'agency' (White Label / Roles Manager are not * one of the 6 restrictable goal sections). */ public static function goal_tool_slugs(): array { $slug_to_key = [ 'awbseo-cat-growth' => 'growth', 'awbseo-cat-visibility' => 'visibility', 'awbseo-cat-optimize' => 'optimize', 'awbseo-cat-local' => 'local', 'awbseo-cat-ai' => 'ai', 'awbseo-cat-arabic' => 'arabic', 'awbseo-cat-fix' => 'fix', ]; $map = []; foreach ( $slug_to_key as $cat_slug => $key ) { $slugs = [ $cat_slug ]; $tools = apply_filters( 'awbseo_category_tools', self::flyout_base_tools( $key ), $key ); foreach ( $tools as $tool ) { $query = wp_parse_url( $tool['url'] ?? '', PHP_URL_QUERY ); if ( ! $query ) { continue; } parse_str( $query, $args ); if ( ! empty( $args['page'] ) ) { $slugs[] = $args['page']; } } $map[ $key ] = array_values( array_unique( $slugs ) ); } return $map; } public static function render_cat_growth() { $tools = apply_filters( 'awbseo_category_tools', [ [ 'icon' => '📈', 'name' => __( 'Rank Tracker', 'adworksbid-seo' ), 'desc' => __( 'Track keyword positions daily and see ranking trends over time.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-rank-tracker' ), 'pro' => true, ], ], 'growth' ); self::render_category_page( [ 'title' => __( 'Track Your Growth', 'adworksbid-seo' ), 'desc' => __( 'Monitor keyword rankings over time.', 'adworksbid-seo' ), 'tools' => $tools, 'icon' => '📈', 'accent' => '#00c9a7', 'tint' => '0,201,167', ] ); } public static function render_cat_visibility() { $tools = apply_filters( 'awbseo_category_tools', [ [ 'icon' => '🗺', 'name' => __( 'Sitemap', 'adworksbid-seo' ), 'desc' => __( 'Generate and configure your XML sitemap so search engines discover every page.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-sitemap' ), ], [ 'icon' => '⚡', 'name' => __( 'Instant Indexing', 'adworksbid-seo' ), 'desc' => __( 'Push new and updated pages directly to Google and Bing for faster indexing.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-instant-indexing' ), ], [ 'icon' => '🤖', 'name' => __( 'Robots.txt', 'adworksbid-seo' ), 'desc' => __( 'Control which pages search engine crawlers can and cannot access on your site.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-robots-txt' ), ], [ 'icon' => '🔀', 'name' => __( 'Redirects', 'adworksbid-seo' ), 'desc' => __( 'Manage 301 and 302 redirects to preserve link equity and fix broken paths.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-redirects' ), ], [ 'icon' => '✏️', 'name' => __( 'Bulk Editor', 'adworksbid-seo' ), 'desc' => __( 'Edit SEO titles, meta descriptions, and focus keywords across all posts and pages at once.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-bulk' ), ], ], 'visibility' ); self::render_category_page( [ 'title' => __( 'Get Found on Google', 'adworksbid-seo' ), 'desc' => __( 'Control how search engines crawl, index, and surface your site in search results.', 'adworksbid-seo' ), 'tools' => $tools, 'icon' => '🗺', 'accent' => '#1e3a5f', 'tint' => '30,58,95', ] ); } public static function render_cat_optimize() { $tools = apply_filters( 'awbseo_category_tools', [ [ 'icon' => '✏️', 'name' => __( 'Bulk Editor', 'adworksbid-seo' ), 'desc' => __( 'Edit titles, meta descriptions, and focus keywords for every post and page from a single spreadsheet-style table.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-bulk' ), ], [ 'icon' => '📉', 'name' => __( 'Fading Content', 'adworksbid-seo' ), 'desc' => __( 'Find pages that used to rank but are slowly losing traffic and visibility.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-decay' ), ], [ 'icon' => '⚠️', 'name' => __( 'Competing Pages', 'adworksbid-seo' ), 'desc' => __( 'Detect keywords where multiple pages cannibalize each other in search results.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-cannibalization' ), ], ], 'optimize' ); self::render_category_page( [ 'title' => __( 'Optimize Your Pages', 'adworksbid-seo' ), 'desc' => __( 'Edit titles and descriptions, then catch fading content and pages competing against each other.', 'adworksbid-seo' ), 'tools' => $tools, 'icon' => '✏️', 'accent' => '#059669', 'tint' => '5,150,105', ] ); } public static function render_cat_local() { $tools = apply_filters( 'awbseo_category_tools', [ [ 'icon' => '📍', 'name' => __( 'Local SEO', 'adworksbid-seo' ), 'desc' => __( 'Manage NAP data, business hours, and local schema to rank in map and local searches.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-local' ), ], ], 'local' ); self::render_category_page( [ 'title' => __( 'Build Local Authority', 'adworksbid-seo' ), 'desc' => __( 'Set up local business signals so Google and AI engines surface you in local searches.', 'adworksbid-seo' ), 'tools' => $tools, 'icon' => '📍', 'accent' => '#d97706', 'tint' => '217,119,6', ] ); } public static function render_cat_ai() { $tools = [ [ 'icon' => '🧩', 'name' => __( 'Schema Markup', 'adworksbid-seo' ), 'desc' => __( 'Add structured data so AI engines and search engines understand what your content is about.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-schema' ), ], ]; if ( class_exists( 'WooCommerce' ) && self::is_feature_enabled( 'woocommerce_seo' ) ) { $tools[] = [ 'icon' => '🛒', 'name' => __( 'WooCommerce SEO', 'adworksbid-seo' ), 'desc' => __( 'Optimize product titles, descriptions, and schema for your WooCommerce store.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-woo' ), ]; } $tools = apply_filters( 'awbseo_category_tools', $tools, 'ai' ); self::render_category_page( [ 'title' => __( 'Enhance AI Search', 'adworksbid-seo' ), 'desc' => __( 'Structure your content so AI engines can understand, cite, and recommend your pages.', 'adworksbid-seo' ), 'tools' => $tools, 'icon' => '✦', 'accent' => '#dc2626', 'tint' => '220,38,38', ] ); } public static function render_cat_arabic() { $tools = apply_filters( 'awbseo_category_tools', AWBSEO_Arabic_Suite::tools(), 'arabic' ); self::render_category_page( [ 'title' => __( 'Arabic SEO', 'adworksbid-seo' ), 'desc' => __( 'Tools built for Arabic sites: the spelling, URL, direction, seasonal, and quality problems other SEO plugins ignore.', 'adworksbid-seo' ), 'tools' => $tools, 'icon' => '🌐', 'accent' => '#0f766e', 'tint' => '15,118,110', ] ); } public static function render_cat_fix() { $tools = apply_filters( 'awbseo_category_tools', [ [ 'icon' => '🔗', 'name' => __( 'Broken Links', 'adworksbid-seo' ), 'desc' => __( 'Scan your site for dead links that hurt your SEO and user experience, then fix them.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-broken-links' ), ], ], 'fix' ); self::render_category_page( [ 'title' => __( 'Fix & Maintain', 'adworksbid-seo' ), 'desc' => __( 'Find broken links and catch technical issues before they affect your rankings.', 'adworksbid-seo' ), 'tools' => $tools, 'icon' => '🔗', 'accent' => '#475569', 'tint' => '71,85,105', ] ); } public static function render_cat_agency() { $tools = apply_filters( 'awbseo_category_tools', [ [ 'icon' => '🏷️', 'name' => __( 'White Label', 'adworksbid-seo' ), 'desc' => __( 'Replace CitedSEO\'s own branding with your agency\'s name, logo, and color throughout the admin.', 'adworksbid-seo' ), 'url' => admin_url( 'admin.php?page=awbseo-white-label' ), ], ], 'agency' ); self::render_category_page( [ 'title' => __( 'Agency Fleet', 'adworksbid-seo' ), 'desc' => __( 'Manage your agency branding and control which team members can access each part of CitedSEO.', 'adworksbid-seo' ), 'tools' => $tools, 'icon' => '🏢', 'accent' => '#6d3fc0', 'tint' => '109,63,192', ] ); } public static function render_go_pro() { $pro_url = 'https://adworks.bid/wordpress-seo-plugin/?utm_source=plugin&utm_medium=go_pro_page'; ?> get_error_message() ); } // Let PRO record bulk edits in the Activity Log. if ( $result > 0 ) { do_action( 'awbseo_activity', 'on-page', 'Bulk updated SEO data', $result . ' post' . ( $result !== 1 ? 's' : '' ) . ' updated' ); } wp_send_json_success( [ 'saved' => $result ] ); } public static function ajax_save_redirect() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error(); $source = sanitize_text_field( $_POST['source'] ?? '' ); $target = esc_url_raw( $_POST['target'] ?? '' ); $type = (int) ( $_POST['type'] ?? 301 ); if ( ! $source || ! $target ) wp_send_json_error( 'Source and target are required.' ); $result = AWBSEO_Redirects::add( $source, $target, $type ); if ( false === $result ) { global $wpdb; wp_send_json_error( $wpdb->last_error ?: 'Database write failed. Try deactivating and reactivating the plugin to rebuild tables.' ); } do_action( 'awbseo_activity', 'technical', 'Added ' . $type . ' redirect', $source . ' -> ' . $target ); wp_send_json_success(); } public static function ajax_delete_redirect() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error(); AWBSEO_Redirects::delete( (int) ( $_POST['id'] ?? 0 ) ); wp_send_json_success(); } public static function ajax_import_redirects() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error(); $csv = isset( $_POST['csv'] ) ? sanitize_textarea_field( wp_unslash( $_POST['csv'] ) ) : ''; if ( ! $csv ) wp_send_json_error( 'No CSV data provided.' ); $imported = AWBSEO_Redirects::import_csv( $csv ); wp_send_json_success( [ 'imported' => $imported ] ); } public static function ajax_add_keyword() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) wp_send_json_error( 'Forbidden' ); $kw = sanitize_text_field( $_POST['keyword'] ?? '' ); $url = esc_url_raw( $_POST['url'] ?? '' ); $region = sanitize_key( $_POST['region'] ?? '' ); $ok = AWBSEO_Rank_Tracker::add_keyword( $kw, $url, $region ); if ( ! $ok ) wp_send_json_error( 'Keyword already tracked or invalid.' ); // Let PRO record keyword additions in the Activity Log. do_action( 'awbseo_activity', 'content', 'Started tracking keyword', '"' . $kw . '"' ); $position = AWBSEO_Rank_Tracker::fetch_position( $kw ); wp_send_json_success( [ 'position' => $position ] ); } public static function ajax_remove_keyword() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) wp_send_json_error( 'Forbidden' ); AWBSEO_Rank_Tracker::remove_keyword( sanitize_text_field( $_POST['keyword'] ?? '' ) ); wp_send_json_success(); } // ── Local SEO ───────────────────────────────────────────────────────────── public static function ajax_get_location() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' ); $loc = AWBSEO_Local_SEO::get_location( (int) ( $_POST['id'] ?? 0 ) ); $loc ? wp_send_json_success( $loc ) : wp_send_json_error( 'Not found.' ); } public static function ajax_save_location() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' ); $raw_hours = json_decode( wp_unslash( $_POST['hours'] ?? '[]' ), true ); $hours = is_array( $raw_hours ) ? array_map( function( $row ) { return is_array( $row ) ? array_map( 'sanitize_text_field', $row ) : []; }, $raw_hours ) : []; $result = AWBSEO_Local_SEO::save_location( [ 'id' => absint( $_POST['id'] ?? 0 ), 'name' => sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) ), 'street' => sanitize_text_field( wp_unslash( $_POST['street'] ?? '' ) ), 'city' => sanitize_text_field( wp_unslash( $_POST['city'] ?? '' ) ), 'region' => sanitize_text_field( wp_unslash( $_POST['region'] ?? '' ) ), 'postal_code' => sanitize_text_field( wp_unslash( $_POST['postal_code'] ?? '' ) ), 'country' => sanitize_text_field( wp_unslash( $_POST['country'] ?? 'US' ) ), 'phone' => sanitize_text_field( wp_unslash( $_POST['phone'] ?? '' ) ), 'email' => sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ), 'lat' => sanitize_text_field( wp_unslash( $_POST['lat'] ?? '' ) ), 'lng' => sanitize_text_field( wp_unslash( $_POST['lng'] ?? '' ) ), 'price_range' => sanitize_text_field( wp_unslash( $_POST['price_range'] ?? '' ) ), 'hours' => $hours, 'page_id' => absint( $_POST['page_id'] ?? 0 ), 'is_primary' => absint( $_POST['is_primary'] ?? 0 ), 'business_type' => sanitize_text_field( wp_unslash( $_POST['business_type'] ?? 'LocalBusiness' ) ), 'name_ar' => sanitize_text_field( wp_unslash( $_POST['name_ar'] ?? '' ) ), 'street_ar' => sanitize_text_field( wp_unslash( $_POST['street_ar'] ?? '' ) ), 'city_ar' => sanitize_text_field( wp_unslash( $_POST['city_ar'] ?? '' ) ), 'region_ar' => sanitize_text_field( wp_unslash( $_POST['region_ar'] ?? '' ) ), 'maps_url' => esc_url_raw( wp_unslash( $_POST['maps_url'] ?? '' ) ), 'whatsapp' => sanitize_text_field( wp_unslash( $_POST['whatsapp'] ?? '' ) ), 'image' => esc_url_raw( wp_unslash( $_POST['image'] ?? '' ) ), 'area_served' => sanitize_text_field( wp_unslash( $_POST['area_served'] ?? '' ) ), ] ); if ( is_wp_error( $result ) ) wp_send_json_error( $result->get_error_message() ); // Let PRO record location saves in the Activity Log. do_action( 'awbseo_activity', 'local', 'Saved local SEO location', sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) ) ); wp_send_json_success( [ 'id' => $result ] ); } public static function ajax_delete_location() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' ); AWBSEO_Local_SEO::delete_location( (int) ( $_POST['id'] ?? 0 ) ); wp_send_json_success(); } // ── WooCommerce SEO ─────────────────────────────────────────────────────── public static function ajax_save_woo_settings() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' ); wp_send_json_success( AWBSEO_Woocommerce_SEO::save_settings( $_POST ) ); } public static function ajax_woo_bulk_fix_alt() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden' ); wp_send_json_success( [ 'fixed' => AWBSEO_Woocommerce_SEO::bulk_fix_alt_text() ] ); } public static function handle_export_csv() { check_admin_referer( 'awbseo_export' ); AWBSEO_Bulk_Editor::export_csv(); } public static function save_settings() { check_admin_referer( 'awbseo_save_settings' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden' ); $defaults = AWBSEO_Core::default_settings(); $current = get_option( 'awbseo_settings', $defaults ); $new = []; $preserve_if_blank = [ 'openai_api_key', 'anthropic_api_key', 'gemini_api_key', 'perplexity_api_key', 'serp_api_key' ]; $not_on_this_form = [ 'sitemap_taxonomies', 'cwv_threshold_lcp', 'cwv_threshold_fid', 'cwv_threshold_inp', 'cwv_threshold_cls', 'pagespeed_api_key', 'acf_by_post_type' ]; $textarea_fields = [ 'acf_schema_mapping' ]; foreach ( $defaults as $key => $default ) { if ( in_array( $key, $not_on_this_form, true ) ) { $new[ $key ] = $current[ $key ] ?? $default; } elseif ( is_bool( $default ) ) { $new[ $key ] = isset( $_POST[ 'awb_' . $key ] ); } elseif ( is_array( $default ) ) { $new[ $key ] = isset( $_POST[ 'awb_' . $key ] ) ? array_map( 'sanitize_text_field', (array) $_POST[ 'awb_' . $key ] ) : []; } elseif ( in_array( $key, $preserve_if_blank, true ) ) { $submitted = sanitize_text_field( $_POST[ 'awb_' . $key ] ?? '' ); $new[ $key ] = ( '' !== $submitted ) ? $submitted : ( $current[ $key ] ?? $default ); } elseif ( in_array( $key, $textarea_fields, true ) ) { $new[ $key ] = sanitize_textarea_field( $_POST[ 'awb_' . $key ] ?? $default ); } else { $new[ $key ] = sanitize_text_field( $_POST[ 'awb_' . $key ] ?? $default ); } } // Hreflang pairs. $pairs = []; if ( isset( $_POST['awb_hreflang_lang'] ) && is_array( $_POST['awb_hreflang_lang'] ) ) { foreach ( array_keys( $_POST['awb_hreflang_lang'] ) as $i ) { $lang = sanitize_text_field( $_POST['awb_hreflang_lang'][ $i ] ); $url = esc_url_raw( $_POST['awb_hreflang_url'][ $i ] ?? '' ); if ( $lang && $url ) $pairs[] = compact( 'lang', 'url' ); } } $new['hreflang_pairs'] = $pairs; $by_type = []; $post_types = get_post_types( [ 'public' => true ] ); foreach ( $post_types as $pt ) { $title_field = sanitize_text_field( $_POST['awb_acf_pt_title'][ $pt ] ?? '' ); $desc_field = sanitize_text_field( $_POST['awb_acf_pt_desc'][ $pt ] ?? '' ); if ( $title_field || $desc_field ) { $by_type[ $pt ] = array_filter( [ 'title' => $title_field, 'desc' => $desc_field, ] ); } } $new['acf_by_post_type'] = $by_type; update_option( 'awbseo_settings', array_merge( $current, $new ) ); AWBSEO_Sitemap::flush_cache(); // Allow Pro plugin to flush its own caches on settings save. do_action( 'awbseo_settings_saved', $new ); wp_redirect( admin_url( 'admin.php?page=awbseo-settings&saved=1' ) ); exit; } // ── Dashboard data helpers ──────────────────────────────────────────────── private static function pct_change( $cur, $prev ) { if ( $prev == 0 ) return null; return round( ( ( $cur - $prev ) / $prev ) * 100, 1 ); } private static function summarize_site_stats( $site_stats ) { $summary = [ 'clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0, 'dates' => [], 'daily_clicks' => [], 'daily_impr' => [], 'clicks_change' => null, 'impr_change' => null, ]; if ( empty( $site_stats['daily'] ) ) return $summary; $ctr_sum = 0; $pos_sum = 0; foreach ( $site_stats['daily'] as $row ) { $summary['clicks'] += $row['clicks']; $summary['impressions'] += $row['impressions']; $ctr_sum += $row['ctr']; $pos_sum += $row['position']; $summary['dates'][] = $row['keys'][0]; $summary['daily_clicks'][] = (int) $row['clicks']; $summary['daily_impr'][] = (int) $row['impressions']; } $count = count( $site_stats['daily'] ); $summary['ctr'] = $count ? round( ( $ctr_sum / $count ) * 100, 1 ) : 0; $summary['position'] = $count ? round( $pos_sum / $count, 1 ) : 0; $summary['clicks_change'] = self::pct_change( $summary['clicks'], $site_stats['prev_sum']['clicks'] ?? 0 ); $summary['impr_change'] = self::pct_change( $summary['impressions'], $site_stats['prev_sum']['impressions'] ?? 0 ); $summary['ctr_change'] = self::pct_change( $summary['ctr'], $site_stats['prev_sum']['ctr'] ?? 0 ); $summary['position_change'] = self::pct_change( $summary['position'], $site_stats['prev_sum']['position'] ?? 0 ); return $summary; } public static function get_dashboard_data( $range_days = 28, $start = null, $end = null ) { $valid = [ 7 => true, 28 => true, 90 => true, 180 => true ]; if ( ! isset( $valid[ $range_days ] ) ) $range_days = 28; $use_custom = (bool) ( $start && $end ); if ( $use_custom && $start > $end ) [ $start, $end ] = [ $end, $start ]; $range_label = $use_custom ? date_i18n( 'M j', strtotime( $start ) ) . ' – ' . date_i18n( 'M j, Y', strtotime( $end ) ) : "last {$range_days} days"; $range_days_display = $use_custom ? max( 1, (int) ( ( strtotime( $end ) - strtotime( $start ) ) / DAY_IN_SECONDS ) + 1 ) : $range_days; $gsc = AWBSEO_GSC::get_instance(); $gsc_on = $gsc->is_connected(); $gsc_stats = $gsc_on ? $gsc->get_site_stats( $range_days, $use_custom ? $start : null, $use_custom ? $end : null ) : null; $gsc_summary = $gsc_on ? self::summarize_site_stats( $gsc_stats ) : null; $top_pages = $gsc_on ? $gsc->get_top_pages( $range_days, 10, $use_custom ? $start : null, $use_custom ? $end : null ) : []; $top_queries = $gsc_on ? $gsc->get_top_queries( $range_days, 10, $use_custom ? $start : null, $use_custom ? $end : null ) : []; $gsc_lang_breakdown = $gsc_on ? $gsc->get_top_queries_by_language( $range_days, 8, $use_custom ? $start : null, $use_custom ? $end : null ) : []; $gsc_last_error = ( $gsc_on && empty( $gsc_summary['dates'] ) ) ? $gsc->get_last_error() : null; $bing = AWBSEO_Bing::get_instance(); $bing_on = $bing->is_connected(); $bing_stats = $bing_on ? $bing->get_site_stats( $range_days, $use_custom ? $start : null, $use_custom ? $end : null ) : null; $bing_summary = $bing_on ? self::summarize_site_stats( $bing_stats ) : null; $bing_top_pages = $bing_on ? $bing->get_top_pages( $range_days, 10, $use_custom ? $start : null, $use_custom ? $end : null ) : []; $bing_top_queries = $bing_on ? $bing->get_top_queries( $range_days, 10, $use_custom ? $start : null, $use_custom ? $end : null ) : []; return compact( 'range_days', 'use_custom', 'start', 'end', 'range_label', 'range_days_display', 'gsc_on', 'gsc_stats', 'gsc_summary', 'gsc_last_error', 'top_pages', 'top_queries', 'gsc_lang_breakdown', 'bing_on', 'bing_stats', 'bing_summary', 'bing_top_pages', 'bing_top_queries' ); } public static function render_page_rows( $rows ) { if ( ! $rows ) return ''; ob_start(); foreach ( $rows as $row ) : $url = $row['keys'][0]; $path = urldecode( parse_url( $url, PHP_URL_PATH ) ?: '/' ); $pos = round( $row['position'], 1 ); $pos_class = $pos <= 3 ? 'top' : ( $pos <= 10 ? 'good' : ( $pos <= 20 ? 'ok' : 'low' ) ); ?> % # # is_string( $d ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $d ); $use_custom = $is_date( $start ) && $is_date( $end ); $data = self::get_dashboard_data( $range_days, $use_custom ? $start : null, $use_custom ? $end : null ); $payload = [ 'range_days' => $data['range_days'], 'use_custom' => $data['use_custom'], 'start' => $data['start'], 'end' => $data['end'], 'range_label' => $data['range_label'], 'range_days_display' => (int) $data['range_days_display'], ]; if ( $data['gsc_on'] && $data['gsc_stats'] ) { $payload['gsc'] = [ 'connected' => true, 'clicks' => number_format_i18n( $data['gsc_summary']['clicks'] ), 'clicks_change' => $data['gsc_summary']['clicks_change'], 'impressions' => number_format_i18n( $data['gsc_summary']['impressions'] ), 'impr_change' => $data['gsc_summary']['impr_change'], 'ctr' => $data['gsc_summary']['ctr'], 'position' => $data['gsc_summary']['position'], 'position_good' => $data['gsc_summary']['position'] > 0 && $data['gsc_summary']['position'] <= 10, 'chart' => [ 'dates' => $data['gsc_summary']['dates'], 'clicks' => $data['gsc_summary']['daily_clicks'], 'impr' => $data['gsc_summary']['daily_impr'] ], 'pages_html' => self::render_page_rows( $data['top_pages'] ), 'queries_html' => self::render_query_rows( $data['top_queries'] ), 'pages_empty' => empty( $data['top_pages'] ), 'queries_empty' => empty( $data['top_queries'] ), ]; // Query language breakdown - keeps the Arabic/Other cards on the // Analytics page in sync with the date-range picker instead of // only ever reflecting the range the page first loaded with. $lang = $data['gsc_lang_breakdown'] ?? []; $lang_arabic = $lang['arabic'] ?? [ 'totals' => [ 'clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0, 'query_count' => 0 ], 'top_queries' => [] ]; $lang_other = $lang['other'] ?? [ 'totals' => [ 'clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0, 'query_count' => 0 ], 'top_queries' => [] ]; $payload['gsc_lang'] = [ 'has_data' => (bool) ( $lang_arabic['totals']['query_count'] || $lang_other['totals']['query_count'] ), 'arabic' => [ 'clicks' => number_format_i18n( $lang_arabic['totals']['clicks'] ), 'impressions' => number_format_i18n( $lang_arabic['totals']['impressions'] ), 'ctr' => $lang_arabic['totals']['ctr'], 'position' => $lang_arabic['totals']['position'], 'query_count' => (int) $lang_arabic['totals']['query_count'], 'queries_html' => self::render_query_rows( $lang_arabic['top_queries'] ), 'queries_empty' => empty( $lang_arabic['top_queries'] ), ], 'other' => [ 'clicks' => number_format_i18n( $lang_other['totals']['clicks'] ), 'impressions' => number_format_i18n( $lang_other['totals']['impressions'] ), 'ctr' => $lang_other['totals']['ctr'], 'position' => $lang_other['totals']['position'], 'query_count' => (int) $lang_other['totals']['query_count'], 'queries_html' => self::render_query_rows( $lang_other['top_queries'] ), 'queries_empty' => empty( $lang_other['top_queries'] ), ], ]; } else { $payload['gsc'] = [ 'connected' => false ]; $payload['gsc_lang'] = [ 'has_data' => false ]; } if ( $data['bing_on'] && $data['bing_stats'] ) { $payload['bing'] = [ 'connected' => true, 'clicks' => number_format_i18n( $data['bing_summary']['clicks'] ), 'clicks_change' => $data['bing_summary']['clicks_change'], 'impressions' => number_format_i18n( $data['bing_summary']['impressions'] ), 'impr_change' => $data['bing_summary']['impr_change'], 'ctr' => $data['bing_summary']['ctr'], 'position' => $data['bing_summary']['position'], 'position_good' => $data['bing_summary']['position'] > 0 && $data['bing_summary']['position'] <= 10, 'pages_html' => self::render_page_rows( $data['bing_top_pages'] ), 'queries_html' => self::render_query_rows( $data['bing_top_queries'] ), 'pages_empty' => empty( $data['bing_top_pages'] ), 'queries_empty' => empty( $data['bing_top_queries'] ), ]; } else { $payload['bing'] = [ 'connected' => false ]; } wp_send_json_success( $payload ); } // ── AI Meta Description Suggestion ─────────────────────────────────────── public static function ajax_suggest_meta_desc() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) wp_send_json_error( 'Permission denied.' ); $post_id = absint( $_POST['post_id'] ?? 0 ); if ( ! $post_id ) wp_send_json_error( 'Invalid post.' ); $post = get_post( $post_id ); if ( ! $post ) wp_send_json_error( 'Post not found.' ); $title = get_the_title( $post ); $keyword = AWBSEO_Meta_Manager::get( $post_id, 'focus_keyword' ); // Strip shortcodes BEFORE trimming so page-builder blobs (vc_raw_html, etc.) // don't count as a single enormous "word" and blow the broker token limit. $raw_content = strip_shortcodes( $post->post_excerpt ?: $post->post_content ); $content = wp_strip_all_tags( wp_trim_words( $raw_content, 150, '' ) ); // Hard cap - never send more than 4 000 chars (~1 000 tokens) to the broker. $content = mb_substr( $content, 0, 4000 ); $post_type = $post->post_type; // Variation 1-6 cycles styles on each click so suggestions never feel repetitive. $variation = wp_rand( 1, 6 ); // When a dialect is selected in the meta box, the broker writes the // suggestion directly in that Arabic dialect instead of a generic/MSA // draft that would otherwise need a second 'Adapt for dialect' pass. $dialect = sanitize_text_field( wp_unslash( $_POST['dialect'] ?? '' ) ); $response = wp_remote_post( AWBSEO_SUGGEST_BROKER_URL, [ 'timeout' => 20, 'headers' => [ 'Content-Type' => 'application/json', 'X-AWB-Token' => AWBSEO_SUGGEST_BROKER_TOKEN, ], 'body' => wp_json_encode( [ 'title' => $title, 'keyword' => $keyword, 'content' => $content, 'post_type' => $post_type, 'variation' => $variation, 'dialect' => $dialect, ] ), ] ); if ( is_wp_error( $response ) ) { wp_send_json_error( $response->get_error_message() ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $body['error'] ) ) { wp_send_json_error( $body['error'] ); } $text = trim( $body['suggestion'] ?? '' ); if ( ! $text ) { wp_send_json_error( 'No suggestion returned from the server.' ); } // Safety cap: broker should already enforce 155 chars, but truncate here // at a word boundary as a last resort so the field never overflows. if ( mb_strlen( $text ) > 155 ) { $cut = mb_substr( $text, 0, 155 ); $last_space = mb_strrpos( $cut, ' ' ); $text = $last_space ? rtrim( mb_substr( $cut, 0, $last_space ), '.,;:' ) : $cut; } wp_send_json_success( [ 'suggestion' => $text ] ); } // ── Arabic Dialect Keyword Adapter ──────────────────────────────────────── /** * Generates 3 keyword variants adapted to the requested Arabic dialect. * * Expects POST fields: nonce, keyword, dialect (e.g. "saudi", "egyptian"). * Returns: { variants: [ "...", "...", "..." ] } */ public static function ajax_dialect_adapt() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( 'Permission denied.' ); } $keyword = sanitize_text_field( wp_unslash( $_POST['keyword'] ?? '' ) ); $dialect = sanitize_text_field( wp_unslash( $_POST['dialect'] ?? '' ) ); if ( ! $keyword || ! $dialect ) { wp_send_json_error( 'Missing keyword or dialect.' ); } $dialect_labels = [ 'msa' => 'Modern Standard Arabic (Fus-ha)', 'saudi' => 'Saudi Najdi Arabic', 'egyptian' => 'Egyptian Arabic (Masri)', 'kuwaiti' => 'Kuwaiti Arabic', 'lebanese' => 'Lebanese Arabic', 'uae' => 'Emirati Arabic (UAE)', 'iraqi' => 'Iraqi Arabic', 'jordanian' => 'Jordanian Arabic', 'moroccan' => 'Moroccan Arabic (Darija)', ]; // Concrete, dialect-specific anchoring. A bare label like "Emirati // Arabic (UAE)" isn't enough signal -- general-purpose models default // to safe Modern Standard Arabic and just reshuffle the MSA keyword, // which is exactly what real Khaliji/dialect users don't type. Each // hint calls out actual colloquial words and patterns for that // dialect so the model has something concrete to imitate. $dialect_hints = [ 'saudi' => 'Use Najdi/Hejazi colloquial words people actually type, e.g. "وين" not "أين", "كم سعر" not "ما هي تكلفة", "دكتور زين" style phrasing, drop formal case endings and literary connectors like "لدى" or "بغية".', 'egyptian' => 'Use Masri colloquial words, e.g. "فين" not "أين", "ازاي" not "كيف", "دكتور كويس" instead of literary phrasing, drop formal Fus-ha endings and connectors.', 'kuwaiti' => 'Use Kuwaiti Gulf colloquial words, e.g. "وين", "شلون", "شنو", "يبونه" style verb forms, everyday spoken vocabulary instead of media/formal Arabic.', 'lebanese' => 'Use Lebanese colloquial words, e.g. "وين", "كيفك", "بكم" instead of "ما هو السعر", French-influenced everyday terms where natural, drop formal Fus-ha grammar.', 'uae' => 'Use Emirati Gulf colloquial words, e.g. "وين" not "أين", "شنو" not "ماذا", "كم سعره" not "ما هي تكلفته", everyday spoken phrasing an Emirati or Gulf resident would actually type into Google -- not literary/media Arabic, and not just the same MSA sentence with words reordered.', 'iraqi' => 'Use Iraqi colloquial words, e.g. "وين", "شلون", "شنو", "اكو" (there is), everyday spoken Iraqi phrasing instead of formal Fus-ha.', 'jordanian' => 'Use Jordanian colloquial words, e.g. "وين", "كيفك", "قديش" not "كم", everyday Levantine spoken phrasing instead of formal Fus-ha.', 'moroccan' => 'Use Darija: French/Berber-influenced vocabulary, e.g. "فين" not "أين", "بشحال" not "كم", "شنو" not "ماذا" -- this should look distinctly different from Fus-ha, not a lightly reworded version of it.', ]; $dialect_label = $dialect_labels[ $dialect ] ?? $dialect; $dialect_hint = $dialect_hints[ $dialect ] ?? ''; $prompt = "You are an expert Arabic SEO specialist who deeply knows regional dialects, not just Modern Standard Arabic. " . "A user has the focus keyword: \"$keyword\"\n\n" . "Generate exactly 3 keyword variants adapted to $dialect_label that a real, ordinary person from that region -- " . "not a journalist or textbook -- would actually type into Google.\n\n" . "CRITICAL: Modern Standard Arabic (Fus-ha) is the WRONG output unless the requested dialect is literally MSA. " . "Do not just reorder or lightly reword the original keyword in formal Arabic -- that is the mistake to avoid. " . ( $dialect_hint ? "Specifically for $dialect_label: $dialect_hint\n\n" : "\n" ) . "Each variant must be genuinely colloquial and immediately recognizable as $dialect_label to a native speaker, " . "using that dialect's own everyday words, question forms, and phrasing -- not literary connectors or Fus-ha grammar.\n\n" . "Return ONLY a JSON array of 3 strings, no explanation, no markdown, no extra text.\n" . "Example format: [\"keyword one\", \"keyword two\", \"keyword three\"]"; $response = wp_remote_post( AWBSEO_DIALECT_BROKER_URL, [ 'timeout' => 25, 'headers' => [ 'Content-Type' => 'application/json', 'X-AWB-Token' => AWBSEO_SUGGEST_BROKER_TOKEN, ], 'body' => wp_json_encode( [ 'prompt' => $prompt, 'engine' => 'awbseo', 'max_tokens' => 150, 'site' => home_url(), ] ), ] ); if ( is_wp_error( $response ) ) { wp_send_json_error( $response->get_error_message() ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $body['error'] ) ) { wp_send_json_error( $body['error'] ); } $raw = trim( $body['text'] ?? '' ); // Extract a JSON array from the response, ignoring any surrounding text. if ( preg_match( '/\[.*\]/s', $raw, $m ) ) { $raw = $m[0]; } $variants = json_decode( $raw, true ); if ( ! is_array( $variants ) || empty( $variants ) ) { wp_send_json_error( 'Could not parse dialect variants. Please try again.' ); } // Sanitize each variant. $variants = array_values( array_map( 'sanitize_text_field', array_filter( $variants ) ) ); wp_send_json_success( [ 'variants' => $variants ] ); } /** * Rewrites a full meta description into the requested Arabic dialect -- * not just the focus keyword. ajax_dialect_adapt() above only ever * touched the keyword field; "Adapt for dialect" otherwise left the * description in whatever Arabic it started in (usually MSA), which * defeats the point for a site actually targeting Gulf/dialect searchers. * * Expects POST fields: nonce, description, dialect. * Returns: { description: "..." } */ public static function ajax_dialect_adapt_description() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( 'Permission denied.' ); } $description = sanitize_textarea_field( wp_unslash( $_POST['description'] ?? '' ) ); $dialect = sanitize_text_field( wp_unslash( $_POST['dialect'] ?? '' ) ); if ( ! $description || ! $dialect ) { wp_send_json_error( 'Missing description or dialect.' ); } $dialect_labels = [ 'msa' => 'Modern Standard Arabic (Fus-ha)', 'saudi' => 'Saudi Najdi Arabic', 'egyptian' => 'Egyptian Arabic (Masri)', 'kuwaiti' => 'Kuwaiti Arabic', 'lebanese' => 'Lebanese Arabic', 'uae' => 'Emirati Arabic (UAE)', 'iraqi' => 'Iraqi Arabic', 'jordanian' => 'Jordanian Arabic', 'moroccan' => 'Moroccan Arabic (Darija)', ]; $dialect_hints = [ 'saudi' => 'Use Najdi/Hejazi colloquial words people actually type, e.g. "وين" not "أين", "كم سعر" not "ما هي تكلفة", drop formal case endings and literary connectors like "لدى" or "بغية".', 'egyptian' => 'Use Masri colloquial words, e.g. "فين" not "أين", "ازاي" not "كيف", drop formal Fus-ha endings and connectors.', 'kuwaiti' => 'Use Kuwaiti Gulf colloquial words, e.g. "وين", "شلون", "شنو", everyday spoken vocabulary instead of media/formal Arabic.', 'lebanese' => 'Use Lebanese colloquial words, e.g. "وين", "كيفك", "بكم" instead of "ما هو السعر", drop formal Fus-ha grammar.', 'uae' => 'Use Emirati Gulf colloquial words, e.g. "وين" not "أين", "شنو" not "ماذا", "كم سعره" not "ما هي تكلفته", everyday spoken phrasing an Emirati or Gulf resident would actually read and say -- not literary/media Arabic.', 'iraqi' => 'Use Iraqi colloquial words, e.g. "وين", "شلون", "شنو", "اكو" (there is), everyday spoken Iraqi phrasing instead of formal Fus-ha.', 'jordanian' => 'Use Jordanian colloquial words, e.g. "وين", "كيفك", "قديش" not "كم", everyday Levantine spoken phrasing instead of formal Fus-ha.', 'moroccan' => 'Use Darija: French/Berber-influenced vocabulary, e.g. "فين" not "أين", "بشحال" not "كم", distinctly different from Fus-ha.', ]; $dialect_label = $dialect_labels[ $dialect ] ?? $dialect; $dialect_hint = $dialect_hints[ $dialect ] ?? ''; $prompt = "You are an expert Arabic SEO copywriter who deeply knows regional dialects, not just Modern Standard Arabic. " . "Rewrite the following meta description entirely in $dialect_label, the way a real local business would write it for $dialect_label readers:\n\n" . "\"$description\"\n\n" . "CRITICAL: Modern Standard Arabic (Fus-ha) is the WRONG output unless the requested dialect is literally MSA. " . "Do not just lightly reword the original in formal Arabic -- rewrite it so it reads as genuinely colloquial $dialect_label. " . ( $dialect_hint ? "Specifically for $dialect_label: $dialect_hint\n\n" : "\n" ) . "Keep it a natural, persuasive meta description of similar length (roughly 140-160 characters), keep any medical/business names and facts unchanged, " . "and keep it grammatically coherent -- colloquial does not mean sloppy.\n\n" . "Return ONLY the rewritten description as plain text, no quotes, no explanation, no markdown."; $response = wp_remote_post( AWBSEO_DIALECT_BROKER_URL, [ 'timeout' => 25, 'headers' => [ 'Content-Type' => 'application/json', 'X-AWB-Token' => AWBSEO_SUGGEST_BROKER_TOKEN, ], 'body' => wp_json_encode( [ 'prompt' => $prompt, 'engine' => 'awbseo', 'max_tokens' => 220, 'site' => home_url(), ] ), ] ); if ( is_wp_error( $response ) ) { wp_send_json_error( $response->get_error_message() ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $body['error'] ) ) { wp_send_json_error( $body['error'] ); } $rewritten = trim( $body['text'] ?? '' ); $rewritten = trim( $rewritten, "\"'\n\r\t " ); if ( ! $rewritten ) { wp_send_json_error( 'Could not generate a dialect-adapted description. Please try again.' ); } wp_send_json_success( [ 'description' => sanitize_textarea_field( $rewritten ) ] ); } /** * Settings page "Validate Hreflang Pairs" button. Runs * AWBSEO_Hreflang_Manager::audit_pairs() (HTTP status + reciprocity * checks against the live pages) and returns the results so the admin * UI can list exactly what's wrong, rather than the user only finding * out weeks later that Google silently dropped their hreflang tags. */ public static function ajax_validate_hreflang() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Permission denied.' ); } $settings = get_option( 'awbseo_settings', [] ); $pair_count = count( array_filter( $settings['hreflang_pairs'] ?? [], function ( $p ) { return ! empty( $p['lang'] ) && ! empty( $p['url'] ); } ) ); if ( $pair_count < 2 ) { wp_send_json_success( [ 'issues' => [], 'checked' => $pair_count, 'notice' => 'Add at least two hreflang pairs before validating - reciprocity can only be checked between two or more language versions.', ] ); } $issues = AWBSEO_Hreflang_Manager::audit_pairs(); wp_send_json_success( [ 'issues' => $issues, 'checked' => $pair_count, 'notice' => '', ] ); } /** * Same dialect label/hint pairs used by ajax_dialect_adapt() and * ajax_dialect_adapt_description() above (kept as separate literal * copies there deliberately, matching how the standalone * suggest-meta-desc.php broker script also carries its own copy - each * caller stays independently readable rather than sharing a fragile * cross-file dependency). This one small shared helper is only for the * two NEW local-SEO generators below, so it doesn't touch anything * already shipped. */ private static function dialect_meta( $dialect ) { $labels = [ 'msa' => 'Modern Standard Arabic (Fus-ha)', 'saudi' => 'Saudi Najdi Arabic', 'egyptian' => 'Egyptian Arabic (Masri)', 'kuwaiti' => 'Kuwaiti Arabic', 'lebanese' => 'Lebanese Arabic', 'uae' => 'Emirati Arabic (UAE)', 'iraqi' => 'Iraqi Arabic', 'jordanian' => 'Jordanian Arabic', 'moroccan' => 'Moroccan Arabic (Darija)', ]; $hints = [ 'saudi' => 'Use Najdi/Hejazi colloquial words people actually type, e.g. "وين" not "أين", "كم سعر" not "ما هي تكلفة", drop formal case endings and literary connectors like "لدى" or "بغية".', 'egyptian' => 'Use Masri colloquial words, e.g. "فين" not "أين", "ازاي" not "كيف", drop formal Fus-ha endings and connectors.', 'kuwaiti' => 'Use Kuwaiti Gulf colloquial words, e.g. "وين", "شلون", "شنو", everyday spoken vocabulary instead of media/formal Arabic.', 'lebanese' => 'Use Lebanese colloquial words, e.g. "وين", "كيفك", "بكم" instead of "ما هو السعر", drop formal Fus-ha grammar.', 'uae' => 'Use Emirati Gulf colloquial words, e.g. "وين" not "أين", "شنو" not "ماذا", "كم سعره" not "ما هي تكلفته", everyday spoken phrasing an Emirati or Gulf resident would actually read and say -- not literary/media Arabic.', 'iraqi' => 'Use Iraqi colloquial words, e.g. "وين", "شلون", "شنو", "اكو" (there is), everyday spoken Iraqi phrasing instead of formal Fus-ha.', 'jordanian' => 'Use Jordanian colloquial words, e.g. "وين", "كيفك", "قديش" not "كم", everyday Levantine spoken phrasing instead of formal Fus-ha.', 'moroccan' => 'Use Darija: French/Berber-influenced vocabulary, e.g. "فين" not "أين", "بشحال" not "كم", distinctly different from Fus-ha.', ]; return [ 'label' => $labels[ $dialect ] ?? $dialect, 'hint' => $hints[ $dialect ] ?? '', ]; } /** * Calls the same dialect broker used by the meta box's dialect adapter, * with a caller-supplied prompt. Centralizes the wp_remote_post * boilerplate (and its error handling) for the two new generators * below, both of which need it verbatim. */ private static function call_dialect_broker( $prompt, $max_tokens = 260 ) { $response = wp_remote_post( AWBSEO_DIALECT_BROKER_URL, [ 'timeout' => 25, 'headers' => [ 'Content-Type' => 'application/json', 'X-AWB-Token' => AWBSEO_SUGGEST_BROKER_TOKEN, ], 'body' => wp_json_encode( [ 'prompt' => $prompt, 'engine' => 'awbseo', 'max_tokens' => $max_tokens, 'site' => home_url(), ] ), ] ); if ( is_wp_error( $response ) ) { return new WP_Error( 'awb_broker', $response->get_error_message() ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $body['error'] ) ) { return new WP_Error( 'awb_broker', $body['error'] ); } $text = trim( trim( $body['text'] ?? '' ), "\"'\n\r\t " ); if ( ! $text ) { return new WP_Error( 'awb_broker', 'The AI engine returned an empty response. Please try again.' ); } return $text; } /** * Local SEO page: "Generate Google Business Profile description" - the * dialect adapter previously only touched meta descriptions and * keywords inside the meta box. This extends the same dialect-authentic * writing to the one piece of local-SEO copy every business actually * has to write themselves: their Google Business Profile description. */ public static function ajax_generate_gbp_description() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Permission denied.' ); } $business = sanitize_text_field( wp_unslash( $_POST['business_name'] ?? '' ) ); $category = sanitize_text_field( wp_unslash( $_POST['category'] ?? '' ) ); $city = sanitize_text_field( wp_unslash( $_POST['city'] ?? '' ) ); $dialect = sanitize_text_field( wp_unslash( $_POST['dialect'] ?? '' ) ); if ( ! $business || ! $dialect ) { wp_send_json_error( 'Missing business name or dialect.' ); } $meta = self::dialect_meta( $dialect ); $dialect_label = $meta['label']; $dialect_hint = $meta['hint']; $where = $city ? " in {$city}" : ''; $what = $category ? ", a {$category} business" : ''; $prompt = "You are an expert Arabic SEO copywriter who deeply knows regional dialects, not just Modern Standard Arabic. " . "Write a Google Business Profile description for \"{$business}\"{$what}{$where}, entirely in {$dialect_label}, the way a real local business owner would describe their own business to {$dialect_label}-speaking customers.\n\n" . "CRITICAL: Modern Standard Arabic (Fus-ha) is the WRONG output unless the requested dialect is literally MSA. " . "Do not write a lightly-reworded formal description -- write it so it reads as genuinely colloquial {$dialect_label}. " . ( $dialect_hint ? "Specifically for {$dialect_label}: {$dialect_hint}\n\n" : "\n" ) . "Keep it warm, trustworthy, and specific to what makes this business worth visiting. Google Business Profile descriptions are capped at 750 characters, so keep it under 700. " . "Do not include a phone number, address, or URL - those already appear elsewhere on the profile. " . "Return ONLY the description as plain text, no quotes, no explanation, no markdown."; $result = self::call_dialect_broker( $prompt, 260 ); if ( is_wp_error( $result ) ) { wp_send_json_error( $result->get_error_message() ); } wp_send_json_success( [ 'description' => sanitize_textarea_field( $result ) ] ); } /** * Local SEO page: "Generate a review reply" - takes a pasted customer * review and drafts a dialect-authentic owner reply, since a generic * MSA "thank you for your feedback" reads as corporate and impersonal * to a customer who wrote the review in their own dialect. */ public static function ajax_generate_review_reply() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Permission denied.' ); } $review = sanitize_textarea_field( wp_unslash( $_POST['review'] ?? '' ) ); $business = sanitize_text_field( wp_unslash( $_POST['business_name'] ?? '' ) ); $dialect = sanitize_text_field( wp_unslash( $_POST['dialect'] ?? '' ) ); if ( ! $review || ! $dialect ) { wp_send_json_error( 'Missing review text or dialect.' ); } $meta = self::dialect_meta( $dialect ); $dialect_label = $meta['label']; $dialect_hint = $meta['hint']; $signed_by = $business ? " on behalf of \"{$business}\"" : ''; $prompt = "You are a warm, professional customer-service voice writing{$signed_by}. " . "Read the customer review below and write a reply entirely in {$dialect_label}, the way a real local business owner would personally respond to {$dialect_label}-speaking customers.\n\n" . "Customer review:\n\"{$review}\"\n\n" . "CRITICAL: Modern Standard Arabic (Fus-ha) is the WRONG output unless the requested dialect is literally MSA. " . "Do not write a lightly-reworded formal reply -- write it so it reads as genuinely colloquial {$dialect_label}. " . ( $dialect_hint ? "Specifically for {$dialect_label}: {$dialect_hint}\n\n" : "\n" ) . "If the review is positive, thank them warmly and specifically (reference what they mentioned, if anything). " . "If the review is negative or mixed, apologize sincerely without being defensive, and briefly offer to make it right (e.g. contact the business directly) without over-promising. " . "Keep it concise (2-4 sentences), never robotic or generic. " . "Return ONLY the reply as plain text, no quotes, no explanation, no markdown."; $result = self::call_dialect_broker( $prompt, 220 ); if ( is_wp_error( $result ) ) { wp_send_json_error( $result->get_error_message() ); } wp_send_json_success( [ 'reply' => sanitize_textarea_field( $result ) ] ); } // ── React meta box - live analysis ──────────────────────────────────────── /** * Returns a fresh analysis result using live field values from the React * meta box (before the user has saved). Keyword, title, and description are * taken from POST; all other checks still use the saved post content. */ public static function ajax_live_analyze() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( 'Permission denied.' ); } $post_id = absint( $_POST['post_id'] ?? 0 ); if ( ! $post_id || ! get_post( $post_id ) ) { wp_send_json_error( 'Invalid post.' ); } $overrides = [ 'focus_keyword' => sanitize_text_field( wp_unslash( $_POST['focus_keyword'] ?? '' ) ), 'title' => sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) ), 'description' => sanitize_textarea_field( wp_unslash( $_POST['description'] ?? '' ) ), ]; $result = AWBSEO_On_Page_Analyzer::analyze( $post_id, $overrides ); wp_send_json_success( $result ); } }