|

Local Business Schema Markup Auto Updated Ratings

One area many local businesses overlook is review data within their schema markup. While new reviews continue to come in on Google, the review information embedded on their website often remains outdated for months or even years.

As AI search engines and crawlers analyze websites, they may pick up old ratings and review counts if the schema isn’t being refreshed regularly. This can result in outdated information appearing in search results, AI-generated answers, and other search experiences.

The ideal solution is to automate review updates directly within your schema markup.

I’ve developed a simple PHP code snippet for WordPress that automatically pulls your latest Google Business Profile rating and review count using your Google Places API key and Place ID. Once installed, your website’s schema stays updated in the background without any manual work.

This helps ensure that when Google, ChatGPT, Gemini, Perplexity, and other AI systems crawl your website, they’re seeing your most current review data, increasing the likelihood of accurate ratings being displayed in search results and AI-generated mentions.

Your Google reviews change every day. Your schema markup should too.

Code is here:

/**
 * Basit M. Local and Service Schema Markup
 */
add_action('admin_menu', 'schema_global_settings_page');
function schema_global_settings_page() {
    add_options_page(
        'Global Schema Settings',
        'Global Schema',
        'manage_options',
        'global-schema-settings',
        'schema_global_settings_content'
    );
}

function schema_global_settings_content() {
    ?>
    <div class="wrap">
        <h2>Global Schema Settings</h2>
        <p>Values set here become **STRICT DEFAULTS** and will disable the corresponding field in the page/post editor. Leave a field empty if you wish to set it page-by-page.</p>
        <form method="post" action="options.php">
            <?php
            settings_fields('schema_global_settings_group');
            do_settings_sections('global-schema-settings');
            submit_button();
            ?>
        </form>
    </div>
    <?php
}

add_action('admin_init', 'schema_global_settings_init');
function schema_global_settings_init() {
    register_setting('schema_global_settings_group', 'schema_global_settings', 'schema_sanitize_global_settings');

    // Define the full list of Business Types
    $business_types = [
        "LocalBusiness","AnimalShelter","ArchiveOrganization","AutomotiveBusiness","ChildCare","Dentist",
        "DryCleaningOrLaundry","EmergencyService","EmploymentAgency","EntertainmentBusiness","FinancialService",
        "FoodEstablishment","GovernmentOffice","HealthAndBeautyBusiness","HomeAndConstructionBusiness","InternetCafe",
        "LegalService","Library","LodgingBusiness","MedicalBusiness","ProfessionalService","RadioStation","RealEstateAgent",
        "RecyclingCenter","SelfStorage","ShoppingCenter","SportsActivityLocation","Store","TelevisionStation",
        "TouristInformationCenter","TravelAgency"
    ];

    // --- Local Business Fields ---
    add_settings_section('schema_local_business_section', 'Local Business Defaults (Strict)', null, 'global-schema-settings');
    
    // Use select renderer for businessType
    add_settings_field(
        'businessType', 
        'Business Type', 
        'schema_render_global_select', 
        'global-schema-settings', 
        'schema_local_business_section', 
        ['id' => 'businessType', 'options' => $business_types] 
    );

    // The rest of the local fields (UPDATED WITH NEW REQUESTED FIELDS)
    $local_fields = [
        'name' => 'Business Name', 
        'legalName' => 'Legal Name', // ADDED
        'alternateName' => 'Alternate Name', // ADDED
        'url' => 'Official URL', 
        'logo' => 'Logo URL', 
        'image_urls' => 'Image URLs (comma/newline separated)', // ADDED - Handled as textarea
        'priceRange' => 'Price Range (e.g., $$)', // ADDED
        'telephone' => 'Main Telephone', 
        'description' => 'Description', 
        'streetAddress' => 'Street Address', 
        'addressLocality' => 'Locality/City', 
        'addressRegion' => 'Region/State', 
        'postalCode' => 'Postal Code', 
        'addressCountry' => 'Country', 
        'latitude' => 'Latitude', 
        'longitude' => 'Longitude', 
        'map' => 'Map URL (e.g., Google Maps URL)', // ADDED
        'sameAs' => 'SameAs URLs (comma/newline separated)', // ADDED - Handled as textarea
        'keywords' => 'Keywords (comma separated)', // ADDED - Handled as textarea
        'google_place_id' => 'Google Place ID', 
        'google_api_key' => 'Google API Key',
        
        // EXISTING GLOBAL FIELDS FOR MANUAL RATING IN LOCAL BUSINESS
        'manual_rating_value' => 'Manual Rating Value (Local)',
        'manual_review_count' => 'Manual Review Count (Local)',
    ];
    
    foreach ($local_fields as $id => $label) {
        add_settings_field($id, $label, 'schema_render_global_input', 'global-schema-settings', 'schema_local_business_section', ['id' => $id]);
    }

    // --- Opening Hours Fields (ADDED) ---
    $days = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'];
    foreach ($days as $day) {
        add_settings_field(
            "open_{$day}", 
            ucfirst($day) . ' Open', 
            'schema_render_global_input', 
            'global-schema-settings', 
            'schema_local_business_section', 
            ['id' => "open_{$day}", 'type' => 'time']
        );
        add_settings_field(
            "close_{$day}", 
            ucfirst($day) . ' Close', 
            'schema_render_global_input', 
            'global-schema-settings', 
            'schema_local_business_section', 
            ['id' => "close_{$day}", 'type' => 'time']
        );
    }


    // --- Service/Product Fields (EXISTING) ---
    add_settings_section('schema_product_section', 'Service/Product Defaults (Strict)', null, 'global-schema-settings');
    $product_fields = [
        'product_name' => 'Service/Product Name', 
        'product_description' => 'Service/Product Description', 
        'product_url' => 'Service/Product URL', 
        'product_keywords' => 'Service/Product Keywords (comma sep.)', 
        'brand_name' => 'Brand/Organization Name',
        'google_api_key' => 'Google API Key (Product)', 
        'google_place_id' => 'Google Place ID (Product)', 
        'product_image_urls' => 'Service/Product Image URLs (newline sep.)',
        
        // EXISTING GLOBAL FIELDS FOR MANUAL RATING IN PRODUCT/SERVICE
        'manual_rating_value_product' => 'Manual Rating Value (Product)', 
        'manual_review_count_product' => 'Manual Review Count (Product)', 
    ];
    
    foreach ($product_fields as $id => $label) {
        add_settings_field($id, $label, 'schema_render_global_input', 'global-schema-settings', 'schema_product_section', ['id' => $id]);
    }
}

