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 );
}
}