"VARCHAR(60) NOT NULL DEFAULT 'LocalBusiness'", 'name_ar' => "VARCHAR(255) NOT NULL DEFAULT ''", 'street_ar' => "VARCHAR(255) NOT NULL DEFAULT ''", 'city_ar' => "VARCHAR(120) NOT NULL DEFAULT ''", 'region_ar' => "VARCHAR(120) NOT NULL DEFAULT ''", 'maps_url' => "VARCHAR(512) NOT NULL DEFAULT ''", 'whatsapp' => "VARCHAR(40) NOT NULL DEFAULT ''", 'image' => "VARCHAR(512) NOT NULL DEFAULT ''", 'area_served' => "VARCHAR(255) NOT NULL DEFAULT ''", ]; public static function init() { add_action( 'wp_head', [ __CLASS__, 'output_schema' ], 6 ); add_action( 'admin_init', [ __CLASS__, 'maybe_upgrade_table' ] ); add_shortcode( 'citedseo_location', [ __CLASS__, 'shortcode_location' ] ); add_shortcode( 'citedseo_locations', [ __CLASS__, 'shortcode_locations' ] ); } /** * Business types a location can be (schema.org LocalBusiness subtypes). * The same list is offered in the post-level schema dropdown. */ public static function business_types() { return [ 'LocalBusiness' => __( 'Local business (general)', 'adworksbid-seo' ), 'Restaurant' => __( 'Restaurant', 'adworksbid-seo' ), 'CafeOrCoffeeShop' => __( 'Café or coffee shop', 'adworksbid-seo' ), 'Bakery' => __( 'Bakery', 'adworksbid-seo' ), 'Store' => __( 'Store', 'adworksbid-seo' ), 'Hotel' => __( 'Hotel', 'adworksbid-seo' ), 'MedicalBusiness' => __( 'Medical business', 'adworksbid-seo' ), 'MedicalClinic' => __( 'Medical clinic', 'adworksbid-seo' ), 'Dentist' => __( 'Dentist', 'adworksbid-seo' ), 'Pharmacy' => __( 'Pharmacy', 'adworksbid-seo' ), 'BeautySalon' => __( 'Beauty salon', 'adworksbid-seo' ), 'LegalService' => __( 'Law firm / legal service', 'adworksbid-seo' ), 'FinancialService' => __( 'Financial service', 'adworksbid-seo' ), 'RealEstateAgent' => __( 'Real estate agency', 'adworksbid-seo' ), 'AutoDealer' => __( 'Car dealer', 'adworksbid-seo' ), 'TravelAgency' => __( 'Travel agency', 'adworksbid-seo' ), 'ProfessionalService' => __( 'Professional service (agency, consultancy...)', 'adworksbid-seo' ), ]; } /** * Adds the version 2 columns to existing installs. dbDelta can't be * relied on here (the table is created with IF NOT EXISTS), so each * missing column is added explicitly, once. */ public static function maybe_upgrade_table() { if ( get_option( 'awbseo_local_db_version' ) === self::DB_VERSION ) { return; } global $wpdb; $table = $wpdb->prefix . self::TABLE; if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) { self::create_table(); } foreach ( self::V2_COLUMNS as $col => $def ) { $exists = $wpdb->get_results( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", $col ) ); if ( empty( $exists ) ) { $wpdb->query( "ALTER TABLE {$table} ADD COLUMN {$col} {$def}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column names/definitions are class constants. } } update_option( 'awbseo_local_db_version', self::DB_VERSION, false ); } public static function create_table() { global $wpdb; $table = $wpdb->prefix . self::TABLE; $charset = $wpdb->get_charset_collate(); $sql = "CREATE TABLE IF NOT EXISTS {$table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(255) NOT NULL, street VARCHAR(255) NOT NULL DEFAULT '', city VARCHAR(120) NOT NULL DEFAULT '', region VARCHAR(120) NOT NULL DEFAULT '', postal_code VARCHAR(30) NOT NULL DEFAULT '', country VARCHAR(2) NOT NULL DEFAULT 'US', phone VARCHAR(40) NOT NULL DEFAULT '', email VARCHAR(190) NOT NULL DEFAULT '', lat VARCHAR(30) NOT NULL DEFAULT '', lng VARCHAR(30) NOT NULL DEFAULT '', price_range VARCHAR(10) NOT NULL DEFAULT '', hours TEXT, page_id BIGINT UNSIGNED NOT NULL DEFAULT 0, is_primary TINYINT(1) NOT NULL DEFAULT 0, business_type VARCHAR(60) NOT NULL DEFAULT 'LocalBusiness', name_ar VARCHAR(255) NOT NULL DEFAULT '', street_ar VARCHAR(255) NOT NULL DEFAULT '', city_ar VARCHAR(120) NOT NULL DEFAULT '', region_ar VARCHAR(120) NOT NULL DEFAULT '', maps_url VARCHAR(512) NOT NULL DEFAULT '', whatsapp VARCHAR(40) NOT NULL DEFAULT '', image VARCHAR(512) NOT NULL DEFAULT '', area_served VARCHAR(255) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) {$charset};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); update_option( 'awbseo_local_db_version', self::DB_VERSION, false ); } // ── CRUD ───────────────────────────────────────────────────────────────── public static function get_locations() { global $wpdb; $table = $wpdb->prefix . self::TABLE; return $wpdb->get_results( "SELECT * FROM {$table} ORDER BY is_primary DESC, name ASC" ); } public static function get_location( $id ) { global $wpdb; $table = $wpdb->prefix . self::TABLE; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) ); } public static function get_primary_location() { global $wpdb; $table = $wpdb->prefix . self::TABLE; $row = $wpdb->get_row( "SELECT * FROM {$table} WHERE is_primary = 1 ORDER BY id ASC LIMIT 1" ); if ( ! $row ) { $row = $wpdb->get_row( "SELECT * FROM {$table} ORDER BY id ASC LIMIT 1" ); } return $row; } public static function save_location( $data ) { global $wpdb; $table = $wpdb->prefix . self::TABLE; $row = [ 'name' => sanitize_text_field( $data['name'] ?? '' ), 'street' => sanitize_text_field( $data['street'] ?? '' ), 'city' => sanitize_text_field( $data['city'] ?? '' ), 'region' => sanitize_text_field( $data['region'] ?? '' ), 'postal_code' => sanitize_text_field( $data['postal_code'] ?? '' ), 'country' => sanitize_text_field( $data['country'] ?? 'US' ), 'phone' => sanitize_text_field( $data['phone'] ?? '' ), 'email' => sanitize_email( $data['email'] ?? '' ), 'lat' => sanitize_text_field( $data['lat'] ?? '' ), 'lng' => sanitize_text_field( $data['lng'] ?? '' ), 'price_range' => sanitize_text_field( $data['price_range'] ?? '' ), 'hours' => wp_json_encode( self::sanitize_hours( $data['hours'] ?? [] ) ), 'page_id' => (int) ( $data['page_id'] ?? 0 ), 'updated_at' => current_time( 'mysql' ), ]; // Version 2 fields - only written when the table has been upgraded, // so a save can never fail on an install that hasn't run the migration. if ( get_option( 'awbseo_local_db_version' ) === self::DB_VERSION ) { $type = sanitize_text_field( $data['business_type'] ?? 'LocalBusiness' ); $row = array_merge( $row, [ 'business_type' => isset( self::business_types()[ $type ] ) ? $type : 'LocalBusiness', 'name_ar' => sanitize_text_field( $data['name_ar'] ?? '' ), 'street_ar' => sanitize_text_field( $data['street_ar'] ?? '' ), 'city_ar' => sanitize_text_field( $data['city_ar'] ?? '' ), 'region_ar' => sanitize_text_field( $data['region_ar'] ?? '' ), 'maps_url' => esc_url_raw( $data['maps_url'] ?? '' ), 'whatsapp' => sanitize_text_field( $data['whatsapp'] ?? '' ), 'image' => esc_url_raw( $data['image'] ?? '' ), 'area_served' => sanitize_text_field( $data['area_served'] ?? '' ), ] ); } if ( ! $row['name'] ) { return new WP_Error( 'awb_local_seo', 'Location name is required.' ); } $id = (int) ( $data['id'] ?? 0 ); if ( $id ) { $wpdb->update( $table, $row, [ 'id' => $id ] ); } else { $row['created_at'] = current_time( 'mysql' ); $wpdb->insert( $table, $row ); $id = (int) $wpdb->insert_id; } if ( ! empty( $data['is_primary'] ) ) { self::set_primary( $id ); } return $id; } public static function set_primary( $id ) { global $wpdb; $table = $wpdb->prefix . self::TABLE; $wpdb->query( "UPDATE {$table} SET is_primary = 0" ); $wpdb->update( $table, [ 'is_primary' => 1 ], [ 'id' => (int) $id ] ); } public static function delete_location( $id ) { global $wpdb; $table = $wpdb->prefix . self::TABLE; return $wpdb->delete( $table, [ 'id' => (int) $id ] ); } private static function sanitize_hours( $hours ) { if ( ! is_array( $hours ) ) return []; $clean = []; foreach ( $hours as $row ) { $day = sanitize_text_field( $row['day'] ?? '' ); $opens = sanitize_text_field( $row['opens'] ?? '' ); $closes = sanitize_text_field( $row['closes'] ?? '' ); if ( $day && $opens && $closes ) { $clean[] = compact( 'day', 'opens', 'closes' ); } } return $clean; } // ── Schema ─────────────────────────────────────────────────────────────── /** Whether location details should be shown in Arabic for this page. */ private static function use_arabic( $post_id = 0 ) { if ( ! $post_id && ! is_admin() ) { $post_id = (int) get_queried_object_id(); } if ( $post_id && class_exists( 'AWBSEO_Schema_Manager' ) ) { return AWBSEO_Schema_Manager::page_is_arabic( $post_id ); } return 0 === strpos( determine_locale(), 'ar' ); } /** Name + address parts in the page language, falling back to whichever version is filled in. */ public static function localized( $location, $post_id = 0, $force_ar = null ) { $ar = null === $force_ar ? self::use_arabic( $post_id ) : $force_ar; $pick = function ( $field ) use ( $location, $ar ) { $en = (string) ( $location->$field ?? '' ); $a = (string) ( $location->{$field . '_ar'} ?? '' ); return ( $ar && '' !== $a ) ? $a : $en; }; $name = $pick( 'name' ); $other = $ar ? (string) $location->name : (string) ( $location->name_ar ?? '' ); return [ 'is_ar' => $ar, 'name' => $name, 'alt_name' => ( '' !== $other && $other !== $name ) ? $other : '', 'street' => $pick( 'street' ), 'city' => $pick( 'city' ), 'region' => $pick( 'region' ), ]; } private static function postal_address( $location, $l ) { return [ '@type' => 'PostalAddress', 'streetAddress' => $l['street'] ?: null, 'addressLocality' => $l['city'] ?: null, 'addressRegion' => $l['region'] ?: null, 'postalCode' => $location->postal_code ?: null, 'addressCountry' => $location->country ?: null, ]; } /** Minimal Place (name + address + geo) - used as an Event venue. */ public static function place_schema( $location, $post_id = 0 ) { $l = self::localized( $location, $post_id ); $schema = [ '@type' => 'Place', 'name' => $l['name'], 'address' => self::postal_address( $location, $l ) ]; if ( $location->lat && $location->lng ) { $schema['geo'] = [ '@type' => 'GeoCoordinates', 'latitude' => (float) $location->lat, 'longitude' => (float) $location->lng ]; } return $schema; } /** * Build a LocalBusiness JSON-LD array for a single location row. * * Name and address follow the page language: on an Arabic page the * Arabic name/address are used (English name kept as alternateName), * and the reverse on English pages. Every location gets a stable @id, * so the same branch described by Local SEO and by a post's own * LocalBusiness schema is understood as one entity, not two. * * @param object $location Location row. * @param int $post_id Page being rendered (0 = detect). * @param string $type_override Schema type chosen on the post, if any. */ public static function build_schema( $location, $post_id = 0, $type_override = '' ) { if ( ! $location ) return null; $types = self::business_types(); $type = $type_override && 'LocalBusiness' !== $type_override ? $type_override : ( $location->business_type ?? '' ); if ( ! $type || ( ! isset( $types[ $type ] ) && $type !== $type_override ) ) { $type = 'LocalBusiness'; } $l = self::localized( $location, $post_id ); $page = $location->page_id ? get_permalink( $location->page_id ) : ''; $maps = $location->maps_url ?? ''; $wa = $location->whatsapp ?? ''; $areas = array_values( array_filter( array_map( 'trim', preg_split( '/[,،]+/u', (string) ( $location->area_served ?? '' ) ) ) ) ); $image = ( $location->image ?? '' ) ?: ( get_site_icon_url() ?: null ); $schema = [ '@type' => $type, '@id' => home_url( '/#location-' . (int) $location->id ), 'name' => $l['name'], 'alternateName' => $l['alt_name'] ?: null, 'url' => $page ?: home_url( '/' ), 'image' => $image, 'telephone' => $location->phone ?: null, 'email' => $location->email ?: null, 'priceRange' => $location->price_range ?: null, 'address' => self::postal_address( $location, $l ), 'hasMap' => $maps ?: null, 'sameAs' => $maps ? [ $maps ] : null, 'areaServed' => $areas ?: null, ]; if ( $location->lat && $location->lng ) { $schema['geo'] = [ '@type' => 'GeoCoordinates', 'latitude' => (float) $location->lat, 'longitude' => (float) $location->lng, ]; } $hours = json_decode( $location->hours ?: '[]', true ); if ( $hours ) { $spec = []; foreach ( $hours as $h ) { $spec[] = [ '@type' => 'OpeningHoursSpecification', 'dayOfWeek' => $h['day'], 'opens' => $h['opens'], 'closes' => $h['closes'], ]; } $schema['openingHoursSpecification'] = $spec; } if ( $wa ) { // WhatsApp is how most GCC customers actually reach a business. $schema['contactPoint'] = [ '@type' => 'ContactPoint', 'name' => 'WhatsApp', 'telephone' => $wa, 'contactType' => 'customer service', 'url' => 'https://wa.me/' . preg_replace( '/\D/', '', $wa ), ]; } // Branches of a multi-location business point back to one parent // organization, so Google and AI engines see one brand with several // branches rather than unrelated businesses. static $location_count = null; if ( null === $location_count ) { $location_count = count( (array) self::get_locations() ); } if ( $location_count > 1 ) { $schema['parentOrganization'] = [ '@type' => 'Organization', '@id' => home_url( '/#organization' ), 'name' => get_bloginfo( 'name' ), 'url' => home_url( '/' ), ]; } /** * Filters one location's LocalBusiness schema (Ramadan & Eid mode * adds special opening hours here). * * @param array $schema * @param object $location */ $schema = apply_filters( 'awbseo_location_schema', $schema, $location ); return class_exists( 'AWBSEO_Schema_Manager' ) ? AWBSEO_Schema_Manager::prune( $schema ) : $schema; } public static function output_schema() { $locations = self::get_locations(); if ( ! $locations ) return; $matched_id = 0; $emitted = []; // Page-specific location, if the current page is pinned to one. if ( is_singular() ) { $post_id = get_the_ID(); foreach ( $locations as $loc ) { if ( (int) $loc->page_id === (int) $post_id ) { $matched_id = (int) $loc->id; $schema = self::build_schema( $loc ); if ( $schema ) { $emitted[] = $schema; } } } } // Primary location's schema, site-wide, unless already emitted above. $primary = self::get_primary_location(); if ( $primary && (int) $primary->id !== $matched_id ) { $schema = self::build_schema( $primary ); if ( $schema ) { $emitted[] = $schema; } } foreach ( $emitted as $schema ) { // JSON_HEX_TAG escapes literal < and > inside encoded string // values (e.g. a location name containing "") so they // can never break out of this \n"; } } // ── Shortcodes ─────────────────────────────────────────────────────────── /** * Front-end labels. Arabic comes from a fixed map rather than the .mo, * because an Arabic page on an English-locale site (the common setup) * would otherwise still get English labels. */ private static function label( $key, $is_ar ) { $ar = [ 'phone' => 'الهاتف', 'whatsapp' => 'واتساب', 'email' => 'البريد الإلكتروني', 'hours' => 'ساعات العمل', 'closed' => 'مغلق', 'directions' => 'الاتجاهات', 'open_now' => 'مفتوح الآن', 'closed_now' => 'مغلق الآن', 'ramadan_hours' => 'ساعات العمل في رمضان', 'Monday' => 'الإثنين', 'Tuesday' => 'الثلاثاء', 'Wednesday' => 'الأربعاء', 'Thursday' => 'الخميس', 'Friday' => 'الجمعة', 'Saturday' => 'السبت', 'Sunday' => 'الأحد', ]; if ( $is_ar ) { return $ar[ $key ] ?? $key; } $en = [ 'phone' => __( 'Phone', 'adworksbid-seo' ), 'whatsapp' => __( 'WhatsApp', 'adworksbid-seo' ), 'email' => __( 'Email', 'adworksbid-seo' ), 'hours' => __( 'Opening hours', 'adworksbid-seo' ), 'closed' => __( 'Closed', 'adworksbid-seo' ), 'directions' => __( 'Get directions', 'adworksbid-seo' ), 'open_now' => __( 'Open now', 'adworksbid-seo' ), 'closed_now' => __( 'Closed now', 'adworksbid-seo' ), 'ramadan_hours' => __( 'Ramadan hours', 'adworksbid-seo' ), 'Monday' => __( 'Monday', 'adworksbid-seo' ), 'Tuesday' => __( 'Tuesday', 'adworksbid-seo' ), 'Wednesday' => __( 'Wednesday', 'adworksbid-seo' ), 'Thursday' => __( 'Thursday', 'adworksbid-seo' ), 'Friday' => __( 'Friday', 'adworksbid-seo' ), 'Saturday' => __( 'Saturday', 'adworksbid-seo' ), 'Sunday' => __( 'Sunday', 'adworksbid-seo' ), ]; return $en[ $key ] ?? $key; } /** true = open, false = closed, null = no hours set. Handles overnight hours (e.g. 18:00-02:00). */ public static function is_open_now( $location ) { $hours = json_decode( $location->hours ?: '[]', true ); $now = new DateTime( 'now', wp_timezone() ); if ( class_exists( 'AWBSEO_Ramadan_Mode' ) && AWBSEO_Ramadan_Mode::is_eid_closed() ) { return false; // closed for Eid (Ramadan & Eid mode) } // During Ramadan the location's Ramadan hours replace its normal hours, every day. $ramadan = class_exists( 'AWBSEO_Ramadan_Mode' ) ? AWBSEO_Ramadan_Mode::active_hours( $location ) : []; if ( $ramadan ) { $hours = []; foreach ( [ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday' ] as $d ) { foreach ( $ramadan as $r ) { $hours[] = [ 'day' => $d, 'opens' => $r[0], 'closes' => $r[1] ]; } } } if ( ! $hours ) return null; $today = $now->format( 'l' ); $yday = ( clone $now )->modify( '-1 day' )->format( 'l' ); $time = $now->format( 'H:i' ); foreach ( $hours as $h ) { $opens = $h['opens'] ?? ''; $closes = $h['closes'] ?? ''; if ( ! $opens || ! $closes ) continue; $overnight = $closes <= $opens; if ( $h['day'] === $today && ( $overnight ? $time >= $opens : ( $time >= $opens && $time < $closes ) ) ) return true; if ( $overnight && $h['day'] === $yday && $time < $closes ) return true; } return false; } /** * [citedseo_location id="3" show="address,phone,whatsapp,email,hours,map" lang="auto"] * Shows one location (default: the main one) in the page's language. */ public static function shortcode_location( $atts ) { $atts = shortcode_atts( [ 'id' => 0, 'show' => 'address,phone,whatsapp,email,hours,map', 'lang' => 'auto' ], $atts, 'citedseo_location' ); $loc = (int) $atts['id'] ? self::get_location( (int) $atts['id'] ) : self::get_primary_location(); if ( ! $loc ) return ''; return self::render_location( $loc, $atts ); } /** [citedseo_locations show="address,phone,hours,map"] - every location, main one first. */ public static function shortcode_locations( $atts ) { $atts = shortcode_atts( [ 'show' => 'address,phone,whatsapp,hours,map', 'lang' => 'auto' ], $atts, 'citedseo_locations' ); $out = ''; foreach ( (array) self::get_locations() as $loc ) { $out .= self::render_location( $loc, $atts ); } return $out ? '
' . $out . '
' : ''; } private static function render_location( $loc, $atts ) { $force = 'ar' === $atts['lang'] ? true : ( 'en' === $atts['lang'] ? false : null ); $l = self::localized( $loc, get_the_ID() ?: 0, $force ); $ar = $l['is_ar']; $show = array_map( 'trim', explode( ',', strtolower( $atts['show'] ) ) ); $wa = $loc->whatsapp ?? ''; static $styled = false; $css = ''; if ( ! $styled ) { $styled = true; $css = ''; } $html = '
'; $open = in_array( 'hours', $show, true ) ? self::is_open_now( $loc ) : null; $badge = null === $open ? '' : '' . esc_html( self::label( $open ? 'open_now' : 'closed_now', $ar ) ) . ''; $html .= '