function schema_render_global_input($args) {
    $options = get_option('schema_global_settings');
    $value = $options[$args['id']] ?? '';
    $id = $args['id'];
    
    // Use a textarea for multi-line/comma-separated input fields
    if (in_array($id, ['product_image_urls', 'image_urls', 'sameAs', 'keywords'])) {
        $placeholder = ($id === 'image_urls' || $id === 'product_image_urls') 
                       ? "Enter one URL per line or separate by commas." 
                       : "Enter comma or newline separated values.";
        ?>
        <textarea id="<?php echo esc_attr($id); ?>" 
                  name="schema_global_settings[<?php echo esc_attr($id); ?>]" 
                  class="regular-text widefat" rows="4" 
                  placeholder="<?php echo esc_attr($placeholder); ?>"><?php echo esc_textarea($value); ?></textarea>
        <?php
    } elseif (strpos($id, 'open_') === 0 || strpos($id, 'close_') === 0) {
        // Time input for opening hours
        ?>
        <input type="time" id="<?php echo esc_attr($id); ?>" 
               name="schema_global_settings[<?php echo esc_attr($id); ?>]" 
               value="<?php echo esc_attr($value); ?>" class="regular-text" style="width: 150px;" />
        <?php
    } elseif (strpos($id, 'manual_rating_value') !== false) {
        // Use number input with step for rating
        ?>
        <input type="number" id="<?php echo esc_attr($id); ?>" 
               name="schema_global_settings[<?php echo esc_attr($id); ?>]" 
               value="<?php echo esc_attr($value); ?>" class="regular-text widefat" step="0.1" min="0" max="5" placeholder="e.g. 4.9" />
        <?php
    } elseif (strpos($id, 'manual_review_count') !== false) {
        // Use number input for review count
        ?>
        <input type="number" id="<?php echo esc_attr($id); ?>" 
               name="schema_global_settings[<?php echo esc_attr($id); ?>]" 
               value="<?php echo esc_attr($value); ?>" class="regular-text widefat" step="1" min="0" placeholder="e.g. 100" />
        <?php
    } else {
        // Default text input
        ?>
        <input type="text" id="<?php echo esc_attr($id); ?>" 
               name="schema_global_settings[<?php echo esc_attr($id); ?>]" 
               value="<?php echo esc_attr($value); ?>" class="regular-text widefat" />
        <?php
    }
}

/**
 * Renders a select dropdown for global settings (used for businessType).
 */
function schema_render_global_select($args) {
    $options = get_option('schema_global_settings');
    $value = $options[$args['id']] ?? '';
    $select_options = $args['options'] ?? [];
    ?>
    <select id="<?php echo esc_attr($args['id']); ?>"
            name="schema_global_settings[<?php echo esc_attr($args['id']); ?>]"
            class="regular-text widefat">
        <option value="">Leave empty for page-level editing</option>
        <?php foreach ($select_options as $option) : ?>
            <option value="<?php echo esc_attr($option); ?>" <?php selected($value, $option); ?>>
                <?php echo esc_html($option); ?>
            </option>
        <?php endforeach; ?>
    </select>
    <?php
}

/**
 * Sanitizes all global settings fields (UPDATED FOR NEW FIELDS).
 */
