0; if ( ! $force_refresh && $cache_enabled ) { $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } } $response = wp_remote_get( $api_url, array( 'timeout' => 15, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'A1 Tools API error (' . $cache_key . '): ' . $response->get_error_message() ); } // Negative cache: prevent hammering a down service. set_transient( $cache_key . '_fail', time(), 60 ); // Try stale cache fallback. $stale = get_option( 'a1tools_stale_' . $cache_key ); return $stale ?: null; } $code = wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'A1 Tools API HTTP ' . $code . ' (' . $cache_key . ')' ); } set_transient( $cache_key . '_fail', time(), 60 ); $stale = get_option( 'a1tools_stale_' . $cache_key ); return $stale ?: null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! is_array( $data ) || empty( $data['success'] ) ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'A1 Tools API invalid response (' . $cache_key . ')' ); } set_transient( $cache_key . '_fail', time(), 60 ); $stale = get_option( 'a1tools_stale_' . $cache_key ); return $stale ?: null; } $result = $data[ $data_key ] ?? array(); if ( $filter_name ) { $result = apply_filters( $filter_name, $result ); } if ( $cache_enabled ) { set_transient( $cache_key, $result, $cache_expiry ); } else { // Caching disabled: actively delete any pre-existing transient so a // stale value (e.g. one written under the pre-2.4.2 buggy TTL logic) // cannot keep being returned by get_transient(). delete_transient( $cache_key ); } // Save as stale fallback (used only when the API is unreachable). update_option( 'a1tools_stale_' . $cache_key, $result, false ); return $result; } /** * Build the standard A1 Tools public API URL. * * @since 2.2.1 * * @param string $filter_name WordPress filter for the base URL. * @param string $default_url Default API base URL constant. * @return string Full API URL with action and url query args. */ function a1tools_build_api_url( $filter_name, $default_url ) { $api_base_url = apply_filters( $filter_name, $default_url ); return add_query_arg( array( 'action' => 'public', 'url' => site_url(), ), $api_base_url ); } /** * Get site variables from API or cache. * * @since 1.0.0 * @param bool $force_refresh Force a fresh API call. * @return array|null Variables array or null on error. */ function a1tools_get_site_variables( $force_refresh = false ) { $url = a1tools_build_api_url( 'a1tools_api_url', A1TOOLS_DEFAULT_API_URL ); return a1tools_fetch_api_data( A1TOOLS_CACHE_KEY, $url, 'variables', '', $force_refresh ); } /** * Auto-resolve the CRM company id for THIS WordPress site. * * Returns the value stored in `a1tools_form_crm_company_id` when it's * non-zero (admin override wins). Otherwise hits * `website_variables.php?action=public&url=` once, reads * `company_id` off the root of the response, and persists it back * into the option so the Web Chat + Web Form widgets render with the * correct `data-company-id` without any admin intervention. * * The remote response shape (since A1 Tools 5.3.171) is: * { success: true, variables: {...}, franchises: [...], group_id: N, company_id: N } * * Returns 0 when the resolution fails (DNS error, group not bound to * a CRM company, etc.). The widgets gate on the integer being > 0 * before mounting, so a 0 here keeps the previous "no config" UX. * * Result is briefly negative-cached (10 min) so a failure doesn't * hammer the API on every page render. * * @since 2.5.6 * @return int CRM company id (>0) or 0 when unresolved. */ function a1tools_resolve_crm_company_id() { $current = (int) get_option( 'a1tools_form_crm_company_id', 0 ); if ( $current > 0 ) { return $current; } // Negative cache — don't hammer the API on every page request // when the site's group simply isn't bound to a CRM company yet. $neg_key = 'a1tools_crm_company_id_resolve_fail'; if ( get_transient( $neg_key ) ) { return 0; } $api_url = a1tools_build_api_url( 'a1tools_api_url', A1TOOLS_DEFAULT_API_URL ); $response = wp_remote_get( $api_url, array( 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { set_transient( $neg_key, time(), 10 * MINUTE_IN_SECONDS ); return 0; } $code = wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) { set_transient( $neg_key, time(), 10 * MINUTE_IN_SECONDS ); return 0; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! is_array( $data ) || empty( $data['success'] ) || ! isset( $data['company_id'] ) ) { set_transient( $neg_key, time(), 10 * MINUTE_IN_SECONDS ); return 0; } $cid = (int) $data['company_id']; if ( $cid > 0 ) { // Persist so subsequent renders don't re-fetch. The admin // settings page still shows + accepts manual edits — this // just back-fills the empty default. update_option( 'a1tools_form_crm_company_id', $cid, false ); // Also clear the negative cache in case it was set earlier. delete_transient( $neg_key ); return $cid; } set_transient( $neg_key, time(), 10 * MINUTE_IN_SECONDS ); return 0; } /** * Get a single site variable. * * @since 1.0.0 * @param string $key Variable key (e.g., 'phone_primary', 'facebook_url'). * @param string $default Default value if not found. * @return string Variable value. */ function a1tools_get_variable( $key, $default = '' ) { $variables = a1tools_get_site_variables(); if ( null === $variables || ! isset( $variables[ $key ] ) ) { return $default; } return $variables[ $key ]; } /** * Clear the site variables cache. * * @since 1.0.0 * @return void */ function a1tools_clear_cache() { delete_transient( A1TOOLS_CACHE_KEY ); delete_transient( A1TOOLS_STORES_CACHE_KEY ); delete_transient( A1TOOLS_FRANCHISES_CACHE_KEY ); delete_transient( A1TOOLS_CITY_PAGES_CACHE_KEY ); delete_transient( A1TOOLS_SERVICES_CACHE_KEY ); delete_transient( A1TOOLS_REVIEWS_CACHE_KEY ); delete_transient( A1TOOLS_BEFORE_AFTER_CACHE_KEY ); delete_transient( A1TOOLS_TEAM_CACHE_KEY ); delete_transient( A1TOOLS_FAQ_CACHE_KEY ); if ( defined( 'A1TOOLS_SEO_LOCATIONS_CACHE_KEY' ) ) { delete_transient( A1TOOLS_SEO_LOCATIONS_CACHE_KEY ); } } /** * Get group store locations from the A1 Tools API. * * Fetches store data for the group that this site belongs to. * Results are cached using transients. * * @since 1.7.0 * @param bool $force_refresh Whether to bypass cache. * @return array|null Array of stores or null on failure. */ function a1tools_get_group_stores( $force_refresh = false ) { $url = a1tools_build_api_url( 'a1tools_stores_api_url', A1TOOLS_DEFAULT_STORES_API_URL ); return a1tools_fetch_api_data( A1TOOLS_STORES_CACHE_KEY, $url, 'stores', '', $force_refresh ); } /** * Get franchise locations from the A1 Tools API. * * Fetches franchise data for this specific site. * Results are cached using transients. * * @since 1.7.1 * @param bool $force_refresh Whether to bypass cache. * @return array|null Array of franchises or null on failure. */ function a1tools_get_site_franchises( $force_refresh = false ) { $url = a1tools_build_api_url( 'a1tools_franchises_api_url', A1TOOLS_DEFAULT_FRANCHISES_API_URL ); return a1tools_fetch_api_data( A1TOOLS_FRANCHISES_CACHE_KEY, $url, 'franchises', '', $force_refresh ); } /** * Get city page data from the A1 Tools API. * * Fetches city-specific phone/address data for this site's WordPress city pages. * Results are cached using transients and keyed by wp_page_id for O(1) shortcode lookup. * * @since 1.7.9 * @param bool $force_refresh Whether to bypass cache. * @return array|null Associative array keyed by wp_page_id, or null on failure. */ function a1tools_get_site_city_pages( $force_refresh = false ) { $url = a1tools_build_api_url( 'a1tools_city_pages_api_url', A1TOOLS_DEFAULT_CITY_PAGES_API_URL ); return a1tools_fetch_api_data( A1TOOLS_CITY_PAGES_CACHE_KEY, $url, 'city_pages', '', $force_refresh ); } /** * Get ALL WordPress city pages by querying the local WP database directly. * * City pages are identified by author display names containing "City" * (e.g., "Single City", "Location City"), excluding "SEO City" authors. * This returns every published city page regardless of whether it has * saved data in the A1 Tools API. * * Uses $wpdb direct queries and WP_Query for maximum reliability across * all WordPress versions and configurations. * * @since 1.8.2 * @return array Array of city pages keyed by WP page ID. */ function a1tools_get_wp_city_pages() { static $cached = null; if ( null !== $cached ) { return $cached; } global $wpdb; // Direct DB query for city authors — most reliable across all WP versions. // Match display_name containing "city" but NOT "seo" (excludes "SEO City" authors). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- cached via static variable above. $authors = $wpdb->get_results( $wpdb->prepare( "SELECT ID, display_name FROM {$wpdb->users} WHERE LOWER(display_name) LIKE %s AND LOWER(display_name) NOT LIKE %s", '%city%', '%seo%' ) ); if ( empty( $authors ) ) { $cached = array(); return $cached; } $city_author_ids = array(); $author_names = array(); foreach ( $authors as $author ) { $city_author_ids[] = (int) $author->ID; $author_names[ (int) $author->ID ] = $author->display_name; } // Use WP_Query for pages — handles large result sets reliably. $query = new WP_Query( array( 'post_type' => 'page', 'author__in' => $city_author_ids, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true, // Skip counting for performance. ) ); $result = array(); if ( $query->have_posts() ) { foreach ( $query->posts as $page ) { $author_id = (int) $page->post_author; $author_name = isset( $author_names[ $author_id ] ) ? $author_names[ $author_id ] : ''; $page_type = ( false !== stripos( $author_name, 'location' ) ) ? 'location_city' : 'single_city'; $result[ $page->ID ] = array( 'wp_page_id' => $page->ID, 'page_title' => $page->post_title, 'page_type' => $page_type, 'author_name' => $author_name, 'group_name' => null, 'group_order' => 0, ); } } wp_reset_postdata(); $cached = $result; return $cached; } /** * Get ALL WordPress service pages by querying the local WP database directly. * * Service pages are identified by author display names containing "large service" * (e.g., "Large Service"), excluding "SEO" authors. * This returns every published service page regardless of whether it has * saved data in the A1 Tools API. * * @since 1.9.0 * @return array Array of service pages keyed by WP page ID. */ function a1tools_get_wp_service_pages() { static $cached = null; if ( null !== $cached ) { return $cached; } global $wpdb; // Direct DB query for service authors — most reliable across all WP versions. // Match display_name containing "large service" but NOT "seo". // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- cached via static variable above. $authors = $wpdb->get_results( $wpdb->prepare( "SELECT ID, display_name FROM {$wpdb->users} WHERE LOWER(display_name) LIKE %s AND LOWER(display_name) NOT LIKE %s", '%large service%', '%seo%' ) ); if ( empty( $authors ) ) { $cached = array(); return $cached; } $service_author_ids = array(); $author_names = array(); foreach ( $authors as $author ) { $service_author_ids[] = (int) $author->ID; $author_names[ (int) $author->ID ] = $author->display_name; } // Use WP_Query for pages — handles large result sets reliably. $query = new WP_Query( array( 'post_type' => 'page', 'author__in' => $service_author_ids, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true, // Skip counting for performance. ) ); $result = array(); if ( $query->have_posts() ) { foreach ( $query->posts as $page ) { $author_id = (int) $page->post_author; $author_name = isset( $author_names[ $author_id ] ) ? $author_names[ $author_id ] : ''; $result[ $page->ID ] = array( 'wp_page_id' => $page->ID, 'page_title' => $page->post_title, 'author_name' => $author_name, ); } } wp_reset_postdata(); $cached = $result; return $cached; } /** * Get service data from the A1 Tools API. * * Fetches service-specific data for this site's WordPress service pages. * Results are cached using transients and keyed by wp_page_id for O(1) shortcode lookup. * * @since 1.9.0 * @param bool $force_refresh Whether to bypass cache. * @return array|null Associative array keyed by wp_page_id, or null on failure. */ function a1tools_get_site_services( $force_refresh = false ) { $url = a1tools_build_api_url( 'a1tools_services_api_url', A1TOOLS_DEFAULT_SERVICES_API_URL ); return a1tools_fetch_api_data( A1TOOLS_SERVICES_CACHE_KEY, $url, 'services', '', $force_refresh ); } /** * Get group reviews from the A1 Tools API. * * Fetches review data for the group that this site belongs to. * Results are cached using transients. * * @since 1.9.0 * @param bool $force_refresh Whether to bypass cache. * @return array|null Array of reviews or null on failure. */ function a1tools_get_group_reviews( $force_refresh = false ) { $url = a1tools_build_api_url( 'a1tools_reviews_api_url', A1TOOLS_DEFAULT_REVIEWS_API_URL ); return a1tools_fetch_api_data( A1TOOLS_REVIEWS_CACHE_KEY, $url, 'reviews', '', $force_refresh ); } /** * Get group before/after items from the A1 Tools API. * * Fetches before/after comparison data for the group that this site belongs to. * Results are cached using transients. * * @since 1.9.0 * @param bool $force_refresh Whether to bypass cache. * @return array|null Array of before/after items or null on failure. */ function a1tools_get_group_before_after( $force_refresh = false ) { $url = a1tools_build_api_url( 'a1tools_before_after_api_url', A1TOOLS_DEFAULT_BEFORE_AFTER_API_URL ); return a1tools_fetch_api_data( A1TOOLS_BEFORE_AFTER_CACHE_KEY, $url, 'items', '', $force_refresh ); } /** * Get group team members from the A1 Tools API. * * Fetches team member data for the group that this site belongs to. * Results are cached using transients. * * @since 1.9.0 * @param bool $force_refresh Whether to bypass cache. * @return array|null Array of team members or null on failure. */ function a1tools_get_group_team( $force_refresh = false ) { $url = a1tools_build_api_url( 'a1tools_team_api_url', A1TOOLS_DEFAULT_TEAM_API_URL ); return a1tools_fetch_api_data( A1TOOLS_TEAM_CACHE_KEY, $url, 'members', '', $force_refresh ); } /** * Get group FAQ items from the A1 Tools API. * * Fetches FAQ data for the group that this site belongs to. * Results are cached using transients. * * @since 1.9.0 * @param bool $force_refresh Whether to bypass cache. * @return array|null Array of FAQ items or null on failure. */ function a1tools_get_group_faq( $force_refresh = false ) { $url = a1tools_build_api_url( 'a1tools_faq_api_url', A1TOOLS_DEFAULT_FAQ_API_URL ); return a1tools_fetch_api_data( A1TOOLS_FAQ_CACHE_KEY, $url, 'faqs', '', $force_refresh ); } /** * Get SEO location data from the A1 Tools API. * * Fetches SEO-specific location data (phone, address, web URLs, map, viewpoint) * for this site's WordPress SEO city pages. * Results are cached using transients and keyed by wp_page_id for O(1) shortcode lookup. * * @since 2.1.0 * @param bool $force_refresh Whether to bypass cache. * @return array|null Associative array keyed by wp_page_id, or null on failure. */ function a1tools_get_seo_locations( $force_refresh = false ) { $url = a1tools_build_api_url( 'a1tools_seo_locations_api_url', A1TOOLS_DEFAULT_SEO_LOCATIONS_API_URL ); return a1tools_fetch_api_data( A1TOOLS_SEO_LOCATIONS_CACHE_KEY, $url, 'seo_locations', '', $force_refresh ); } /** * Get ALL WordPress SEO city pages by querying the local WP database directly. * * SEO city pages are identified by author display names containing BOTH * 'seo' AND 'city' (e.g., "SEO City"). * This returns every published SEO city page regardless of whether it has * saved data in the A1 Tools API. * * Uses $wpdb direct queries and WP_Query for maximum reliability across * all WordPress versions and configurations. * * @since 2.1.0 * @return array Array of SEO city pages keyed by WP page ID. */ function a1tools_get_wp_seo_city_pages() { static $cached = null; if ( null !== $cached ) { return $cached; } global $wpdb; // Direct DB query for SEO city authors — match display_name containing // both "seo" and "city" (e.g., "SEO City"). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- cached via static variable above. $authors = $wpdb->get_results( $wpdb->prepare( "SELECT ID, display_name FROM {$wpdb->users} WHERE LOWER(display_name) LIKE %s AND LOWER(display_name) LIKE %s", '%seo%', '%city%' ) ); if ( empty( $authors ) ) { $cached = array(); return $cached; } $seo_author_ids = array(); $author_names = array(); foreach ( $authors as $author ) { $seo_author_ids[] = (int) $author->ID; $author_names[ (int) $author->ID ] = $author->display_name; } // Use WP_Query for pages — handles large result sets reliably. $query = new WP_Query( array( 'post_type' => 'page', 'author__in' => $seo_author_ids, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true, // Skip counting for performance. ) ); $result = array(); if ( $query->have_posts() ) { foreach ( $query->posts as $page ) { $author_id = (int) $page->post_author; $author_name = isset( $author_names[ $author_id ] ) ? $author_names[ $author_id ] : ''; $result[ $page->ID ] = array( 'wp_page_id' => $page->ID, 'page_title' => $page->post_title, 'author_name' => $author_name, 'group_name' => null, 'group_order' => 0, ); } } wp_reset_postdata(); $cached = $result; return $cached; }