' . esc_html( $l['name'] ) . $badge . '

'; if ( in_array( 'address', $show, true ) ) { $parts = array_filter( [ $l['street'], $l['city'], $l['region'], $loc->postal_code ] ); if ( $parts ) { $html .= '

' . esc_html( implode( $ar ? '، ' : ', ', $parts ) ) . '

'; } } if ( in_array( 'phone', $show, true ) && $loc->phone ) { $html .= '

' . esc_html( self::label( 'phone', $ar ) ) . ': ' . esc_html( $loc->phone ) . '

'; } if ( in_array( 'whatsapp', $show, true ) && $wa ) { $html .= '

' . esc_html( self::label( 'whatsapp', $ar ) ) . ': ' . esc_html( $wa ) . '

'; } if ( in_array( 'email', $show, true ) && $loc->email ) { $html .= '

' . esc_html( self::label( 'email', $ar ) ) . ': ' . esc_html( $loc->email ) . '

'; } $ramadan = ( in_array( 'hours', $show, true ) && class_exists( 'AWBSEO_Ramadan_Mode' ) ) ? AWBSEO_Ramadan_Mode::active_hours( $loc ) : []; if ( $ramadan ) { $html .= '

🌙 ' . esc_html( self::label( 'ramadan_hours', $ar ) ) . ': ' . esc_html( implode( ', ', array_map( fn( $r ) => $r[0] . ' - ' . $r[1], $ramadan ) ) ) . '