function schema_sanitize_global_settings($input) {
    $new_input = [];
    $textarea_fields = ['product_image_urls', 'image_urls', 'sameAs', 'keywords', 'product_keywords'];
    
    foreach ($input as $key => $value) {
        if (in_array($key, $textarea_fields)) {
             $new_input[$key] = sanitize_textarea_field($value);
        } elseif (strpos($key, 'manual_rating_value') !== false) {
             $new_input[$key] = floatval($value); // Sanitize as float for rating
        } elseif (strpos($key, 'manual_review_count') !== false) {
             $new_input[$key] = intval($value); // Sanitize as integer for review count
        } else {
             $new_input[$key] = sanitize_text_field($value); // Default for all others (text, url, time, etc.)
        }
    }
    return $new_input;
}

// --- LOCAL BUSINESS META BOX (Updated to check for all new global fields) ---
add_action('add_meta_boxes', function () {
    add_meta_box('custom_schema_box', 'Local Business Schema Settings', 
        'render_custom_schema_box', ['page'], 'normal', 'high');
});

function render_custom_schema_box($post) {
    $business_types = [
        "LocalBusiness","AnimalShelter","ArchiveOrganization","AutomotiveBusiness","ChildCare","Dentist",
        "DryCleaningOrLaundry","EmergencyService","EmploymentAgency","EntertainmentBusiness","FinancialService",
        "FoodEstablishment","GovernmentOffice","HealthAndBeautyBusiness","HomeAndConstructionBusiness","InternetCafe",
        "LegalService","Library","LodgingBusiness","MedicalBusiness","ProfessionalService","RadioStation","RealEstateAgent",
        "RecyclingCenter","SelfStorage","ShoppingCenter","SportsActivityLocation","Store","TelevisionStation",
        "TouristInformationCenter","TravelAgency"
    ];
    
    $global = get_option('schema_global_settings', []);
    $global_get = fn($k) => trim($global[$k] ?? '');

    echo '<table class="form-table">';
    
    // Business Type Field - Check if strictly set globally
    $global_type = $global_get('businessType');
    $is_type_strict = !empty($global_type);
    
    echo '<tr><th><label for="schema_businessType">Business Type</label></th><td>';
    if ($is_type_strict) {
        echo '<p class="description">This field is locked globally: <strong>' . esc_html($global_type) . '</strong></p>';
    } else {
        $saved_type = get_post_meta($post->ID, '_schema_businessType', true);
        echo '<select name="schema_businessType" id="schema_businessType" class="widefat">';
        echo '<option value="">Select Business Type</option>';
        foreach ($business_types as $type) {
            $selected = selected($saved_type, $type, false);
            echo "<option value='$type' $selected>$type</option>";
        }
        echo '</select>';
        echo '<br><input type="text" name="schema_businessType_manual" id="schema_businessType_manual" value="' . esc_attr($saved_type) . '" class="widefat" placeholder="Or enter custom Business Type" />';
    }
    echo '</td></tr>';

    // UPDATED FIELD LIST to include all new local fields and ensure all global fields are checked for strictness
    $fields = [
        'name', 'legalName', 'alternateName', 'url', 'logo', 'image_urls', // image_urls is the new multi-line field
        'priceRange', 'telephone', 'description',
        'streetAddress', 'addressLocality', 'addressRegion', 'postalCode', 'addressCountry',
        'latitude', 'longitude', 'map', 'sameAs', 'keywords', // These now respect global strictness
        'manual_rating_value', 'manual_review_count', 'google_place_id', 'google_api_key'
    ];

    foreach ($fields as $field) {
        $global_value = $global_get($field);
        $value = get_post_meta($post->ID, '_schema_' . $field, true);
        
        // Determine if field should be disabled
        $is_strict_global = !empty($global_value);
        $note = $is_strict_global ? " <strong>(STRICT Global: " . esc_html($global_value) . ")</strong>" : '';

        // If strict global is set, render disabled view
        $label_text = ucfirst(str_replace('_', ' ', $field));

        echo '<tr><th><label for="schema_' . $field . '">' . $label_text . $note . '</label></th>';
        echo '<td>';
        
        if ($is_strict_global) {
             echo '<p class="description">This field is locked globally: <strong>' . esc_html($global_value) . '</strong></p>';
             // Always render a hidden input to save the page-level value (in case it was previously set)
             echo '<input type="hidden" name="schema_' . $field . '" value="' . esc_attr($value) . '" />';
        } elseif ($field === 'manual_rating_value') {
            echo "<input type='number' name='schema_$field' id='schema_$field' value='" . esc_attr($value) . "' class='widefat' step='0.1' min='0' max='5' placeholder='e.g. 4.9' />";
        } elseif ($field === 'manual_review_count') {
            echo "<input type='number' name='schema_$field' id='schema_$field' value='" . esc_attr($value) . "' class='widefat' step='1' min='0' placeholder='e.g. 100' />";
        } elseif (in_array($field, ['sameAs', 'keywords', 'image_urls'])) {
            // Use textarea for multi-line/comma-separated page-level fields
            $placeholder = ($field === 'image_urls') 
                           ? 'Enter one URL per line or separate by commas.' 
                           : 'Enter comma or newline separated values.';
            echo "<textarea name='schema_$field' id='schema_$field' class='widefat' rows='3' placeholder='$placeholder'>" . esc_textarea($value) . "</textarea>";
        } else {
             // Otherwise, render a standard input for page-level editing
             echo '<input type="text" name="schema_' . $field . '" id="schema_' . $field . '" value="' . esc_attr($value) . '" class="widefat" />';
        }
        
        echo '</td></tr>';
    }

    // Opening Hours (Updated to respect global strictness)
    $days = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'];
    foreach ($days as $day) {
        $open_key = "open_{$day}";
        $close_key = "close_{$day}";
        
        $global_open = $global_get($open_key);
        $global_close = $global_get($close_key);
        $is_open_strict = !empty($global_open);
        $is_close_strict = !empty($global_close);
        
        $open = get_post_meta($post->ID, "_schema_$open_key", true);
        $close = get_post_meta($post->ID, "_schema_$close_key", true);

        echo "<tr><th>" . ucfirst($day) . "</th><td>";

        // Open Time
        if ($is_open_strict) {
            echo "Opens: <p class='description'>Locked: <strong>" . esc_html($global_open) . "</strong></p>";
            echo "<input type='hidden' name='schema_$open_key' value='" . esc_attr($open) . "' />";
        } else {
            echo "Opens: <input type='time' name='schema_$open_key' value='$open' />";
        }
        
        // Close Time
        if ($is_close_strict) {
            echo "Closes: <p class='description'>Locked: <strong>" . esc_html($global_close) . "</strong></p>";
            echo "<input type='hidden' name='schema_$close_key' value='" . esc_attr($close) . "' />";
        } else {
            echo "Closes: <input type='time' name='schema_$close_key' value='$close' />";
        }

        echo "</td></tr>";
    }

    echo '</table>';
    wp_nonce_field('save_schema_fields', 'schema_nonce');
}

// --- LOCAL BUSINESS SAVE HOOK ---
add_action('save_post', function ($post_id) {
    if (!isset($_POST['schema_nonce']) || !wp_verify_nonce($_POST['schema_nonce'], 'save_schema_fields')) return;
    if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;

    $global = get_option('schema_global_settings', []);
    $global_get = fn($k) => trim($global[$k] ?? ''); // Helper function

    // Handle Business Type save only if not globally strict
    if (empty($global_get('businessType'))) {
        $business_type = sanitize_text_field($_POST['schema_businessType_manual'] ?? $_POST['schema_businessType'] ?? '');
        update_post_meta($post_id, '_schema_businessType', $business_type);
    }

    // Save all non-strict page-level fields (including new ones)
    foreach ($_POST as $key => $val) {
        if (strpos($key, 'schema_') === 0 && $key !== 'schema_businessType_manual') {
            $meta_key_suffix = str_replace('schema_', '', $key);
            
            // Do not save if a strict global value exists for this field (checks only local keys for Local Business fields)
            if (empty($global_get($meta_key_suffix))) { 
                
                $sanitized_val = '';
                
                if (strpos($meta_key_suffix, 'manual_rating_value') !== false) {
                    $sanitized_val = floatval($val);
                } elseif (strpos($meta_key_suffix, 'manual_review_count') !== false) {
                    $sanitized_val = intval($val);
                } elseif (in_array($meta_key_suffix, ['image_urls', 'sameAs', 'keywords', 'product_image_urls'])) {
                    $sanitized_val = sanitize_textarea_field($val);
                } else {
                    $sanitized_val = sanitize_text_field($val);
                }

                update_post_meta($post_id, '_' . $key, $sanitized_val);
            }
        }
    }
}, 10, 1);

// --- PRODUCT SCHEMA META BOX (Existing) ---
add_action('add_meta_boxes', function () {
    add_meta_box('product_schema_box', 'Service/Product Schema Settings', 'render_product_schema_box', ['page'], 'normal', 'high');
});