'; } if ( in_array( 'hours', $show, true ) && ! $ramadan ) { $hours = json_decode( $loc->hours ?: '[]', true ); if ( $hours ) { $by_day = []; foreach ( $hours as $h ) { $by_day[ $h['day'] ][] = $h['opens'] . ' - ' . $h['closes']; } $today = ( new DateTime( 'now', wp_timezone() ) )->format( 'l' ); $html .= '

' . esc_html( self::label( 'hours', $ar ) ) . '

'; foreach ( [ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday' ] as $d ) { $html .= '' . esc_html( isset( $by_day[ $d ] ) ? implode( ', ', $by_day[ $d ] ) : self::label( 'closed', $ar ) ) . ''; } $html .= '
' . esc_html( self::label( $d, $ar ) ) . '
'; } } if ( in_array( 'map', $show, true ) ) { $maps = ( $loc->maps_url ?? '' ) ?: ( ( $loc->lat && $loc->lng ) ? 'https://www.google.com/maps/search/?api=1&query=' . rawurlencode( $loc->lat . ',' . $loc->lng ) : '' ); if ( $maps ) { $html .= '

' . esc_html( self::label( 'directions', $ar ) ) . ' ↗

'; } } return $css . $html . '
'; } // ── NAP Consistency Check ──────────────────────────────────────────────── /** * Scans published pages for phone numbers that don't match the primary * location's phone, and flags whether the primary location's city is * mentioned anywhere on the homepage. Rule-based - no external API * required, mirrors the approach used by AWBSEO_Content_Decay. */ public static function nap_consistency_check() { $primary = self::get_primary_location(); if ( ! $primary || ! $primary->phone ) { return [ 'status' => 'no_primary', 'issues' => [] ]; } $primary_digits = preg_replace( '/\D/', '', $primary->phone ); $issues = []; $pages = get_posts( [ 'post_type' => [ 'page', 'post' ], 'post_status' => 'publish', 'posts_per_page' => 60, ] ); foreach ( $pages as $page ) { // Strip shortcodes first - form plugins (WPForms, Gravity Forms, etc.) // embed entry IDs / Unix timestamps in shortcode attributes that look // like long digit strings and get falsely flagged as phone mismatches. $text = wp_strip_all_tags( strip_shortcodes( $page->post_content ) ); preg_match_all( '/(\+?\d[\d\-.\(\) ]{7,}\d)/', $text, $matches ); foreach ( $matches[1] ?? [] as $raw ) { $digits = preg_replace( '/\D/', '', $raw ); if ( strlen( $digits ) < 7 ) continue; // too short - not a phone // GPS coordinates contain a decimal point; phone numbers never do. if ( strpos( $raw, '.' ) !== false ) continue; // Skip bare unformatted digit strings over 12 digits - those are // database IDs or Unix timestamps, not phone numbers. Real phone // numbers (even international with country code) are ≤12 digits // and almost always include formatting (+, spaces, dashes, etc.). if ( strlen( $digits ) > 12 && trim( $raw ) === $digits ) continue; // Skip year ranges like "2025-2026" or "2019-20" - these are // never phone numbers but match the digit-dash-digit pattern. if ( preg_match( '/^(19|20)\d{2}-(19|20)?\d{2,4}$/', trim( $raw ) ) ) continue; if ( $digits !== $primary_digits && substr( $digits, -7 ) !== substr( $primary_digits, -7 ) ) { $issues[] = [ 'post_id' => $page->ID, 'title' => $page->post_title, 'found' => trim( $raw ), 'type' => 'phone_mismatch', ]; } } } // Homepage city mention check. if ( $primary->city ) { $front_id = (int) get_option( 'page_on_front' ); $front = $front_id ? get_post( $front_id ) : null; $haystack = $front ? wp_strip_all_tags( $front->post_content ) : ''; if ( $haystack && stripos( $haystack, $primary->city ) === false ) { $issues[] = [ 'post_id' => $front_id, 'title' => $front ? $front->post_title : 'Homepage', 'found' => '', 'type' => 'city_missing_homepage', ]; } } return [ 'status' => $issues ? 'issues' : 'clean', 'issues' => $issues ]; } }