function render_product_schema_box($post) {
    $fields = [
        'product_name', 'product_description', 'product_url', 'product_keywords',
        'brand_name', 'google_api_key', 'google_place_id',
        'manual_rating_value', 'manual_review_count',
        'product_image_urls'
    ];
    
    $global = get_option('schema_global_settings', []);
    
    // Global get function for product/service fields, checks product-specific keys first
    $global_get_product = fn($k) => trim($global[$k.'_product'] ?? '') ?: trim($global[$k] ?? '');

    echo '<table class="form-table">';
    foreach ($fields as $field) {
        // Use the combined global check here to see if the page-level field should be locked
        $global_value = $global_get_product($field); 
        $value = get_post_meta($post->ID, '_schema_' . $field, true);
        
        $is_strict_global = !empty($global_value);
        $note = $is_strict_global ? " <strong>(STRICT Global: " . esc_html($global_value) . ")</strong>" : '';
        
        $label = str_replace('Product', 'Service', ucwords(str_replace('_', ' ', $field)));
        
        echo "<tr><th><label for='schema_$field'>$label $note</label></th><td>";
        
        if ($is_strict_global) {
            echo '<p class="description">This field is locked globally: <strong>' . esc_html($global_value) . '</strong></p>';
            echo '<input type="hidden" name="schema_' . $field . '" value="' . esc_attr($value) . '" />';
        } 
        elseif ($field === 'product_image_urls') {
            echo "<textarea id='schema_$field' name='schema_$field' class='widefat' rows='4' placeholder='Enter one image URL per line. Single images work too.'>" . esc_textarea($value) . "</textarea>";
        } 
        elseif ($field === 'manual_rating_value') {
            echo "<input type='number' id='schema_$field' name='schema_$field' value='" . esc_attr($value) . "' class='widefat' step='0.1' min='0' max='5' placeholder='e.g. 4.9'>";
        } elseif ($field === 'manual_review_count') {
            echo "<input type='number' id='schema_$field' name='schema_$field' value='" . esc_attr($value) . "' class='widefat' step='1' min='0' placeholder='e.g. 100'>";
        } else {
            echo "<input type='text' id='schema_$field' name='schema_$field' value='" . esc_attr($value) . "' class='widefat'>";
        }

        echo "</td></tr>";
    }
    echo '</table>';

    $offers = get_post_meta($post->ID, '_schema_offers', true);
    if (!is_array($offers)) $offers = [];
    ?>
    <h3>Service Offers (Always Page-Specific)</h3>
    <div id="schema-offers-container">
        <?php foreach ($offers as $i => $offer) : ?>
            <div class="offer-item" style="margin-bottom:10px;border:1px solid #ddd;padding:10px;">
                <input type="text" name="schema_offers[<?php echo $i; ?>][name]" value="<?php echo esc_attr($offer['name'] ?? ''); ?>" placeholder="Offer Name" style="width:30%;margin-right:5px;">
                <input type="text" name="schema_offers[<?php echo $i; ?>][price]" value="<?php echo esc_attr($offer['price'] ?? ''); ?>" placeholder="Price" style="width:30%;margin-right:5px;">
                <input type="text" name="schema_offers[<?php echo $i; ?>][currency]" value="<?php echo esc_attr($offer['currency'] ?? ''); ?>" placeholder="Currency (e.g., USD)" style="width:30%;">
                <button type="button" class="remove-offer button" style="margin-left:5px;">Remove</button>
            </div>
        <?php endforeach; ?>
    </div>
    <button type="button" id="add-offer" class="button">+ Add Offer</button>
    <script>
    document.addEventListener('DOMContentLoaded', function() {
        const container = document.getElementById('schema-offers-container');
        document.getElementById('add-offer').addEventListener('click', function() {
            const count = container.querySelectorAll('.offer-item').length;
            const div = document.createElement('div');
            div.className = 'offer-item';
            div.style.marginBottom = '10px';
            div.style.border = '1px solid #ddd';
            div.style.padding = '10px';
            div.innerHTML = `
                <input type="text" name="schema_offers[${count}][name]" placeholder="Offer Name" style="width:30%;margin-right:5px;">
                <input type="text" name="schema_offers[${count}][price]" placeholder="Price" style="width:30%;margin-right:5px;">
                <input type="text" name="schema_offers[${count}][currency]" placeholder="Currency (e.g., USD)" style="width:30%;">
                <button type="button" class="remove-offer button" style="margin-left:5px;">Remove</button>
            `;
            container.appendChild(div);
            div.querySelector('.remove-offer').addEventListener('click', function() {
                div.remove();
            });
        });
        document.querySelectorAll('.remove-offer').forEach(btn => {
            btn.addEventListener('click', function() {
                this.parentElement.remove();
            });
        });
    });
    </script>
    <?php
    wp_nonce_field('save_product_schema_fields', 'product_schema_nonce');
}

// --- PRODUCT SCHEMA SAVE HOOK (Existing with minor adjustment) ---
add_action('save_post', function ($post_id) {
    if (!isset($_POST['product_schema_nonce']) || !wp_verify_nonce($_POST['product_schema_nonce'], 'save_product_schema_fields')) return;
    if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;

    $global = get_option('schema_global_settings', []);
    
    foreach ($_POST as $key => $val) {
        if (strpos($key, 'schema_') === 0 && $key !== 'schema_offers') {
            $meta_key_suffix = str_replace('schema_', '', $key);
            
            // Global check for Product/Service fields: checks product-specific key first
            $global_check_key = in_array($meta_key_suffix, ['manual_rating_value', 'manual_review_count', 'google_api_key', 'google_place_id']) 
                                ? $meta_key_suffix . '_product' 
                                : $meta_key_suffix;

            // Only save if no strict global value exists
            if (empty($global[$global_check_key])) {
                if ($meta_key_suffix === 'product_image_urls') {
                    $sanitized_val = sanitize_textarea_field($val);
                } elseif (strpos($meta_key_suffix, 'manual_rating_value') !== false) {
                    $sanitized_val = floatval($val);
                } elseif (strpos($meta_key_suffix, 'manual_review_count') !== false) {
                    $sanitized_val = intval($val);
                } else {
                    $sanitized_val = sanitize_text_field($val);
                }
                update_post_meta($post_id, '_' . $key, $sanitized_val);
            }
        }
    }

    if (isset($_POST['schema_offers']) && is_array($_POST['schema_offers'])) {
        $offers = array_map(function($offer) {
            return [
                'name' => sanitize_text_field($offer['name'] ?? ''),
                'price' => sanitize_text_field($offer['price'] ?? ''),
                'currency' => sanitize_text_field($offer['currency'] ?? ''),
            ];
        }, $_POST['schema_offers']);
        update_post_meta($post_id, '_schema_offers', array_filter($offers));
    } else {
        delete_post_meta($post_id, '_schema_offers');
    }
}, 11, 1); // Changed priority to 11 to avoid conflict with the local business save hook at 10

function get_google_rating_data($place_id, $api_key) {
    if (!$place_id || !$api_key) return null;
    $url = "https://maps.googleapis.com/maps/api/place/details/json?place_id=" . urlencode($place_id) . "&key=" . urlencode($api_key);
    $response = wp_remote_get($url);
    if (is_wp_error($response)) return null;
    $data = json_decode(wp_remote_retrieve_body($response), true);
    if (isset($data['result']['rating']) && isset($data['result']['user_ratings_total'])) {
        return [
            'ratingValue' => $data['result']['rating'],
            'reviewCount' => $data['result']['user_ratings_total']
        ];
    }
    return null;
}

function fetch_google_place_data($api_key, $place_id) {
    return get_google_rating_data($place_id, $api_key); 
}

/**
 * UPDATED: Fetches opening hours respecting global strict defaults.
 */
function get_opening_hours_schema() {
    global $post;
    $get = get_schema_data_retriever(); 

    $days = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'];
    $output = [];
    foreach ($days as $day) {
        // Use $get to retrieve global setting if strict, otherwise post meta
        $open = $get("open_{$day}");
        $close = $get("close_{$day}");
        
        if ($open && $close) {
            $output[] = [
                "@type" => "OpeningHoursSpecification",
                "dayOfWeek" => ucfirst($day),
                "opens" => $open,
                "closes" => $close,
            ];
        }
    }
    return $output ?: null; // Return null if empty for clean schema
}


function get_schema_data_retriever() {
    global $post;
    $global = get_option('schema_global_settings', []);
    $global_get = fn($k) => trim($global[$k] ?? '');
    
    // STRICT FALLBACK: Global value takes precedence if set. Otherwise, use post meta.
    return fn($k) => $global_get($k) ?: trim(get_post_meta($post->ID, '_schema_' . $k, true));
}

add_action('wp_head', function () {
    if (!is_singular()) return;
    global $post;
    $get = get_schema_data_retriever();
    
    // --- Local Business Schema Generation ---
    if ($get('name') && $get('url')) {
        
        // Handle new multi-line/comma-separated fields
        $sameAs_raw = $get('sameAs');
        $sameAs_temp = preg_split('/[\n,]/', $sameAs_raw, -1, PREG_SPLIT_NO_EMPTY);
        $sameAs = array_filter(array_map('trim', $sameAs_temp));

        $keywords_raw = $get('keywords');
        $keywords = $keywords_raw ? array_filter(array_map('trim', explode(',', $keywords_raw))) : null;

        $image_urls_raw = $get('image_urls');
        $images_temp = preg_split('/[\n,]/', $image_urls_raw, -1, PREG_SPLIT_NO_EMPTY);
        $images = array_filter(array_map('trim', $images_temp));
        

        // Retrieve API keys and Place IDs, falling back to global settings
        $api_key = $get('google_api_key');
        $place_id = $get('google_place_id');
        
        $rating_data = null;
        if ($api_key && $place_id) {
            $rating_data = get_google_rating_data($place_id, $api_key);
        }
        
        // Manual/Aggregate Rating Logic (Order: Google API -> Global Manual -> Post Meta Manual -> Default)
        $rating_value = $rating_data['ratingValue'] ?? ($get('manual_rating_value') ?: '5.0');
        $review_count = $rating_data['reviewCount'] ?? ($get('manual_review_count') ?: '49');
        
        // Business type: Uses strict global (if set) OR page-level meta OR default
        $business_type = $get('businessType') ?: get_post_meta($post->ID, '_schema_businessType', true) ?: "ProfessionalService";
        
        // Helper to clean array of empty fields
        $clean_empty_fields = function(array $arr) use (&$clean_empty_fields) {
            return array_filter($arr, function($value) use ($clean_empty_fields) {
                if (is_array($value)) {
                    $value = $clean_empty_fields($value);
                    return !empty($value);
                }
                return $value !== '' && $value !== null;
            });
        };

        $local_schema = [
            "@context" => "http://schema.org",
            "@type" => $business_type,
            "name" => $get('name'),
            "legalName" => $get('legalName'), // ADDED
            "alternateName" => $get('alternateName'), // ADDED
            "url" => $get('url'),
            "logo" => $get('logo'),
            "image" => $images ?: null, // Uses the new multi-url field
            "priceRange" => $get('priceRange'), // ADDED
            "telephone" => $get('telephone'),
            "description" => $get('description'),
            "address" => array_filter([
                "@type" => "PostalAddress",
                "streetAddress" => $get('streetAddress'),
                "addressLocality" => $get('addressLocality'),
                "addressRegion" => $get('addressRegion'),
                "postalCode" => $get('postalCode'),
                "addressCountry" => $get('addressCountry'),
            ]),
            "geo" => array_filter([
                "@type" => "GeoCoordinates",
                "latitude" => $get('latitude'),
                "longitude" => $get('longitude'),
            ]),
            "hasMap" => $get('map'), // ADDED
            "sameAs" => $sameAs ?: null, // ADDED
            "keywords" => $keywords, // ADDED
            "openingHoursSpecification" => get_opening_hours_schema(), // UPDATED function call
            "aggregateRating" => [
                "@type" => "AggregateRating",
                "ratingValue" => $rating_value,
                "reviewCount" => $review_count
            ]
        ];

        $local_schema = $clean_empty_fields($local_schema);
        
        // Output Local Business Schema
        echo '<script type="application/ld+json">' . json_encode($local_schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . '</script>';
    }
    
    // --- Service/Product Schema Generation (Existing) ---
    $required_fields = ['product_name', 'product_description', 'product_url', 'product_image_urls'];
    $is_product_schema_enabled = true;
    foreach ($required_fields as $field) {
        if (empty($get($field))) {
            $is_product_schema_enabled = false;
            break;
        }
    }
    
    if ($is_product_schema_enabled) {
        $keywords_raw = $get('product_keywords');
        $keywords = $keywords_raw ? array_map('trim', explode(',', $keywords_raw)) : [];

        $images_raw = $get('product_image_urls');
        $images = array_filter(array_map('trim', explode("\n", $images_raw)));

        // Product-specific check for API keys/Place IDs, favoring product-specific global keys
        $global_settings = get_option('schema_global_settings', []);
        $api_key = $global_settings['google_api_key_product'] ?? $get('google_api_key');
        $place_id = $global_settings['google_place_id_product'] ?? $get('google_place_id');
        
        $product_schema = [
            "@context" => "https://schema.org",
            "@type" => "Product",
            "additionalType" => "https://schema.org/Service", 
            "name" => $get('product_name'),
            "description" => $get('product_description'),
            "url" => $get('product_url'),
            "keywords" => $keywords ?: null,
            "image" => $images ?: null, 
            "brand" => [
                "@type" => "Organization",
                "name" => $get('brand_name'),
            ],
        ];

        $ratingValue = null;
        $reviewCount = null;

        if (!empty($api_key) && !empty($place_id)) {
            $google_data = fetch_google_place_data($api_key, $place_id);
            if ($google_data && $google_data['ratingValue'] && $google_data['reviewCount']) {
                $ratingValue = $google_data['ratingValue'];
                $reviewCount = $google_data['reviewCount'];
            }
        }

        // Check Manual Ratings (Order: Global Product Manual -> Global Local Manual -> Post Meta Manual)
        if (!$ratingValue || !$reviewCount) {
            
            // 1. Global Product Manual Ratings
            $global_product_rating = $global_settings['manual_rating_value_product'] ?? null;
            $global_product_review = $global_settings['manual_review_count_product'] ?? null;
            
            // 2. Fallback to Global Local Manual Ratings
            $global_local_rating = $global_settings['manual_rating_value'] ?? null;
            $global_local_review = $global_settings['manual_review_count'] ?? null;
            
            // 3. Fallback to Post Meta Manual Ratings
            $post_meta_rating = get_post_meta($post->ID, '_schema_manual_rating_value', true);
            $post_meta_review = get_post_meta($post->ID, '_schema_manual_review_count', true);
            
            $manual_rating = floatval($global_product_rating ?: $global_local_rating ?: $post_meta_rating);
            $manual_count = intval($global_product_review ?: $global_local_review ?: $post_meta_review);
                        
            if ($manual_rating > 0 && $manual_count > 0) {
                $ratingValue = $manual_rating;
                $reviewCount = $manual_count;
            }
        }

        if ($ratingValue && $reviewCount) {
            $product_schema['aggregateRating'] = [
                "@type" => "AggregateRating",
                "ratingValue" => $ratingValue,
                "reviewCount" => $reviewCount
            ];
        }

        $offers = get_post_meta($post->ID, '_schema_offers', true);
        if (is_array($offers) && !empty($offers)) {
            $product_schema['offers'] = [];
            foreach ($offers as $offer) {
                if (!empty($offer['name']) && !empty($offer['price']) && !empty($offer['currency'])) {
                    $product_schema['offers'][] = [
                        "@type" => "Offer",
                        "name" => $offer['name'],
                        "price" => $offer['price'],
                        "priceCurrency" => $offer['currency']
                    ];
                }
            }
        }
        
        $product_schema = $clean_empty_fields($product_schema);

        // Output Product Schema
        echo '<script type="application/ld+json">' . json_encode(
            $product_schema,
            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
        ) . '</script>';
    }
});

How to Configure the AI-Optimized Local Business & Service Schema Plugin

This schema system automatically generates Local Business and Service/Product schema markup for your website. It can also pull your Google Business Profile ratings and review count directly from Google, ensuring your schema stays updated automatically.

Step 1: Open Global Schema Settings

In WordPress Admin:

Settings → Global Schema

Anything entered here becomes a global default across your entire website.

If a field is filled globally, page-level editing for that field is automatically disabled.

Use Global Settings when:

  • Business information is the same on every page
  • You want consistent schema sitewide
  • You don’t want editors accidentally changing important business details

Local Business Fields

Business Type

Choose the most accurate category.

Examples:

  • ProfessionalService
  • Dentist
  • MedicalBusiness
  • RealEstateAgent
  • Store
  • TravelAgency
  • LocalBusiness

For most service businesses:

ProfessionalService is usually the safest option.


Business Name

Enter your public business name.

Example:

Focused Medical Billing


Legal Name

Enter the official registered company name.

Example:

Focused Medical Billing LLC


Alternate Name

Any commonly used variation.

Example:

FMB


Official URL

Homepage URL.

Example:


Logo URL

Direct image URL to your logo.

Example:

https://focusedmedicalbilling.com/logo.png

Image URLs

Add business photos.

You can add:

One image per line

OR

Comma-separated URLs

Example:

https://site.com/image1.jpg
https://site.com/image2.jpg
https://site.com/image3.jpg

Price Range

Examples:

$

$$

$$$

$$$$

For service businesses:

$$ is usually sufficient.


Telephone

Main business phone number.

Example:

+1 555 123 4567


Description

Short description of the company.

Example:

Focused Medical Billing provides medical billing, revenue cycle management, and credentialing services for healthcare providers throughout the United States.


Address Information

Street Address

Example:

123 Main Street Suite 200


City

Example:

Houston


State / Region

Example:

Texas


Postal Code

Example:

77001


Country

Example:

US


Geo Coordinates

Latitude

Example:

29.7604

Longitude

Example:

-95.3698

You can obtain coordinates from Google Maps.


Map URL

Paste your Google Maps business URL.

Example:

https://maps.google.com/…

Social Profiles (SameAs)

Add all social profiles.

One per line or comma separated.

Example:

https://facebook.com/business
https://instagram.com/business
https://linkedin.com/company/business
https://youtube.com/@business

Keywords

Enter important keywords.

Example:

medical billing, credentialing, revenue cycle management, healthcare billing


Google Review Automation

This is the most important section for AI optimization.

Google Place ID

Enter your Google Business Profile Place ID.

Example:

ChIJ1234567890ABCDEF


Google API Key

Enter your Google Places API key.

Example:

AIzaSyxxxxxxxxxxxxxxxxxxxxxxxx


What Happens?

When both fields are filled:

  • Rating updates automatically
  • Review count updates automatically
  • AggregateRating schema stays current
  • Google, ChatGPT, Gemini, Perplexity, and other AI crawlers see current review data

No manual updates required.


Manual Rating Backup

Only use these fields if you do NOT have a Google API key.

Manual Rating Value

Example:

4.9


Manual Review Count

Example:

257


Business Hours

For each day:

Enter:

Open Time

Close Time

Example:

Monday

Open: 09:00

Close: 17:00

Repeat for all business days.


Service/Product Schema

Used for service pages.

Example:

  • Medical Billing
  • Kitchen Remodeling
  • Roofing
  • HVAC Repair
  • Plumbing

Service/Product Name

Example:

Medical Billing Services


Service/Product Description

Describe the service.

Example:

Professional medical billing services for private practices, clinics, and healthcare organizations.


Service/Product URL

URL of the service page.

Example:

https://site.com/medical-billing-services

Service Keywords

Example:

medical billing, revenue cycle management, insurance claims


Brand Name

Usually your business name.

Example:

Focused Medical Billing


Service Images

Add service-related images.

One image URL per line.


Offers

Each page can have multiple offers.

Example:

Offer Name: Starter Billing Package

Price: 299

Currency: USD


Offer Name: Full Revenue Cycle Management

Price: 999

Currency: USD


Recommended Setup

For best SEO and AI optimization:

Fill all business information globally

Add Google Place ID

Add Google API Key

Add social profiles

Add business hours

Add service-specific schema on service pages

Use multiple business images

Keep descriptions unique and detailed

This setup allows search engines and AI systems to understand your business, services, location, reputation, and reviews with maximum accuracy.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *