'public', 'url' => $site_url, ), $api_base_url ); $response = wp_remote_get( $api_url, array( 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { return null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! isset( $data['success'] ) || true !== $data['success'] ) { return null; } $variables = isset( $data['variables'] ) ? $data['variables'] : array(); // Cache the result. $cache_expiry = (int) get_option( 'a1tools_cache_expiry', A1TOOLS_DEFAULT_CACHE_EXPIRY ); if ( $cache_expiry > 0 ) { set_transient( A1TOOLS_CACHE_KEY, $variables, $cache_expiry ); } return $variables; } /** * 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 ); } /** * 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 ) { // Check cache first. if ( ! $force_refresh ) { $cached = get_transient( A1TOOLS_STORES_CACHE_KEY ); if ( false !== $cached ) { return $cached; } } $site_url = site_url(); $api_base_url = apply_filters( 'a1tools_stores_api_url', A1TOOLS_DEFAULT_STORES_API_URL ); $api_url = add_query_arg( array( 'action' => 'public', 'url' => $site_url, ), $api_base_url ); $response = wp_remote_get( $api_url, array( 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { return null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! isset( $data['success'] ) || true !== $data['success'] ) { return null; } $stores = isset( $data['stores'] ) ? $data['stores'] : array(); // Cache the result. $cache_expiry = (int) get_option( 'a1tools_cache_expiry', A1TOOLS_DEFAULT_CACHE_EXPIRY ); if ( $cache_expiry > 0 ) { set_transient( A1TOOLS_STORES_CACHE_KEY, $stores, $cache_expiry ); } return $stores; } /** * 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 ) { // Check cache first. if ( ! $force_refresh ) { $cached = get_transient( A1TOOLS_FRANCHISES_CACHE_KEY ); if ( false !== $cached ) { return $cached; } } $site_url = site_url(); $api_base_url = apply_filters( 'a1tools_franchises_api_url', A1TOOLS_DEFAULT_FRANCHISES_API_URL ); $api_url = add_query_arg( array( 'action' => 'public', 'url' => $site_url, ), $api_base_url ); $response = wp_remote_get( $api_url, array( 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { return null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! isset( $data['success'] ) || true !== $data['success'] ) { return null; } $franchises = isset( $data['franchises'] ) ? $data['franchises'] : array(); // Cache the result. $cache_expiry = (int) get_option( 'a1tools_cache_expiry', A1TOOLS_DEFAULT_CACHE_EXPIRY ); if ( $cache_expiry > 0 ) { set_transient( A1TOOLS_FRANCHISES_CACHE_KEY, $franchises, $cache_expiry ); } return $franchises; } /** * 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 ) { // Check cache first. if ( ! $force_refresh ) { $cached = get_transient( A1TOOLS_CITY_PAGES_CACHE_KEY ); if ( false !== $cached ) { return $cached; } } $site_url = site_url(); $api_base_url = apply_filters( 'a1tools_city_pages_api_url', A1TOOLS_DEFAULT_CITY_PAGES_API_URL ); $api_url = add_query_arg( array( 'action' => 'public', 'url' => $site_url, ), $api_base_url ); $response = wp_remote_get( $api_url, array( 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { return null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! isset( $data['success'] ) || true !== $data['success'] ) { return null; } // city_pages is already keyed by wp_page_id from the API $city_pages = isset( $data['city_pages'] ) ? $data['city_pages'] : array(); // Cache the result. $cache_expiry = (int) get_option( 'a1tools_cache_expiry', A1TOOLS_DEFAULT_CACHE_EXPIRY ); if ( $cache_expiry > 0 ) { set_transient( A1TOOLS_CITY_PAGES_CACHE_KEY, $city_pages, $cache_expiry ); } return $city_pages; } /** * 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). $authors = $wpdb->get_results( "SELECT ID, display_name FROM {$wpdb->users} WHERE LOWER(display_name) LIKE '%city%' AND LOWER(display_name) NOT LIKE '%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". $authors = $wpdb->get_results( "SELECT ID, display_name FROM {$wpdb->users} WHERE LOWER(display_name) LIKE '%large service%' AND LOWER(display_name) NOT LIKE '%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 ) { // Check cache first. if ( ! $force_refresh ) { $cached = get_transient( A1TOOLS_SERVICES_CACHE_KEY ); if ( false !== $cached ) { return $cached; } } $site_url = site_url(); $api_base_url = apply_filters( 'a1tools_services_api_url', A1TOOLS_DEFAULT_SERVICES_API_URL ); $api_url = add_query_arg( array( 'action' => 'public', 'url' => $site_url, ), $api_base_url ); $response = wp_remote_get( $api_url, array( 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { return null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! isset( $data['success'] ) || true !== $data['success'] ) { return null; } $services = isset( $data['services'] ) ? $data['services'] : array(); // Cache the result. $cache_expiry = (int) get_option( 'a1tools_cache_expiry', A1TOOLS_DEFAULT_CACHE_EXPIRY ); if ( $cache_expiry > 0 ) { set_transient( A1TOOLS_SERVICES_CACHE_KEY, $services, $cache_expiry ); } return $services; } /** * 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 ) { // Check cache first. if ( ! $force_refresh ) { $cached = get_transient( A1TOOLS_REVIEWS_CACHE_KEY ); if ( false !== $cached ) { return $cached; } } $site_url = site_url(); $api_base_url = apply_filters( 'a1tools_reviews_api_url', A1TOOLS_DEFAULT_REVIEWS_API_URL ); $api_url = add_query_arg( array( 'action' => 'public', 'url' => $site_url, ), $api_base_url ); $response = wp_remote_get( $api_url, array( 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { return null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! isset( $data['success'] ) || true !== $data['success'] ) { return null; } $reviews = isset( $data['reviews'] ) ? $data['reviews'] : array(); // Cache the result. $cache_expiry = (int) get_option( 'a1tools_cache_expiry', A1TOOLS_DEFAULT_CACHE_EXPIRY ); if ( $cache_expiry > 0 ) { set_transient( A1TOOLS_REVIEWS_CACHE_KEY, $reviews, $cache_expiry ); } return $reviews; } /** * 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 ) { // Check cache first. if ( ! $force_refresh ) { $cached = get_transient( A1TOOLS_BEFORE_AFTER_CACHE_KEY ); if ( false !== $cached ) { return $cached; } } $site_url = site_url(); $api_base_url = apply_filters( 'a1tools_before_after_api_url', A1TOOLS_DEFAULT_BEFORE_AFTER_API_URL ); $api_url = add_query_arg( array( 'action' => 'public', 'url' => $site_url, ), $api_base_url ); $response = wp_remote_get( $api_url, array( 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { return null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! isset( $data['success'] ) || true !== $data['success'] ) { return null; } $items = isset( $data['items'] ) ? $data['items'] : array(); // Cache the result. $cache_expiry = (int) get_option( 'a1tools_cache_expiry', A1TOOLS_DEFAULT_CACHE_EXPIRY ); if ( $cache_expiry > 0 ) { set_transient( A1TOOLS_BEFORE_AFTER_CACHE_KEY, $items, $cache_expiry ); } return $items; } /** * 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 ) { // Check cache first. if ( ! $force_refresh ) { $cached = get_transient( A1TOOLS_TEAM_CACHE_KEY ); if ( false !== $cached ) { return $cached; } } $site_url = site_url(); $api_base_url = apply_filters( 'a1tools_team_api_url', A1TOOLS_DEFAULT_TEAM_API_URL ); $api_url = add_query_arg( array( 'action' => 'public', 'url' => $site_url, ), $api_base_url ); $response = wp_remote_get( $api_url, array( 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { return null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! isset( $data['success'] ) || true !== $data['success'] ) { return null; } $members = isset( $data['members'] ) ? $data['members'] : array(); // Cache the result. $cache_expiry = (int) get_option( 'a1tools_cache_expiry', A1TOOLS_DEFAULT_CACHE_EXPIRY ); if ( $cache_expiry > 0 ) { set_transient( A1TOOLS_TEAM_CACHE_KEY, $members, $cache_expiry ); } return $members; } /** * 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 ) { // Check cache first. if ( ! $force_refresh ) { $cached = get_transient( A1TOOLS_FAQ_CACHE_KEY ); if ( false !== $cached ) { return $cached; } } $site_url = site_url(); $api_base_url = apply_filters( 'a1tools_faq_api_url', A1TOOLS_DEFAULT_FAQ_API_URL ); $api_url = add_query_arg( array( 'action' => 'public', 'url' => $site_url, ), $api_base_url ); $response = wp_remote_get( $api_url, array( 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { return null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! isset( $data['success'] ) || true !== $data['success'] ) { return null; } $faqs = isset( $data['faqs'] ) ? $data['faqs'] : array(); // Cache the result. $cache_expiry = (int) get_option( 'a1tools_cache_expiry', A1TOOLS_DEFAULT_CACHE_EXPIRY ); if ( $cache_expiry > 0 ) { set_transient( A1TOOLS_FAQ_CACHE_KEY, $faqs, $cache_expiry ); } return $faqs; } // ============================================================================ // SHORTCODES // ============================================================================ /** * Process A1 Tools shortcodes inside HTML attributes (href, src, etc.). * * WordPress does not run do_shortcode() inside HTML attributes. This filter * finds occurrences like href="tel:[a1tools_var key="phone_primary"]" * or href="mailto:[a1tools_var ...]" and resolves them before normal rendering. * * @since 1.5.0 * @param string $content Post content. * @return string Content with shortcodes resolved inside attributes. */ function a1tools_process_shortcodes_in_attributes( $content ) { // Match href="...anything with [a1tools_ shortcode..." (handles HTML-encoded quotes too) $content = preg_replace_callback( '/(?<=href=["\'])([^"\']*\[a1tools_[^\]]*\][^"\']*)/i', function ( $matches ) { // Decode HTML entities so shortcode parser can read the attributes $decoded = html_entity_decode( $matches[1], ENT_QUOTES, 'UTF-8' ); // Process any shortcodes found inside the href value return do_shortcode( $decoded ); }, $content ); return $content; } add_filter( 'the_content', 'a1tools_process_shortcodes_in_attributes', 7 ); add_filter( 'widget_text', 'a1tools_process_shortcodes_in_attributes', 7 ); /** * Shortcode: [a1tools_var key="phone_primary"] * Outputs a site variable value. * * @since 1.0.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_var( $atts ) { $atts = shortcode_atts( array( 'key' => '', 'link' => 'false', 'target' => '_blank', 'class' => '', 'default' => '', ), $atts, 'a1tools_var' ); if ( empty( $atts['key'] ) ) { return ''; } $value = a1tools_get_variable( $atts['key'], $atts['default'] ); if ( empty( $value ) ) { return esc_html( $atts['default'] ); } // Wrap in link if requested and value is a URL. if ( 'true' === $atts['link'] && filter_var( $value, FILTER_VALIDATE_URL ) ) { $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; $rel_attr = '_blank' === $atts['target'] ? ' rel="noopener noreferrer"' : ''; return '' . esc_html( $value ) . ''; } // Wrap in span if class provided. if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $value ) . ''; } return esc_html( $value ); } add_shortcode( 'a1tools_var', 'a1tools_shortcode_var' ); /** * Shortcode: [a1tools_website_url] * Outputs the website URL as text or a clickable link. * * @since 1.5.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_website_url( $atts ) { $atts = shortcode_atts( array( 'link' => 'false', 'target' => '_blank', 'class' => '', 'text' => '', ), $atts, 'a1tools_website_url' ); $url = home_url( '/' ); // If link="true", output as a clickable link. if ( 'true' === $atts['link'] ) { $link_text = ! empty( $atts['text'] ) ? esc_html( $atts['text'] ) : esc_html( $url ); $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; $rel_attr = '_blank' === $atts['target'] ? ' rel="noopener noreferrer"' : ''; return '' . $link_text . ''; } // Plain text output. if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $url ) . ''; } return esc_html( $url ); } add_shortcode( 'a1tools_website_url', 'a1tools_shortcode_website_url' ); /** * Shortcode: [a1tools_address format="full"] * Outputs formatted address. * * @since 1.0.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_address( $atts ) { $atts = shortcode_atts( array( 'format' => 'full', 'separator' => '
', ), $atts, 'a1tools_address' ); $vars = a1tools_get_site_variables(); if ( ! $vars ) { return ''; } $parts = array(); switch ( $atts['format'] ) { case 'street': $parts = array_filter( array( isset( $vars['address_line1'] ) ? $vars['address_line1'] : '', isset( $vars['address_line2'] ) ? $vars['address_line2'] : '', ) ); break; case 'city_state_zip': $city_state = array_filter( array( isset( $vars['city'] ) ? $vars['city'] : '', isset( $vars['state'] ) ? $vars['state'] : '', ) ); $zip = isset( $vars['zip'] ) ? $vars['zip'] : ''; $parts = array( implode( ', ', $city_state ) . ' ' . $zip ); break; case 'full': default: $city = isset( $vars['city'] ) ? $vars['city'] : ''; $state = isset( $vars['state'] ) ? $vars['state'] : ''; $zip = isset( $vars['zip'] ) ? $vars['zip'] : ''; $state_zip = trim( $state . ' ' . $zip ); $city_line = $city ? $city . ( $state_zip ? ', ' . $state_zip : '' ) : $state_zip; $parts = array_filter( array( isset( $vars['address_line1'] ) ? $vars['address_line1'] : '', isset( $vars['address_line2'] ) ? $vars['address_line2'] : '', $city_line, isset( $vars['country'] ) ? $vars['country'] : '', ) ); break; } // Handle separator - allow
for HTML output, otherwise escape. $separator = $atts['separator']; if ( '
' === $separator || '
' === $separator || '
' === $separator ) { return implode( '
', array_map( 'esc_html', $parts ) ); } return esc_html( implode( $separator, $parts ) ); } add_shortcode( 'a1tools_address', 'a1tools_shortcode_address' ); /** * Shortcode: [a1tools_full_address] * Outputs the complete address in a single line. * * @since 1.2.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_full_address( $atts ) { $atts = shortcode_atts( array( 'separator' => ', ', 'class' => '', ), $atts, 'a1tools_full_address' ); $vars = a1tools_get_site_variables(); if ( ! $vars ) { return ''; } // Use standalone full_address field if set. if ( ! empty( $vars['full_address'] ) ) { $output = esc_html( $vars['full_address'] ); if ( ! empty( $atts['class'] ) ) { $output = '' . $output . ''; } return $output; } // Fallback: assemble from individual address fields. $parts = array(); // Address Line 1. if ( ! empty( $vars['address_line1'] ) ) { $parts[] = $vars['address_line1']; } // Address Line 2. if ( ! empty( $vars['address_line2'] ) ) { $parts[] = $vars['address_line2']; } // City. if ( ! empty( $vars['city'] ) ) { $parts[] = $vars['city']; } // State + ZIP (combined). $state = ! empty( $vars['state'] ) ? $vars['state'] : ''; $zip = ! empty( $vars['zip'] ) ? $vars['zip'] : ''; if ( $state || $zip ) { $parts[] = trim( $state . ' ' . $zip ); } if ( empty( $parts ) ) { return ''; } $output = esc_html( implode( $atts['separator'], $parts ) ); if ( ! empty( $atts['class'] ) ) { $output = '' . $output . ''; } return $output; } add_shortcode( 'a1tools_full_address', 'a1tools_shortcode_full_address' ); /** * Shortcode: [a1tools_hours] * Outputs formatted operating hours table. * * @since 1.0.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_hours( $atts ) { $atts = shortcode_atts( array( 'format' => 'table', 'class' => 'a1tools-hours', ), $atts, 'a1tools_hours' ); $vars = a1tools_get_site_variables(); if ( ! $vars || empty( $vars['operating_hours'] ) ) { return ''; } $hours = $vars['operating_hours']; $days = array( 'mon' => __( 'Monday', 'a1-tools' ), 'tue' => __( 'Tuesday', 'a1-tools' ), 'wed' => __( 'Wednesday', 'a1-tools' ), 'thu' => __( 'Thursday', 'a1-tools' ), 'fri' => __( 'Friday', 'a1-tools' ), 'sat' => __( 'Saturday', 'a1-tools' ), 'sun' => __( 'Sunday', 'a1-tools' ), ); $output = ''; if ( 'list' === $atts['format'] ) { $output = ''; } else { $output = ''; foreach ( $days as $key => $label ) { $time = isset( $hours[ $key ] ) && ! empty( $hours[ $key ] ) ? esc_html( $hours[ $key ] ) : esc_html__( 'Closed', 'a1-tools' ); $output .= ''; } $output .= '
' . esc_html( $label ) . '' . $time . '
'; } return $output; } add_shortcode( 'a1tools_hours', 'a1tools_shortcode_hours' ); /** * Get SVG markup for icons not available in Font Awesome 5. * * @since 1.5.0 * @param string $icon_key The SVG icon key (e.g. 'svg-x'). * @return string SVG markup or empty string. */ function a1tools_get_svg_icon( $icon_key ) { $svgs = array( 'svg-x' => '', ); return isset( $svgs[ $icon_key ] ) ? $svgs[ $icon_key ] : ''; } /** * Get icon HTML (SVG or Font Awesome tag). * * @since 1.5.0 * @param string $icon Icon class or SVG key. * @return string HTML markup for the icon. */ function a1tools_get_icon_html( $icon ) { $svg = a1tools_get_svg_icon( $icon ); if ( $svg ) { return $svg; } return ''; } /** * Shortcode: [a1tools_social_links] * Outputs social media icons/links with optional styling. * * @since 1.0.0 * @since 1.2.0 Added styling parameters. * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_social_links( $atts ) { $atts = shortcode_atts( array( 'class' => 'a1tools-social-links', 'icon_class' => '', 'style' => '', // default, outline, minimal, light, dark 'shape' => 'rounded', // rounded, circle, square 'size' => '40', // button size in px 'icon_size' => '18', // icon size in px 'spacing' => '8', // spacing between icons in px 'alignment' => 'left', // left, center, right 'color' => '', // custom color (hex) 'hover' => 'scale', // scale, lift, rotate, pulse, none ), $atts, 'a1tools_social_links' ); $vars = a1tools_get_site_variables(); if ( ! $vars ) { return ''; } $platforms = array( 'facebook_url' => array( 'label' => 'Facebook', 'icon' => 'fab fa-facebook-f', 'color' => '#1877F2' ), 'instagram_url' => array( 'label' => 'Instagram', 'icon' => 'fab fa-instagram', 'color' => '#E4405F' ), 'youtube_url' => array( 'label' => 'YouTube', 'icon' => 'fab fa-youtube', 'color' => '#FF0000' ), 'twitter_url' => array( 'label' => 'X (Twitter)', 'icon' => 'svg-x', 'color' => '#000000' ), 'linkedin_url' => array( 'label' => 'LinkedIn', 'icon' => 'fab fa-linkedin-in', 'color' => '#0A66C2' ), 'tiktok_url' => array( 'label' => 'TikTok', 'icon' => 'fab fa-tiktok', 'color' => '#000000' ), 'yelp_url' => array( 'label' => 'Yelp', 'icon' => 'fab fa-yelp', 'color' => '#D32323' ), 'google_business_url' => array( 'label' => 'Google', 'icon' => 'fab fa-google', 'color' => '#4285F4' ), 'pinterest_url' => array( 'label' => 'Pinterest', 'icon' => 'fab fa-pinterest-p', 'color' => '#E60023' ), 'bbb_url' => array( 'label' => 'BBB', 'icon' => 'fas fa-shield-alt', 'color' => '#006CB7' ), 'nextdoor_url' => array( 'label' => 'Nextdoor', 'icon' => 'fas fa-home', 'color' => '#8ED500' ), 'houzz_url' => array( 'label' => 'Houzz', 'icon' => 'fab fa-houzz', 'color' => '#4DBC15' ), 'angi_url' => array( 'label' => 'Angi', 'icon' => 'fas fa-tools', 'color' => '#FF6153' ), 'thumbtack_url' => array( 'label' => 'Thumbtack', 'icon' => 'fas fa-thumbtack', 'color' => '#009FD9' ), 'homeadvisor_url' => array( 'label' => 'HomeAdvisor', 'icon' => 'fas fa-home', 'color' => '#EF6C00' ), ); // If no style is set, use simple output (backwards compatible). if ( empty( $atts['style'] ) ) { $output = '
'; foreach ( $platforms as $key => $data ) { if ( ! empty( $vars[ $key ] ) ) { $icon = ! empty( $atts['icon_class'] ) ? $atts['icon_class'] : $data['icon']; $output .= ''; $output .= a1tools_get_icon_html( $icon ); $output .= '' . esc_html( $data['label'] ) . ''; $output .= ''; } } $output .= '
'; return $output; } // Styled output. $style = $atts['style']; $shape = $atts['shape']; $size = (int) $atts['size']; $icon_size = (int) $atts['icon_size']; $spacing = (int) $atts['spacing']; $alignment = $atts['alignment']; $custom_color = $atts['color']; $hover_effect = $atts['hover']; // Generate unique ID. static $instance_count = 0; $instance_count++; $widget_id = 'a1tools-social-sc-' . $instance_count; // Border radius based on shape. $border_radius = '8px'; if ( 'circle' === $shape ) { $border_radius = '50%'; } elseif ( 'square' === $shape ) { $border_radius = '0'; } // Build CSS for this instance. $css = '#' . esc_attr( $widget_id ) . ' { text-align:' . esc_attr( $alignment ) . '; }'; $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { '; $css .= 'width:' . (int) $size . 'px;height:' . (int) $size . 'px;'; $css .= 'margin:0 ' . (int) ( $spacing / 2 ) . 'px ' . (int) $spacing . 'px;'; $css .= 'font-size:' . (int) $icon_size . 'px;'; $css .= 'display:inline-flex;align-items:center;justify-content:center;'; $css .= 'text-decoration:none;transition:all 0.3s ease;'; $css .= 'border-radius:' . esc_attr( $border_radius ) . ';'; $css .= '}'; // Style-specific CSS. if ( 'default' === $style || 'official' === $style ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { color: #fff; }'; } elseif ( 'outline' === $style ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { background: transparent; border: 2px solid; }'; } elseif ( 'minimal' === $style ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { background: transparent; }'; } elseif ( 'light' === $style ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { background: #f5f5f5; }'; } elseif ( 'dark' === $style ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { background: #333; color: #fff; }'; } // Hover effects. if ( 'scale' === $hover_effect ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon:hover { transform: scale(1.1); }'; } elseif ( 'lift' === $hover_effect ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon:hover { transform: translateY(-3px); box-shadow: 0 4px 12px rgba(0,0,0,0.2); }'; } elseif ( 'rotate' === $hover_effect ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon:hover { transform: rotate(15deg); }'; } elseif ( 'pulse' === $hover_effect ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon:hover { animation: a1tools-pulse 0.5s ease; }'; $css .= '@keyframes a1tools-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.15); } }'; } // Ensure the style handle exists before adding inline CSS. if ( ! wp_style_is( 'a1tools-social', 'registered' ) ) { wp_register_style( 'a1tools-social', false, array(), A1TOOLS_VERSION ); } if ( ! wp_style_is( 'a1tools-social', 'enqueued' ) ) { wp_enqueue_style( 'a1tools-social' ); } wp_add_inline_style( 'a1tools-social', $css ); $output = '
'; foreach ( $platforms as $key => $data ) { if ( ! empty( $vars[ $key ] ) ) { $item_color = $custom_color ? $custom_color : $data['color']; $inline_style = ''; if ( 'default' === $style || 'official' === $style ) { $inline_style = 'background-color:' . esc_attr( $item_color ) . ';'; } elseif ( 'outline' === $style ) { $inline_style = 'border-color:' . esc_attr( $item_color ) . ';color:' . esc_attr( $item_color ) . ';'; } elseif ( 'minimal' === $style || 'light' === $style ) { $inline_style = 'color:' . esc_attr( $item_color ) . ';'; } $icon = ! empty( $atts['icon_class'] ) ? $atts['icon_class'] : $data['icon']; $output .= ''; $output .= a1tools_get_icon_html( $icon ); $output .= '' . esc_html( $data['label'] ) . ''; $output .= ''; } } $output .= '
'; return $output; } add_shortcode( 'a1tools_social_links', 'a1tools_shortcode_social_links' ); /** * Shortcode: [a1tools_city_name] * Outputs the city name (for geo-targeting). * * @since 1.0.8 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_city_name( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'class' => '', ), $atts, 'a1tools_city_name' ); $value = a1tools_get_variable( 'city_name', $atts['default'] ); if ( empty( $value ) ) { return esc_html( $atts['default'] ); } if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $value ) . ''; } return esc_html( $value ); } add_shortcode( 'a1tools_city_name', 'a1tools_shortcode_city_name' ); /** * Shortcode: [a1tools_state] * Outputs the state/location name (for geo-targeting). * * @since 1.0.8 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_state( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'class' => '', ), $atts, 'a1tools_state' ); $value = a1tools_get_variable( 'location_name', $atts['default'] ); if ( empty( $value ) ) { return esc_html( $atts['default'] ); } if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $value ) . ''; } return esc_html( $value ); } add_shortcode( 'a1tools_state', 'a1tools_shortcode_state' ); /** * Shortcode: [a1tools_city_link] * Outputs the city name as a clickable link using the configured city_url. * If no city_url is set, falls back to plain text (same as [a1tools_city_name]). * * @since 1.9.2 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_city_link( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'text' => '', 'target' => '_self', 'class' => '', ), $atts, 'a1tools_city_link' ); $city_name = a1tools_get_variable( 'city_name', $atts['default'] ); $city_url = a1tools_get_variable( 'city_url', '' ); $text = ! empty( $atts['text'] ) ? $atts['text'] : $city_name; if ( empty( $text ) ) { return ''; } if ( ! empty( $city_url ) ) { $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . esc_html( $text ) . ''; } // No URL set — plain text fallback. if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $text ) . ''; } return esc_html( $text ); } add_shortcode( 'a1tools_city_link', 'a1tools_shortcode_city_link' ); /** * Shortcode: [a1tools_state_link] * Outputs the state name as a clickable link using the configured state_url. * If no state_url is set, falls back to plain text (same as [a1tools_state]). * * @since 1.9.2 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_state_link( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'text' => '', 'target' => '_self', 'class' => '', ), $atts, 'a1tools_state_link' ); $state_name = a1tools_get_variable( 'location_name', $atts['default'] ); $state_url = a1tools_get_variable( 'state_url', '' ); $text = ! empty( $atts['text'] ) ? $atts['text'] : $state_name; if ( empty( $text ) ) { return ''; } if ( ! empty( $state_url ) ) { $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . esc_html( $text ) . ''; } // No URL set — plain text fallback. if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $text ) . ''; } return esc_html( $text ); } add_shortcode( 'a1tools_state_link', 'a1tools_shortcode_state_link' ); /** * Shortcode: [a1tools_google_map] * Outputs a Google Maps embed or link. * * @since 1.0.8 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_google_map( $atts ) { $atts = shortcode_atts( array( 'type' => 'link', 'width' => '100%', 'height' => '400', 'class' => 'a1tools-google-map', 'text' => __( 'View on Google Maps', 'a1-tools' ), 'target' => '_blank', ), $atts, 'a1tools_google_map' ); $url = a1tools_get_variable( 'google_maps_url', '' ); if ( empty( $url ) ) { return ''; } // If type is 'embed', create an iframe if ( 'embed' === $atts['type'] ) { // Check if URL is already an embed URL or convert it $embed_url = $url; // If it's a regular Google Maps URL, try to convert to embed format if ( false !== strpos( $url, 'google.com/maps' ) && false === strpos( $url, '/embed' ) ) { // Extract place or coordinates and create embed URL if ( preg_match( '/place\/([^\/]+)/', $url, $matches ) ) { $place = $matches[1]; $embed_url = 'https://www.google.com/maps/embed/v1/place?key=&q=' . urlencode( $place ); } elseif ( preg_match( '/@(-?\d+\.\d+),(-?\d+\.\d+)/', $url, $matches ) ) { $lat = $matches[1]; $lng = $matches[2]; $embed_url = 'https://maps.google.com/maps?q=' . $lat . ',' . $lng . '&output=embed'; } } return ''; } // Default: return a link $rel = '_blank' === $atts['target'] ? ' rel="noopener noreferrer"' : ''; return '' . esc_html( $atts['text'] ) . ''; } add_shortcode( 'a1tools_google_map', 'a1tools_shortcode_google_map' ); // ============================================================================ // CITY PAGE SHORTCODES // ============================================================================ /** * Shortcode: [a1tools_city_phone] * Outputs the city-specific phone number for the current page. * Automatically detects which city page it's on via get_the_ID(). * Falls back to the site's primary phone if no city-specific phone is set. * * @since 1.7.9 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_city_phone( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'link' => 'no', 'class' => '', 'page_id' => '', ), $atts, 'a1tools_city_phone' ); $page_id = ! empty( $atts['page_id'] ) ? intval( $atts['page_id'] ) : get_the_ID(); $city_pages = a1tools_get_site_city_pages(); // Get city-specific phone. $phone = ''; if ( ! empty( $city_pages ) && isset( $city_pages[ $page_id ] ) ) { $phone = isset( $city_pages[ $page_id ]['phone'] ) ? $city_pages[ $page_id ]['phone'] : ''; } // Fallback to site primary phone if no city-specific phone. if ( empty( $phone ) ) { if ( ! empty( $atts['default'] ) ) { return esc_html( $atts['default'] ); } // Use the site's primary phone as fallback. $phone = a1tools_get_variable( 'phone_primary', '' ); if ( empty( $phone ) ) { return ''; } } // Output as tel: link if requested. if ( 'yes' === $atts['link'] ) { $clean = preg_replace( '/[^0-9+]/', '', $phone ); $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . esc_html( $phone ) . ''; } if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $phone ) . ''; } return esc_html( $phone ); } add_shortcode( 'a1tools_city_phone', 'a1tools_shortcode_city_phone' ); /** * Shortcode: [a1tools_city_address] * Outputs the city-specific address for the current page. * Automatically detects which city page it's on via get_the_ID(). * * Attributes: * - format: 'full' (multi-line), 'inline' (single-line), or a specific field name * (e.g., 'city', 'state', 'zip', 'address_line1') * * @since 1.7.9 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_city_address( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'format' => 'full', 'class' => '', 'page_id' => '', ), $atts, 'a1tools_city_address' ); $page_id = ! empty( $atts['page_id'] ) ? intval( $atts['page_id'] ) : get_the_ID(); $city_pages = a1tools_get_site_city_pages(); // Try city-specific address first. $city_data = null; if ( ! empty( $city_pages ) && isset( $city_pages[ $page_id ] ) ) { $city_data = $city_pages[ $page_id ]; } // Check if city data has any address fields set. $has_city_address = false; if ( $city_data ) { $has_city_address = ! empty( $city_data['address_line1'] ) || ! empty( $city_data['city'] ) || ! empty( $city_data['state'] ); } // If format is a specific field name, return that field from city data or site fallback. $single_fields = array( 'address_line1', 'address_line2', 'city', 'state', 'zip', 'country' ); if ( in_array( $atts['format'], $single_fields, true ) ) { $value = ''; if ( $has_city_address && isset( $city_data[ $atts['format'] ] ) ) { $value = $city_data[ $atts['format'] ]; } // Fallback to site variable. if ( empty( $value ) ) { $value = a1tools_get_variable( $atts['format'], '' ); } if ( empty( $value ) ) { return esc_html( $atts['default'] ); } if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $value ) . ''; } return esc_html( $value ); } // Determine source: city-specific or site fallback. if ( $has_city_address ) { $line1 = isset( $city_data['address_line1'] ) ? $city_data['address_line1'] : ''; $line2 = isset( $city_data['address_line2'] ) ? $city_data['address_line2'] : ''; $city = isset( $city_data['city'] ) ? $city_data['city'] : ''; $state = isset( $city_data['state'] ) ? $city_data['state'] : ''; $zip = isset( $city_data['zip'] ) ? $city_data['zip'] : ''; $country = isset( $city_data['country'] ) ? $city_data['country'] : ''; } else { // Fallback to site address from Information tab. $vars = a1tools_get_site_variables(); $line1 = isset( $vars['address_line1'] ) ? $vars['address_line1'] : ''; $line2 = isset( $vars['address_line2'] ) ? $vars['address_line2'] : ''; $city = isset( $vars['city'] ) ? $vars['city'] : ''; $state = isset( $vars['state'] ) ? $vars['state'] : ''; $zip = isset( $vars['zip'] ) ? $vars['zip'] : ''; $country = isset( $vars['country'] ) ? $vars['country'] : ''; } if ( 'inline' === $atts['format'] ) { // Single-line comma-separated. $parts = array_filter( array( $line1, $line2, $city, trim( $state . ' ' . $zip ), $country ) ); $output = implode( ', ', $parts ); } else { // Multi-line (full format). $lines = array(); if ( ! empty( $line1 ) ) { $lines[] = esc_html( $line1 ); } if ( ! empty( $line2 ) ) { $lines[] = esc_html( $line2 ); } $city_state_zip = trim( $city . ( ! empty( $state ) ? ', ' . $state : '' ) . ( ! empty( $zip ) ? ' ' . $zip : '' ) ); if ( ! empty( $city_state_zip ) ) { $lines[] = esc_html( $city_state_zip ); } if ( ! empty( $country ) && 'USA' !== $country ) { $lines[] = esc_html( $country ); } $output = implode( '
', $lines ); } if ( empty( $output ) ) { return esc_html( $atts['default'] ); } $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . $output . ''; } add_shortcode( 'a1tools_city_address', 'a1tools_shortcode_city_address' ); /** * Shortcode: [a1tools_city_list] * Renders a linked list of city pages, optionally grouped by group_name. * Uses get_permalink() for page URLs. Groups are assigned in the Flutter app. * * Attributes: * - layout: 'list' (single column) or 'columns' (multi-column CSS columns) * - columns: Number of columns (1-4, default 3, used when layout=columns) * - show_groups: 'yes' or '' — show group headings * - group: Filter to a specific group name * - type: 'all', 'single_city', or 'location_city' * - show_bullets: 'yes' or '' — show list bullets * - target: Link target: '_self' or '_blank' * - append_state: 'yes' to append state suffix after city name * - state: State suffix text (e.g., 'NJ') * - class: Additional CSS class for the wrapper * * @since 1.8.1 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_city_list( $atts ) { $atts = shortcode_atts( array( 'layout' => 'list', 'columns' => '3', 'show_groups' => 'yes', 'group' => '', 'type' => 'all', 'show_bullets' => 'yes', 'target' => '_self', 'append_state' => '', 'state' => '', 'class' => '', 'accordion' => '', 'accordion_default' => 'collapsed', 'accordion_mode' => 'multiple', 'accordion_icon' => 'yes', 'accordion_speed' => '300', 'show_count' => '', 'show_search' => '', 'search_placeholder' => 'Search cities...', '_elementor' => '', ), $atts, 'a1tools_city_list' ); // Get ALL WordPress city pages from the local WP database (includes every // published page by "Single City" / "Location City" authors, excludes "SEO City"). $wp_city_pages = a1tools_get_wp_city_pages(); // Get saved data from the A1 Tools API (phone, address, group — only for // cities that have been edited in the app). Keyed by wp_page_id. $saved_data = a1tools_get_site_city_pages(); if ( ! is_array( $saved_data ) ) { $saved_data = array(); } // Merge: start with all WP pages, overlay saved data (phone/address/group). // If WP query returned results, use it as the base (shows ALL city pages). // Otherwise fall back to API-only data (only pages with saved data). $base_pages = ! empty( $wp_city_pages ) ? $wp_city_pages : $saved_data; if ( empty( $base_pages ) ) { return ''; } $cities = array(); foreach ( $base_pages as $wp_page_id => $wp_data ) { $permalink = get_permalink( intval( $wp_page_id ) ); if ( empty( $permalink ) ) { continue; // Skip cities whose WP page no longer exists. } // Overlay saved data if this city has been edited in the app. if ( ! empty( $wp_city_pages ) && isset( $saved_data[ $wp_page_id ] ) ) { $wp_data = array_merge( $wp_data, $saved_data[ $wp_page_id ] ); } $wp_data['wp_page_id'] = $wp_page_id; $wp_data['permalink'] = $permalink; $cities[] = $wp_data; } // Filter by type. if ( 'all' !== $atts['type'] && ! empty( $atts['type'] ) ) { $filter_type = $atts['type']; $cities = array_filter( $cities, function ( $c ) use ( $filter_type ) { return isset( $c['page_type'] ) && $c['page_type'] === $filter_type; } ); } // Filter by specific group. if ( ! empty( $atts['group'] ) ) { $filter_group = $atts['group']; $cities = array_filter( $cities, function ( $c ) use ( $filter_group ) { return isset( $c['group_name'] ) && $c['group_name'] === $filter_group; } ); } if ( empty( $cities ) ) { return ''; } // Sort by group_order, group_name, then page_title. usort( $cities, function ( $a, $b ) { $ao = intval( $a['group_order'] ?? 0 ); $bo = intval( $b['group_order'] ?? 0 ); if ( $ao !== $bo ) { return $ao - $bo; } $gn = strcasecmp( $a['group_name'] ?? '', $b['group_name'] ?? '' ); if ( 0 !== $gn ) { return $gn; } return strcasecmp( $a['page_title'] ?? '', $b['page_title'] ?? '' ); } ); // Parse settings. $show_groups = 'yes' === $atts['show_groups']; $show_bullets = 'yes' === $atts['show_bullets']; $target = esc_attr( $atts['target'] ); $is_columns = 'columns' === $atts['layout']; $accordion = 'yes' === $atts['accordion'] && $show_groups; $acc_expanded = 'expanded' === $atts['accordion_default']; $acc_mode = $atts['accordion_mode']; $acc_icon = 'yes' === $atts['accordion_icon']; $acc_speed = max( 0, intval( $atts['accordion_speed'] ) ); $show_count = 'yes' === $atts['show_count'] && $show_groups; $show_search = 'yes' === $atts['show_search']; // List style attributes. $list_style = $show_bullets ? '' : ' style="list-style:none;padding-left:0;"'; $from_elementor = 'yes' === $atts['_elementor']; $col_style = ''; if ( $is_columns && ! $from_elementor ) { // Standalone shortcode: apply columns via inline style. // When rendered by the Elementor widget, Elementor's responsive // selectors handle columns + column-gap (supports mobile/tablet // breakpoints), so we must NOT add an inline style that would // override them due to CSS specificity. $columns = max( 1, min( 4, intval( $atts['columns'] ) ) ); $col_style = ' style="columns:' . $columns . ';column-gap:20px;"'; } // Wrapper class and data attributes. $wrapper_class = 'a1tools-city-list-wrapper'; if ( ! empty( $atts['class'] ) ) { $wrapper_class .= ' ' . sanitize_html_class( $atts['class'] ); } $data_attrs = ''; if ( $accordion ) { $data_attrs .= ' data-accordion="1"' . ' data-accordion-mode="' . esc_attr( $acc_mode ) . '"' . ' data-accordion-speed="' . esc_attr( $acc_speed ) . '"'; } if ( $show_search ) { $data_attrs .= ' data-search="1"'; } $output = '
'; // Search bar. if ( $show_search ) { $placeholder = ! empty( $atts['search_placeholder'] ) ? $atts['search_placeholder'] : 'Search cities...'; $output .= '
' . '' . '
'; } // Helper to render a single city link. $render_link = function ( $city ) use ( $atts, $target ) { $label = isset( $city['page_title'] ) ? $city['page_title'] : ''; if ( 'yes' === $atts['append_state'] && ! empty( $atts['state'] ) ) { $label .= ', ' . $atts['state']; } return '
  • ' . esc_html( $label ) . '
  • '; }; if ( $show_groups ) { // Group cities by group_name. $groups = array(); $ungrouped = array(); foreach ( $cities as $city ) { $gn = isset( $city['group_name'] ) ? $city['group_name'] : ''; if ( ! empty( $gn ) ) { if ( ! isset( $groups[ $gn ] ) ) { $groups[ $gn ] = array(); } $groups[ $gn ][] = $city; } else { $ungrouped[] = $city; } } // Render each group. foreach ( $groups as $group_name => $group_cities ) { $city_count = count( $group_cities ); $output .= '
    '; // Group heading (with optional accordion + count). $h_class = 'a1tools-city-group-heading'; $h_attrs = ''; if ( $accordion ) { $h_class .= ' a1tools-accordion-toggle'; $h_attrs = ' role="button" tabindex="0" aria-expanded="' . ( $acc_expanded ? 'true' : 'false' ) . '"'; } $output .= '

    '; if ( $accordion && $acc_icon ) { $output .= ''; } $output .= esc_html( $group_name ); if ( $show_count ) { $output .= ' (' . intval( $city_count ) . ')'; } $output .= '

    '; // Wrap list in accordion content div when accordion is enabled. if ( $accordion ) { $display = $acc_expanded ? '' : ' style="display:none;"'; $output .= '
    '; } $output .= '
      '; foreach ( $group_cities as $city ) { $output .= $render_link( $city ); } $output .= '
    '; if ( $accordion ) { $output .= '
    '; // .a1tools-accordion-content } $output .= '
    '; // .a1tools-city-group } // Render ungrouped at the end with "Ungrouped" heading. if ( ! empty( $ungrouped ) ) { $ug_count = count( $ungrouped ); $output .= '
    '; // Heading (with optional accordion + count, same as grouped). $ug_class = 'a1tools-city-group-heading'; $ug_attrs = ''; if ( $accordion ) { $ug_class .= ' a1tools-accordion-toggle'; $ug_attrs = ' role="button" tabindex="0" aria-expanded="' . ( $acc_expanded ? 'true' : 'false' ) . '"'; } $output .= '

    '; if ( $accordion && $acc_icon ) { $output .= ''; } $output .= esc_html__( 'Ungrouped', 'a1-tools' ); if ( $show_count ) { $output .= ' (' . intval( $ug_count ) . ')'; } $output .= '

    '; if ( $accordion ) { $display = $acc_expanded ? '' : ' style="display:none;"'; $output .= '
    '; } $output .= '
      '; foreach ( $ungrouped as $city ) { $output .= $render_link( $city ); } $output .= '
    '; if ( $accordion ) { $output .= '
    '; } $output .= '
    '; } } else { // Flat list. $output .= '
      '; foreach ( $cities as $city ) { $output .= $render_link( $city ); } $output .= '
    '; } $output .= '
    '; // Base styles (output once per page via static flag). static $styles_output = false; if ( ! $styles_output ) { $output .= ''; $styles_output = true; } // Inline JS for accordion + search (output once per page). static $js_output = false; if ( ! $js_output && ( $accordion || $show_search ) ) { $output .= ''; $js_output = true; } return $output; } add_shortcode( 'a1tools_city_list', 'a1tools_shortcode_city_list' ); // ============================================================================ // FRANCHISE LOCATION & STORE LOCATOR SHORTCODES // ============================================================================ /** * Shortcode: [a1tools_franchise] * Outputs franchise location cards with name, phone, address, and map embed. * Supports multiple franchises per site in a responsive grid layout. * * Attributes: * show_phone - yes/no (default: yes) * show_address - yes/no (default: yes) * show_map - yes/no (default: yes) * show_directions - yes/no (default: yes) * map_height - pixels (default: 300) * map_zoom - 1-20 (default: 15) * columns - 1-3 grid columns (default: 1) * id - show only a specific franchise by ID (optional) * class - custom CSS class (default: a1tools-franchise-card) * * @since 1.7.0 * @updated 1.7.1 Multi-franchise support with grid layout. * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_franchise( $atts ) { $atts = shortcode_atts( array( 'show_title' => 'yes', 'show_phone' => 'yes', 'show_address' => 'yes', 'show_map' => 'yes', 'show_directions' => 'yes', 'map_height' => '300', 'map_zoom' => '15', 'columns' => '1', 'id' => '', 'class' => 'a1tools-franchise-card', ), $atts, 'a1tools_franchise' ); $franchises = a1tools_get_site_franchises(); if ( empty( $franchises ) ) { return ''; } // If a specific franchise ID is requested, filter to just that one. if ( ! empty( $atts['id'] ) ) { $target_id = intval( $atts['id'] ); $franchises = array_filter( $franchises, function ( $f ) use ( $target_id ) { return intval( $f['id'] ) === $target_id; } ); if ( empty( $franchises ) ) { return ''; } } $columns = max( 1, min( 3, intval( $atts['columns'] ) ) ); // Grid wrapper. $output = '
    '; foreach ( $franchises as $franchise ) { $name = isset( $franchise['name'] ) ? $franchise['name'] : ''; $phone = isset( $franchise['phone'] ) ? $franchise['phone'] : ''; $lat = isset( $franchise['latitude'] ) ? $franchise['latitude'] : ''; $lng = isset( $franchise['longitude'] ) ? $franchise['longitude'] : ''; $maps_url = isset( $franchise['google_maps_url'] ) ? $franchise['google_maps_url'] : ''; if ( empty( $name ) ) { continue; } // Build address string. $address_parts = array(); if ( ! empty( $franchise['address_line1'] ) ) { $address_parts[] = $franchise['address_line1']; } if ( ! empty( $franchise['address_line2'] ) ) { $address_parts[] = $franchise['address_line2']; } $city_state_zip = array(); if ( ! empty( $franchise['city'] ) ) { $city_state_zip[] = $franchise['city']; } if ( ! empty( $franchise['state'] ) ) { $city_state_zip[] = $franchise['state']; } if ( ! empty( $franchise['zip'] ) ) { $city_state_zip[] = $franchise['zip']; } if ( ! empty( $city_state_zip ) ) { $address_parts[] = implode( ', ', $city_state_zip ); } $full_address = implode( '
    ', array_map( 'esc_html', $address_parts ) ); $output .= '
    '; // Title. if ( 'yes' === $atts['show_title'] ) { $output .= '

    ' . esc_html( $name ) . '

    '; } // Address. if ( 'yes' === $atts['show_address'] && ! empty( $full_address ) ) { $output .= '
    ' . $full_address . '
    '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above via esc_html per part. } // Phone. if ( 'yes' === $atts['show_phone'] && ! empty( $phone ) ) { $phone_clean = preg_replace( '/[^0-9+]/', '', $phone ); $output .= '
    '; $output .= '' . esc_html( $phone ) . ''; $output .= '
    '; } // Map embed — use name + address so Google resolves the actual place & shows info. if ( 'yes' === $atts['show_map'] ) { $embed_parts = array(); if ( ! empty( $name ) ) { $embed_parts[] = $name; } if ( ! empty( $franchise['address_line1'] ) ) { $embed_parts[] = $franchise['address_line1']; } if ( ! empty( $franchise['city'] ) ) { $embed_parts[] = $franchise['city']; } if ( ! empty( $franchise['state'] ) ) { $embed_parts[] = $franchise['state']; } if ( ! empty( $franchise['zip'] ) ) { $embed_parts[] = $franchise['zip']; } $embed_query = implode( ', ', $embed_parts ); // Fallback to lat/lng if no address info available. if ( empty( $embed_query ) && ! empty( $lat ) && ! empty( $lng ) ) { $embed_query = $lat . ',' . $lng; } if ( ! empty( $embed_query ) ) { $embed_url = 'https://maps.google.com/maps?q=' . urlencode( $embed_query ) . '&z=' . esc_attr( $atts['map_zoom'] ) . '&output=embed'; $output .= '
    '; $output .= ''; $output .= '
    '; } } // Get Directions link. if ( 'yes' === $atts['show_directions'] && ! empty( $maps_url ) ) { $output .= ''; } $output .= '
    '; // .a1tools-franchise-card } $output .= '
    '; // .a1tools-franchise-grid // Inline styles (output once). $output .= ''; return $output; } add_shortcode( 'a1tools_franchise', 'a1tools_shortcode_franchise' ); /** * Shortcode: [a1tools_store_locator] * Interactive store locator with Google Maps JS API, custom markers, and split-panel layout. * Falls back to iframe embeds when no Google Maps API key is configured. * * @since 1.7.0 * @updated 1.7.6 Google Maps JS API with custom markers, image cards, professional layout. * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_store_locator( $atts ) { $atts = shortcode_atts( array( 'map_height' => '500', 'show_search' => 'yes', 'show_filters' => 'yes', 'show_images' => 'yes', 'marker_icon' => '', 'api_key' => '', 'class' => 'a1tools-store-locator', ), $atts, 'a1tools_store_locator' ); $stores = a1tools_get_group_stores(); if ( empty( $stores ) ) { return '

    ' . esc_html__( 'No store locations found.', 'a1-tools' ) . '

    '; } // Resolve API key and marker icon (shortcode attr > WP option > empty). $api_key = ! empty( $atts['api_key'] ) ? $atts['api_key'] : get_option( 'a1tools_google_maps_api_key', '' ); $marker_icon = ! empty( $atts['marker_icon'] ) ? $atts['marker_icon'] : get_option( 'a1tools_marker_icon_url', '' ); $use_js_api = ! empty( $api_key ); // Collect unique states for filter dropdown. $states = array(); foreach ( $stores as $store ) { if ( ! empty( $store['state'] ) && ! in_array( $store['state'], $states, true ) ) { $states[] = $store['state']; } } sort( $states ); // Build JSON data for JavaScript. $stores_json = array(); foreach ( $stores as $store ) { $addr_parts = array(); if ( ! empty( $store['address_line1'] ) ) { $addr_parts[] = $store['address_line1']; } if ( ! empty( $store['address_line2'] ) ) { $addr_parts[] = $store['address_line2']; } $csz = array(); if ( ! empty( $store['city'] ) ) { $csz[] = $store['city']; } if ( ! empty( $store['state'] ) ) { $csz[] = $store['state']; } if ( ! empty( $store['zip'] ) ) { $csz[] = $store['zip']; } if ( ! empty( $csz ) ) { $addr_parts[] = implode( ', ', $csz ); } $stores_json[] = array( 'id' => intval( $store['id'] ?? 0 ), 'name' => $store['name'] ?? '', 'address' => implode( ', ', $addr_parts ), 'city' => strtolower( $store['city'] ?? '' ), 'state' => strtolower( $store['state'] ?? '' ), 'zip' => $store['zip'] ?? '', 'phone' => $store['phone'] ?? '', 'email' => $store['email'] ?? '', 'website' => $store['website_url'] ?? '', 'mapsUrl' => $store['google_maps_url'] ?? '', 'image' => $store['image_url'] ?? '', 'lat' => ! empty( $store['latitude'] ) ? floatval( $store['latitude'] ) : null, 'lng' => ! empty( $store['longitude'] ) ? floatval( $store['longitude'] ) : null, ); } $unique_id = 'a1sl-' . wp_unique_id(); $map_height = intval( $atts['map_height'] ); $output = '
    '; // ── Search & Filters ── if ( 'yes' === $atts['show_search'] || 'yes' === $atts['show_filters'] ) { $output .= '
    '; if ( 'yes' === $atts['show_search'] ) { $output .= '
    '; $output .= ''; $output .= ''; $output .= '
    '; } if ( 'yes' === $atts['show_filters'] && ! empty( $states ) ) { $output .= ''; } $output .= '
    '; } // ── Two-panel layout ── $output .= '
    '; // Left: Store cards (scrollable). $output .= '
    '; $output .= '
    ' . esc_html__( 'Results', 'a1-tools' ) . '
    '; foreach ( $stores_json as $idx => $s ) { $data_attrs = ' data-idx="' . $idx . '"' . ' data-name="' . esc_attr( strtolower( $s['name'] ) ) . '"' . ' data-city="' . esc_attr( $s['city'] ) . '"' . ' data-state="' . esc_attr( $s['state'] ) . '"' . ' data-zip="' . esc_attr( $s['zip'] ) . '"'; $output .= '
    '; // Store image. if ( 'yes' === $atts['show_images'] && ! empty( $s['image'] ) ) { $output .= '
    ' . esc_attr( $s['name'] ) . '
    '; } $output .= '
    '; $output .= '

    ' . esc_html( $s['name'] ) . '

    '; if ( ! empty( $s['address'] ) ) { $output .= '

    ' . esc_html( $s['address'] ) . '

    '; } if ( ! empty( $s['phone'] ) ) { $phone_clean = preg_replace( '/[^0-9+]/', '', $s['phone'] ); $output .= '

    ' . esc_html__( 'Phone:', 'a1-tools' ) . ' ' . esc_html( $s['phone'] ) . '

    '; } if ( ! empty( $s['email'] ) ) { $output .= '

    ' . esc_html__( 'Email:', 'a1-tools' ) . ' ' . esc_html( $s['email'] ) . '

    '; } // Links. $dir_url = ''; if ( ! empty( $s['mapsUrl'] ) ) { $dir_url = $s['mapsUrl']; } elseif ( null !== $s['lat'] && null !== $s['lng'] ) { $dir_url = 'https://www.google.com/maps/search/?api=1&query=' . $s['lat'] . ',' . $s['lng']; } $output .= ''; $output .= '
    '; // .card-content $output .= '
    '; // .card } $output .= '
    '; // .a1tools-sl-list // Right: Interactive map. $output .= '
    '; $output .= '
    '; $output .= '
    '; $output .= '
    '; // .a1tools-sl-layout $output .= '
    '; // .a1tools-store-locator // ── Inline styles ── $output .= ''; // ── JavaScript ── $stores_js = wp_json_encode( $stores_json ); $marker_js = $marker_icon ? "'" . esc_js( $marker_icon ) . "'" : 'null'; if ( $use_js_api ) { // Enqueue Google Maps JS API (only once per page). $output .= ''; } $output .= ''; return $output; } add_shortcode( 'a1tools_store_locator', 'a1tools_shortcode_store_locator' ); // ============================================================================ // SERVICES, REVIEWS, BEFORE/AFTER, TEAM & FAQ SHORTCODES // ============================================================================ /** * Shortcode: [a1tools_services] * Outputs a grid or list of service cards with title, description, phone, and icon/image. * * Merges local WP service pages (by "Large Service" authors) with API data, * following the same merge pattern as the city list shortcode. * * @since 1.9.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_services( $atts ) { $atts = shortcode_atts( array( 'layout' => 'grid', 'columns' => '3', 'show_description' => 'yes', 'show_icon' => 'yes', 'show_image' => 'yes', 'show_phone' => 'yes', 'featured_only' => 'no', 'target' => '_self', 'class' => '', '_elementor' => '', ), $atts, 'a1tools_services' ); // Get ALL WordPress service pages from local WP database. $wp_service_pages = a1tools_get_wp_service_pages(); // Get saved data from the A1 Tools API (description, icon, phone, image). $saved_data = a1tools_get_site_services(); if ( ! is_array( $saved_data ) ) { $saved_data = array(); } // Merge: start with all WP pages, overlay saved data. $base_pages = ! empty( $wp_service_pages ) ? $wp_service_pages : $saved_data; if ( empty( $base_pages ) ) { return ''; } $services = array(); foreach ( $base_pages as $wp_page_id => $wp_data ) { $permalink = get_permalink( intval( $wp_page_id ) ); if ( empty( $permalink ) ) { continue; } // Overlay saved data if this service has been edited in the app. if ( ! empty( $wp_service_pages ) && isset( $saved_data[ $wp_page_id ] ) ) { $wp_data = array_merge( $wp_data, $saved_data[ $wp_page_id ] ); } $wp_data['wp_page_id'] = $wp_page_id; $wp_data['permalink'] = $permalink; $services[] = $wp_data; } // Filter featured only. if ( 'yes' === $atts['featured_only'] ) { $services = array_filter( $services, function ( $s ) { return ! empty( $s['featured'] ); } ); } if ( empty( $services ) ) { return ''; } // Sort alphabetically by page_title. usort( $services, function ( $a, $b ) { return strcasecmp( $a['page_title'] ?? '', $b['page_title'] ?? '' ); } ); // Parse settings. $show_desc = 'yes' === $atts['show_description']; $show_icon = 'yes' === $atts['show_icon']; $show_image = 'yes' === $atts['show_image']; $show_phone = 'yes' === $atts['show_phone']; $target = esc_attr( $atts['target'] ); $is_list = 'list' === $atts['layout']; $columns = max( 1, min( 4, intval( $atts['columns'] ) ) ); $from_elementor = 'yes' === $atts['_elementor']; $wrapper_class = 'a1tools-services-grid'; if ( $is_list ) { $wrapper_class = 'a1tools-services-list'; } if ( ! empty( $atts['class'] ) ) { $wrapper_class .= ' ' . sanitize_html_class( $atts['class'] ); } $grid_style = ''; if ( ! $is_list && ! $from_elementor ) { $grid_style = ' style="grid-template-columns:repeat(' . $columns . ',1fr);"'; } $output = '
    '; foreach ( $services as $service ) { $title = isset( $service['page_title'] ) ? $service['page_title'] : ''; $output .= '
    '; // Icon or image. if ( $show_image && ! empty( $service['image_url'] ) ) { $output .= '
    ' . esc_attr( $title ) . '
    '; } elseif ( $show_icon && ! empty( $service['icon'] ) ) { $output .= '
    ' . wp_kses_post( $service['icon'] ) . '
    '; } // Title as link. $output .= '

    ' . esc_html( $title ) . '

    '; // Description. if ( $show_desc && ! empty( $service['description'] ) ) { $output .= '

    ' . esc_html( $service['description'] ) . '

    '; } // Phone. if ( $show_phone && ! empty( $service['phone'] ) ) { $output .= '

    ' . esc_html( $service['phone'] ) . '

    '; } $output .= '
    '; // .a1tools-service-card } $output .= '
    '; // Inline CSS (output once per page). static $services_styles_output = false; if ( ! $services_styles_output ) { $output .= ''; $services_styles_output = true; } return $output; } add_shortcode( 'a1tools_services', 'a1tools_shortcode_services' ); /** * Shortcode: [a1tools_reviews] * Outputs a grid of review cards with star rating, reviewer name, review text, and source badge. * * @since 1.9.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_reviews( $atts ) { $atts = shortcode_atts( array( 'layout' => 'grid', 'columns' => '3', 'show_stars' => 'yes', 'show_source' => 'yes', 'show_date' => 'yes', 'show_image' => 'yes', 'featured_only' => 'no', 'limit' => '0', 'class' => '', ), $atts, 'a1tools_reviews' ); $reviews = a1tools_get_group_reviews(); if ( empty( $reviews ) || ! is_array( $reviews ) ) { return ''; } // Filter featured only. if ( 'yes' === $atts['featured_only'] ) { $reviews = array_filter( $reviews, function ( $r ) { return ! empty( $r['featured'] ); } ); } // Apply limit. $limit = intval( $atts['limit'] ); if ( $limit > 0 ) { $reviews = array_slice( $reviews, 0, $limit ); } if ( empty( $reviews ) ) { return ''; } // Parse settings. $show_stars = 'yes' === $atts['show_stars']; $show_source = 'yes' === $atts['show_source']; $show_date = 'yes' === $atts['show_date']; $show_image = 'yes' === $atts['show_image']; $columns = max( 1, min( 4, intval( $atts['columns'] ) ) ); $wrapper_class = 'a1tools-reviews-grid'; if ( ! empty( $atts['class'] ) ) { $wrapper_class .= ' ' . sanitize_html_class( $atts['class'] ); } $output = '
    '; foreach ( $reviews as $review ) { $output .= '
    '; // Reviewer image. if ( $show_image && ! empty( $review['image_url'] ) ) { $output .= '
    ' . esc_attr( $review['name'] ?? '' ) . '
    '; } // Star rating. if ( $show_stars && isset( $review['rating'] ) ) { $rating = max( 0, min( 5, intval( $review['rating'] ) ) ); $stars = str_repeat( "\xE2\x98\x85", $rating ) . str_repeat( "\xE2\x98\x86", 5 - $rating ); $output .= '
    ' . $stars . '
    '; } // Reviewer name. if ( ! empty( $review['name'] ) ) { $output .= '

    ' . esc_html( $review['name'] ) . '

    '; } // Review text. if ( ! empty( $review['text'] ) ) { $output .= '

    ' . esc_html( $review['text'] ) . '

    '; } // Source badge. if ( $show_source && ! empty( $review['source'] ) ) { $output .= '' . esc_html( $review['source'] ) . ''; } // Date. if ( $show_date && ! empty( $review['date'] ) ) { $output .= '' . esc_html( $review['date'] ) . ''; } $output .= '
    '; // .a1tools-review-card } $output .= '
    '; // Inline CSS (output once per page). static $reviews_styles_output = false; if ( ! $reviews_styles_output ) { $output .= ''; $reviews_styles_output = true; } return $output; } add_shortcode( 'a1tools_reviews', 'a1tools_shortcode_reviews' ); /** * Shortcode: [a1tools_before_after] * Outputs before/after comparison items as interactive sliders or side-by-side grid. * * Slider mode uses CSS clip-path with a JS drag handle for interactive comparison. * Grid mode shows before/after image pairs side by side with labels. * * @since 1.9.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_before_after( $atts ) { $atts = shortcode_atts( array( 'layout' => 'slider', 'columns' => '2', 'show_title' => 'yes', 'show_description' => 'yes', 'class' => '', ), $atts, 'a1tools_before_after' ); $items = a1tools_get_group_before_after(); if ( empty( $items ) || ! is_array( $items ) ) { return ''; } $show_title = 'yes' === $atts['show_title']; $show_desc = 'yes' === $atts['show_description']; $is_slider = 'slider' === $atts['layout']; $columns = max( 1, min( 4, intval( $atts['columns'] ) ) ); $wrapper_class = $is_slider ? 'a1tools-ba-slider-wrapper' : 'a1tools-ba-grid-wrapper'; if ( ! empty( $atts['class'] ) ) { $wrapper_class .= ' ' . sanitize_html_class( $atts['class'] ); } $grid_style = ''; if ( ! $is_slider ) { $grid_style = ' style="grid-template-columns:repeat(' . $columns . ',1fr);"'; } $output = '
    '; foreach ( $items as $index => $item ) { $before_url = isset( $item['before_image'] ) ? $item['before_image'] : ''; $after_url = isset( $item['after_image'] ) ? $item['after_image'] : ''; $title = isset( $item['title'] ) ? $item['title'] : ''; if ( empty( $before_url ) || empty( $after_url ) ) { continue; } if ( $is_slider ) { // Slider mode: interactive comparison slider. $output .= '
    '; if ( $show_title && ! empty( $title ) ) { $output .= '

    ' . esc_html( $title ) . '

    '; } $output .= '
    ' . '
    After
    ' . '
    Before
    ' . '
    ' . '
    '; if ( $show_desc && ! empty( $item['description'] ) ) { $output .= '

    ' . esc_html( $item['description'] ) . '

    '; } $output .= '
    '; } else { // Grid mode: side-by-side image pairs. $output .= '
    '; if ( $show_title && ! empty( $title ) ) { $output .= '

    ' . esc_html( $title ) . '

    '; } $output .= '
    ' . '
    BeforeBefore
    ' . '
    AfterAfter
    ' . '
    '; if ( $show_desc && ! empty( $item['description'] ) ) { $output .= '

    ' . esc_html( $item['description'] ) . '

    '; } $output .= '
    '; } } $output .= '
    '; // Inline CSS (output once per page). static $ba_styles_output = false; if ( ! $ba_styles_output ) { $output .= ''; $ba_styles_output = true; } // Inline JS for slider interaction (output once per page). static $ba_js_output = false; if ( ! $ba_js_output && $is_slider ) { $output .= ''; $ba_js_output = true; } return $output; } add_shortcode( 'a1tools_before_after', 'a1tools_shortcode_before_after' ); /** * Shortcode: [a1tools_team] * Outputs a grid or list of team member cards with photo, name, role, bio, contact info, * and certification badges. * * @since 1.9.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_team( $atts ) { $atts = shortcode_atts( array( 'layout' => 'grid', 'columns' => '3', 'show_photo' => 'yes', 'show_role' => 'yes', 'show_bio' => 'yes', 'show_phone' => 'yes', 'show_email' => 'yes', 'show_certifications' => 'yes', 'featured_only' => 'no', 'category' => '', 'category_order' => '', 'show_category_heading' => 'yes', 'class' => '', ), $atts, 'a1tools_team' ); $members = a1tools_get_group_team(); if ( empty( $members ) || ! is_array( $members ) ) { return ''; } // Filter featured only. if ( 'yes' === $atts['featured_only'] ) { $members = array_filter( $members, function ( $m ) { return ! empty( $m['featured'] ); } ); } if ( empty( $members ) ) { return ''; } // Filter by category if specified. if ( ! empty( $atts['category'] ) ) { $cat_filter = strtolower( trim( $atts['category'] ) ); $members = array_filter( $members, function ( $m ) use ( $cat_filter ) { return isset( $m['category'] ) && strtolower( trim( $m['category'] ) ) === $cat_filter; } ); } if ( empty( $members ) ) { return ''; } // Parse settings. $show_photo = 'yes' === $atts['show_photo']; $show_role = 'yes' === $atts['show_role']; $show_bio = 'yes' === $atts['show_bio']; $show_phone = 'yes' === $atts['show_phone']; $show_email = 'yes' === $atts['show_email']; $show_certs = 'yes' === $atts['show_certifications']; $is_list = 'list' === $atts['layout']; $columns = max( 1, min( 4, intval( $atts['columns'] ) ) ); $show_cat_heading = 'yes' === $atts['show_category_heading']; $category_order = array_filter( array_map( 'trim', explode( ',', $atts['category_order'] ) ) ); $wrapper_class = $is_list ? 'a1tools-team-list' : 'a1tools-team-grid'; if ( ! empty( $atts['class'] ) ) { $wrapper_class .= ' ' . sanitize_html_class( $atts['class'] ); } $grid_style = ''; if ( ! $is_list ) { $grid_style = ' style="grid-template-columns:repeat(' . $columns . ',1fr);"'; } // Group members by category. $grouped = array(); foreach ( $members as $member ) { $cat = ( ! empty( $member['category'] ) ) ? trim( $member['category'] ) : 'Uncategorized'; if ( ! isset( $grouped[ $cat ] ) ) { $grouped[ $cat ] = array(); } $grouped[ $cat ][] = $member; } // Sort categories: use category_order if provided, else by category_sort_order, else alphabetical. if ( ! empty( $category_order ) ) { $sorted_groups = array(); foreach ( $category_order as $cat_name ) { if ( isset( $grouped[ $cat_name ] ) ) { $sorted_groups[ $cat_name ] = $grouped[ $cat_name ]; unset( $grouped[ $cat_name ] ); } } // Append remaining categories. foreach ( $grouped as $cat_name => $cat_members ) { $sorted_groups[ $cat_name ] = $cat_members; } $grouped = $sorted_groups; } else { // Sort by category_sort_order from the first member in each group. uksort( $grouped, function ( $a, $b ) use ( $grouped ) { if ( 'Uncategorized' === $a ) { return 1; } if ( 'Uncategorized' === $b ) { return -1; } $a_order = isset( $grouped[ $a ][0]['category_sort_order'] ) ? intval( $grouped[ $a ][0]['category_sort_order'] ) : 0; $b_order = isset( $grouped[ $b ][0]['category_sort_order'] ) ? intval( $grouped[ $b ][0]['category_sort_order'] ) : 0; if ( $a_order !== $b_order ) { return $a_order - $b_order; } return strcasecmp( $a, $b ); } ); } $output = '
    '; $only_uncategorized = ( count( $grouped ) === 1 && isset( $grouped['Uncategorized'] ) ); foreach ( $grouped as $category_name => $cat_members ) { // Category heading. if ( $show_cat_heading && ! $only_uncategorized && 'Uncategorized' !== $category_name ) { $output .= '

    ' . esc_html( $category_name ) . '

    '; } $output .= '
    '; foreach ( $cat_members as $member ) { $name = isset( $member['name'] ) ? $member['name'] : ''; $output .= '
    '; // Photo. if ( $show_photo ) { if ( ! empty( $member['photo_url'] ) ) { $output .= '
    ' . esc_attr( $name ) . '
    '; } else { $initials = ''; $name_parts = explode( ' ', $name ); foreach ( $name_parts as $part ) { if ( ! empty( $part ) ) { $initials .= mb_strtoupper( mb_substr( $part, 0, 1 ) ); } } $output .= '
    ' . esc_html( $initials ) . '
    '; } } // Name. $output .= '

    ' . esc_html( $name ) . '

    '; // Role. if ( $show_role && ! empty( $member['role'] ) ) { $output .= '

    ' . esc_html( $member['role'] ) . '

    '; } // Bio. if ( $show_bio && ! empty( $member['bio'] ) ) { $output .= '

    ' . esc_html( $member['bio'] ) . '

    '; } // Contact info. if ( $show_phone && ! empty( $member['phone'] ) ) { $output .= '

    ' . esc_html( $member['phone'] ) . '

    '; } if ( $show_email && ! empty( $member['email'] ) ) { $output .= ''; } // Certifications. if ( $show_certs && ! empty( $member['certifications'] ) && is_array( $member['certifications'] ) ) { $output .= '
    '; foreach ( $member['certifications'] as $cert ) { $output .= '' . esc_html( $cert ) . ''; } $output .= '
    '; } $output .= '
    '; // .a1tools-team-card } $output .= '
    '; // .a1tools-team-grid or .a1tools-team-list } $output .= '
    '; // .a1tools-team-wrapper // Inline CSS (output once per page). static $team_styles_output = false; if ( ! $team_styles_output ) { $output .= ''; $team_styles_output = true; } return $output; } add_shortcode( 'a1tools_team', 'a1tools_shortcode_team' ); /** * Shortcode: [a1tools_faq] * Outputs an accordion of FAQ items with optional category filtering * and FAQPage JSON-LD structured data. * * Reuses the same accordion CSS classes and JS pattern from the city list shortcode. * * @since 1.9.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_faq( $atts ) { $atts = shortcode_atts( array( 'category' => '', 'show_category' => 'yes', 'accordion_icon' => 'yes', 'schema' => 'yes', 'class' => '', ), $atts, 'a1tools_faq' ); $faqs = a1tools_get_group_faq(); if ( empty( $faqs ) || ! is_array( $faqs ) ) { return ''; } // Filter by category. if ( ! empty( $atts['category'] ) ) { $filter_cat = $atts['category']; $faqs = array_filter( $faqs, function ( $f ) use ( $filter_cat ) { return isset( $f['category'] ) && $f['category'] === $filter_cat; } ); } if ( empty( $faqs ) ) { return ''; } $show_cat = 'yes' === $atts['show_category']; $acc_icon = 'yes' === $atts['accordion_icon']; $schema = 'yes' === $atts['schema']; $wrapper_class = 'a1tools-faq-wrapper a1tools-city-list-wrapper'; if ( ! empty( $atts['class'] ) ) { $wrapper_class .= ' ' . sanitize_html_class( $atts['class'] ); } $output = '
    '; // Schema data collector. $schema_items = array(); // Group by category if show_category is enabled. if ( $show_cat ) { $categories = array(); $uncategorized = array(); foreach ( $faqs as $faq ) { $cat = isset( $faq['category'] ) && ! empty( $faq['category'] ) ? $faq['category'] : ''; if ( ! empty( $cat ) ) { $categories[ $cat ][] = $faq; } else { $uncategorized[] = $faq; } } // Render categorized FAQs. foreach ( $categories as $cat_name => $cat_faqs ) { $output .= '
    ' . '

    ' . esc_html( $cat_name ) . '

    '; foreach ( $cat_faqs as $faq ) { $output .= a1tools_render_faq_item( $faq, $acc_icon, $schema_items ); } $output .= '
    '; } // Render uncategorized. foreach ( $uncategorized as $faq ) { $output .= a1tools_render_faq_item( $faq, $acc_icon, $schema_items ); } } else { // Flat list. foreach ( $faqs as $faq ) { $output .= a1tools_render_faq_item( $faq, $acc_icon, $schema_items ); } } $output .= '
    '; // FAQPage JSON-LD schema. if ( $schema && ! empty( $schema_items ) ) { $schema_data = array( '@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => $schema_items, ); $output .= ''; } // Inline CSS for FAQ-specific styles (output once per page). // Reuses accordion classes from city list. static $faq_styles_output = false; if ( ! $faq_styles_output ) { $output .= ''; $faq_styles_output = true; } // Inline JS reuses the city list accordion JS. Output a small init // that delegates to the same pattern (only if city list JS has not loaded). static $faq_js_output = false; if ( ! $faq_js_output ) { $output .= ''; $faq_js_output = true; } return $output; } /** * Helper: Render a single FAQ accordion item. * * @since 1.9.0 * @param array $faq FAQ data with 'question' and 'answer' keys. * @param bool $acc_icon Whether to show accordion icon. * @param array &$schema_items Reference to schema items array for JSON-LD. * @return string HTML for the FAQ item. */ function a1tools_render_faq_item( $faq, $acc_icon, &$schema_items ) { $question = isset( $faq['question'] ) ? $faq['question'] : ''; $answer = isset( $faq['answer'] ) ? $faq['answer'] : ''; if ( empty( $question ) ) { return ''; } // Collect for schema. $schema_items[] = array( '@type' => 'Question', 'name' => $question, 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => $answer, ), ); $html = '
    '; $html .= ''; $html .= ''; $html .= '
    '; return $html; } add_shortcode( 'a1tools_faq', 'a1tools_shortcode_faq' ); // ============================================================================ // REST API ENDPOINTS // ============================================================================ /** * Register REST API routes. * * @since 1.0.0 * @return void */ function a1tools_register_rest_routes() { // Get site variables (public). register_rest_route( 'a1-tools/v1', '/site-variables', array( 'methods' => 'GET', 'callback' => 'a1tools_rest_get_variables', 'permission_callback' => '__return_true', ) ); // Clear cache (requires admin). register_rest_route( 'a1-tools/v1', '/site-variables/clear-cache', array( 'methods' => 'POST', 'callback' => 'a1tools_rest_clear_cache', 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, ) ); // Yoast meta endpoints. register_rest_route( 'a1-tools/v1', '/yoast-meta/(?P\d+)', array( 'methods' => 'POST', 'callback' => 'a1tools_update_yoast_meta', 'permission_callback' => function () { return current_user_can( 'edit_posts' ); }, 'args' => array( 'id' => array( 'description' => 'Post ID', 'type' => 'integer', 'required' => true, 'validate_callback' => function ( $param ) { return is_numeric( $param ) && $param > 0; }, ), ), ) ); register_rest_route( 'a1-tools/v1', '/yoast-meta/(?P\d+)', array( 'methods' => 'GET', 'callback' => 'a1tools_get_yoast_meta', 'permission_callback' => function () { return current_user_can( 'edit_posts' ); }, 'args' => array( 'id' => array( 'description' => 'Post ID', 'type' => 'integer', 'required' => true, 'validate_callback' => function ( $param ) { return is_numeric( $param ) && $param > 0; }, ), ), ) ); } add_action( 'rest_api_init', 'a1tools_register_rest_routes' ); /** * REST: Get site variables. * * @since 1.0.0 * @return WP_REST_Response|WP_Error Response object. */ function a1tools_rest_get_variables() { $variables = a1tools_get_site_variables(); if ( null === $variables ) { return new WP_Error( 'fetch_error', __( 'Could not fetch site variables', 'a1-tools' ), array( 'status' => 500 ) ); } return new WP_REST_Response( array( 'success' => true, 'variables' => $variables, 'cached' => false !== get_transient( A1TOOLS_CACHE_KEY ), ), 200 ); } /** * REST: Clear variables cache. * * @since 1.0.0 * @return WP_REST_Response Response object. */ function a1tools_rest_clear_cache() { a1tools_clear_cache(); return new WP_REST_Response( array( 'success' => true, 'message' => __( 'Cache cleared', 'a1-tools' ), ), 200 ); } // ============================================================================ // YOAST SEO INTEGRATION // ============================================================================ /** * Get Yoast meta field mapping. * * @since 1.0.0 * @return array Mapping of API keys to Yoast meta keys. */ function a1tools_get_yoast_meta_mapping() { return array( 'title' => '_yoast_wpseo_title', 'metadesc' => '_yoast_wpseo_metadesc', 'focuskw' => '_yoast_wpseo_focuskw', 'focuskeywords' => '_yoast_wpseo_focuskeywords', 'keywordsynonyms' => '_yoast_wpseo_keywordsynonyms', 'meta-robots-noindex' => '_yoast_wpseo_meta-robots-noindex', 'meta-robots-nofollow' => '_yoast_wpseo_meta-robots-nofollow', 'meta-robots-adv' => '_yoast_wpseo_meta-robots-adv', 'canonical' => '_yoast_wpseo_canonical', 'redirect' => '_yoast_wpseo_redirect', 'is_cornerstone' => '_yoast_wpseo_is_cornerstone', 'opengraph-title' => '_yoast_wpseo_opengraph-title', 'opengraph-description' => '_yoast_wpseo_opengraph-description', 'opengraph-image' => '_yoast_wpseo_opengraph-image', 'opengraph-image-id' => '_yoast_wpseo_opengraph-image-id', 'twitter-title' => '_yoast_wpseo_twitter-title', 'twitter-description' => '_yoast_wpseo_twitter-description', 'twitter-image' => '_yoast_wpseo_twitter-image', 'twitter-image-id' => '_yoast_wpseo_twitter-image-id', 'schema_page_type' => '_yoast_wpseo_schema_page_type', 'schema_article_type' => '_yoast_wpseo_schema_article_type', 'linkdex' => '_yoast_wpseo_linkdex', 'content_score' => '_yoast_wpseo_content_score', 'primary_category' => '_yoast_wpseo_primary_category', ); } /** * Sanitize Yoast meta value based on field type. * * @since 1.0.0 * @param string $key Field key. * @param mixed $value Value to sanitize. * @return mixed Sanitized value. */ function a1tools_sanitize_yoast_value( $key, $value ) { $json_fields = array( 'focuskeywords', 'keywordsynonyms' ); if ( in_array( $key, $json_fields, true ) ) { if ( is_array( $value ) ) { return wp_json_encode( $value ); } if ( is_string( $value ) ) { $decoded = json_decode( $value ); if ( JSON_ERROR_NONE === json_last_error() ) { return $value; } return wp_json_encode( array( $value ) ); } return ''; } $bool_fields = array( 'is_cornerstone', 'meta-robots-noindex', 'meta-robots-nofollow' ); if ( in_array( $key, $bool_fields, true ) ) { return $value ? '1' : '0'; } $int_fields = array( 'linkdex', 'content_score', 'primary_category', 'opengraph-image-id', 'twitter-image-id' ); if ( in_array( $key, $int_fields, true ) ) { return (int) $value; } $url_fields = array( 'canonical', 'redirect', 'opengraph-image', 'twitter-image' ); if ( in_array( $key, $url_fields, true ) ) { return esc_url_raw( $value ); } $text_fields = array( 'title', 'metadesc', 'opengraph-title', 'opengraph-description', 'twitter-title', 'twitter-description' ); if ( in_array( $key, $text_fields, true ) ) { return sanitize_text_field( $value ); } if ( is_string( $value ) ) { return sanitize_text_field( $value ); } return $value; } /** * Update Yoast SEO meta fields for a post. * * @since 1.0.0 * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error Response object. */ function a1tools_update_yoast_meta( $request ) { $post_id = (int) $request['id']; $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( 'post_not_found', __( 'Post not found', 'a1-tools' ), array( 'status' => 404 ) ); } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new WP_Error( 'forbidden', __( 'You do not have permission to edit this post', 'a1-tools' ), array( 'status' => 403 ) ); } $body = $request->get_json_params(); if ( empty( $body ) ) { return new WP_Error( 'no_data', __( 'No data provided', 'a1-tools' ), array( 'status' => 400 ) ); } $mapping = a1tools_get_yoast_meta_mapping(); $updated = array(); $errors = array(); foreach ( $body as $key => $value ) { if ( isset( $mapping[ $key ] ) ) { $meta_key = $mapping[ $key ]; } elseif ( 0 === strpos( $key, '_yoast_wpseo_' ) ) { $meta_key = $key; } else { continue; } $sanitized_value = a1tools_sanitize_yoast_value( $key, $value ); $result = update_post_meta( $post_id, $meta_key, $sanitized_value ); if ( false !== $result ) { $updated[ $key ] = $sanitized_value; } else { $current_value = get_post_meta( $post_id, $meta_key, true ); if ( $current_value === $sanitized_value || $current_value === (string) $sanitized_value ) { $updated[ $key ] = $sanitized_value; } else { $errors[ $key ] = 'Failed to update'; } } } if ( class_exists( 'WPSEO_Meta' ) ) { // Trigger Yoast SEO's native action hook to refresh their internal cache. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- This is Yoast's hook, not ours. do_action( 'wpseo_saved_postdata' ); } return new WP_REST_Response( array( 'success' => true, 'post_id' => $post_id, 'updated' => $updated, 'errors' => $errors, ), 200 ); } /** * Get Yoast SEO meta fields for a post. * * @since 1.0.0 * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error Response object. */ function a1tools_get_yoast_meta( $request ) { $post_id = (int) $request['id']; $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( 'post_not_found', __( 'Post not found', 'a1-tools' ), array( 'status' => 404 ) ); } $mapping = a1tools_get_yoast_meta_mapping(); $meta = array(); foreach ( $mapping as $api_key => $meta_key ) { $value = get_post_meta( $post_id, $meta_key, true ); if ( '' !== $value && false !== $value ) { $meta[ $api_key ] = $value; } } return new WP_REST_Response( array( 'success' => true, 'post_id' => $post_id, 'meta' => $meta, ), 200 ); } // ============================================================================ // ELEMENTOR INTEGRATION // ============================================================================ /** * Register Elementor dynamic tags. * * @since 1.0.0 * @param \Elementor\Core\DynamicTags\Manager $dynamic_tags_manager Dynamic tags manager. * @return void */ function a1tools_register_elementor_tags( $dynamic_tags_manager ) { // Register dynamic tag group. $dynamic_tags_manager->register_group( 'a1-tools', array( 'title' => __( 'A1 Tools', 'a1-tools' ), ) ); // Include and register the dynamic tag class. require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-elementor-tag.php'; if ( class_exists( 'A1Tools_Elementor_Tag' ) ) { $dynamic_tags_manager->register( new A1Tools_Elementor_Tag() ); } } add_action( 'elementor/dynamic_tags/register', 'a1tools_register_elementor_tags' ); /** * Register Elementor widgets. * * @since 1.2.0 * @param \Elementor\Widgets_Manager $widgets_manager Widgets manager. * @return void */ function a1tools_register_elementor_widgets( $widgets_manager ) { // Include and register the Social Icons widget. require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-elementor-widget.php'; if ( class_exists( 'A1Tools_Social_Icons_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_Social_Icons_Elementor_Widget() ); } // Include and register the Hours widget. require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-hours-widget.php'; if ( class_exists( 'A1Tools_Hours_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_Hours_Elementor_Widget() ); } // Include and register info-box widgets (Phone, Email, Location). require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-info-box-widget.php'; if ( class_exists( 'A1Tools_Phone_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_Phone_Elementor_Widget() ); } if ( class_exists( 'A1Tools_Email_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_Email_Elementor_Widget() ); } if ( class_exists( 'A1Tools_Location_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_Location_Elementor_Widget() ); } // Include and register combined Contact Info widget. require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-contact-info-widget.php'; if ( class_exists( 'A1Tools_Contact_Info_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_Contact_Info_Elementor_Widget() ); } // Include and register CTA button widget. require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-cta-widget.php'; if ( class_exists( 'A1Tools_CTA_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_CTA_Elementor_Widget() ); } // Include and register Franchise Location widget. require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-franchise-widget.php'; if ( class_exists( 'A1Tools_Franchise_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_Franchise_Elementor_Widget() ); } // Include and register Store Locator widget. require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-stores-widget.php'; if ( class_exists( 'A1Tools_Stores_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_Stores_Elementor_Widget() ); } // Include and register City List widget. require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-city-list-widget.php'; if ( class_exists( 'A1Tools_City_List_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_City_List_Elementor_Widget() ); } // Include and register Services widget. require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-services-widget.php'; if ( class_exists( 'A1Tools_Services_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_Services_Elementor_Widget() ); } // Include and register Reviews widget. require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-reviews-widget.php'; if ( class_exists( 'A1Tools_Reviews_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_Reviews_Elementor_Widget() ); } // Include and register Before/After widget. require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-before-after-widget.php'; if ( class_exists( 'A1Tools_Before_After_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_Before_After_Elementor_Widget() ); } // Include and register Team widget. require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-team-widget.php'; if ( class_exists( 'A1Tools_Team_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_Team_Elementor_Widget() ); } // Include and register FAQ widget. require_once A1TOOLS_PLUGIN_DIR . 'includes/class-a1-tools-faq-widget.php'; if ( class_exists( 'A1Tools_FAQ_Elementor_Widget' ) ) { $widgets_manager->register( new A1Tools_FAQ_Elementor_Widget() ); } } add_action( 'elementor/widgets/register', 'a1tools_register_elementor_widgets' ); /** * Register Elementor widget styles. * * Registers the base CSS for the A1 Social Icons Elementor widget as an inline * stylesheet so it loads once and works in both frontend and editor. * * @since 1.4.8 * @return void */ function a1tools_register_elementor_styles() { $css = ' .a1tools-social-icons-styled { display: flex; flex-wrap: wrap; align-items: center; } .a1tools-social-icons-styled .a1tools-social-icon { display: inline-flex; align-items: center; justify-content: center; text-decoration: none !important; transition: all 0.3s ease; box-sizing: border-box; } .a1tools-social-icons-styled .a1tools-social-icon i { color: inherit; line-height: 1; } .a1tools-social-icons-styled .a1tools-social-icon .a1tools-svg-icon { width: 1em; height: 1em; fill: currentColor; } /* Style presets for non-custom styles */ .a1tools-style-default .a1tools-social-icon, .a1tools-style-official .a1tools-social-icon { color: #fff; } .a1tools-style-default .a1tools-social-icon i, .a1tools-style-official .a1tools-social-icon i { color: #fff; } .a1tools-style-outline .a1tools-social-icon { background: transparent; border: 2px solid; } .a1tools-style-minimal .a1tools-social-icon { background: transparent; } .a1tools-style-light .a1tools-social-icon { background: #f5f5f5; } .a1tools-style-dark .a1tools-social-icon { background: #333; color: #fff; } .a1tools-style-dark .a1tools-social-icon i { color: #fff; } /* Hover effects via prefix_class */ .a1tools-hover-scale .a1tools-social-icon:hover { transform: scale(1.1); } .a1tools-hover-lift .a1tools-social-icon:hover { transform: translateY(-3px); box-shadow: 0 4px 12px rgba(0,0,0,0.2); } .a1tools-hover-rotate .a1tools-social-icon:hover { transform: rotate(15deg); } .a1tools-hover-pulse .a1tools-social-icon:hover { animation: a1tools-pulse 0.5s ease; } @keyframes a1tools-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.15); } } '; wp_register_style( 'a1tools-elementor-icons', false, array(), A1TOOLS_VERSION ); wp_enqueue_style( 'a1tools-elementor-icons' ); wp_add_inline_style( 'a1tools-elementor-icons', $css ); } add_action( 'wp_enqueue_scripts', 'a1tools_register_elementor_styles' ); add_action( 'elementor/editor/before_enqueue_styles', 'a1tools_register_elementor_styles' ); add_action( 'elementor/preview/enqueue_styles', 'a1tools_register_elementor_styles' ); /** * Register Hours Elementor widget styles. * * @since 1.5.2 * @return void */ function a1tools_register_hours_styles() { $css = ' .a1tools-hours-widget { box-sizing: border-box; } .a1tools-hours-title { display: flex; align-items: flex-start; } .a1tools-hours-widget[style*="text-align: center"] .a1tools-hours-title, .a1tools-hours-widget[style*="text-align:center"] .a1tools-hours-title { justify-content: center; } .a1tools-hours-widget[style*="text-align: right"] .a1tools-hours-title, .a1tools-hours-widget[style*="text-align:right"] .a1tools-hours-title { justify-content: flex-end; } .a1tools-hours-title-text { font-size: inherit; font-weight: bold; } .a1tools-hours-icon { display: inline-flex; align-items: center; justify-content: center; line-height: 1; } .a1tools-hours-icon i { color: inherit; margin: 0; padding: 0; line-height: 1; width: 1em; height: 1em; display: inline-flex; align-items: center; justify-content: center; } .a1tools-hours-icon svg { fill: currentColor; margin: 0; padding: 0; } .a1tools-hours-row { display: flex; align-items: baseline; gap: 16px; } .a1tools-hours-has-divider .a1tools-hours-row { border-bottom: 1px solid #e0e0e0; } .a1tools-hours-has-divider .a1tools-hours-row:last-child { border-bottom: none; } .a1tools-hours-day { font-weight: 600; flex-shrink: 0; } /* Fixed-width day column so times always align */ .a1tools-hours-layout-list .a1tools-hours-day, .a1tools-hours-layout-table .a1tools-hours-day { min-width: 110px; } /* Inline layout: horizontal flow */ .a1tools-hours-layout-inline .a1tools-hours-list { display: flex; flex-wrap: wrap; gap: 8px 20px; } .a1tools-hours-layout-inline .a1tools-hours-row { gap: 6px; } '; wp_register_style( 'a1tools-elementor-hours', false, array(), A1TOOLS_VERSION ); wp_enqueue_style( 'a1tools-elementor-hours' ); wp_add_inline_style( 'a1tools-elementor-hours', $css ); } add_action( 'wp_enqueue_scripts', 'a1tools_register_hours_styles' ); add_action( 'elementor/editor/before_enqueue_styles', 'a1tools_register_hours_styles' ); add_action( 'elementor/preview/enqueue_styles', 'a1tools_register_hours_styles' ); /** * Register Info Box / Contact Info Elementor widget styles. * * Shared CSS for Phone, Email, Location, and combined Contact Info widgets. * * @since 1.5.3 * @return void */ function a1tools_register_info_box_styles() { $css = ' /* ── Info Box (Phone, Email, Location) ─────────────────── */ .a1tools-info-box { display: flex; align-items: flex-start; box-sizing: border-box; transition: border-color 0.2s ease; } .a1tools-info-box-icon { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; line-height: 1; } .a1tools-info-box-icon i { color: inherit; margin: 0; padding: 0; line-height: 1; width: 1em; height: 1em; display: inline-flex; align-items: center; justify-content: center; } .a1tools-info-box-icon svg { fill: currentColor; margin: 0; padding: 0; } .a1tools-info-box-content { display: flex; flex-direction: column; min-width: 0; } .a1tools-info-box-title { font-weight: 600; line-height: 1.3; } .a1tools-info-box-value { text-decoration: none; color: inherit; word-break: break-word; } /* Link wrapper – make behave like the
    it replaces */ a.a1tools-info-box-link, a.a1tools-info-box-link:visited { text-decoration: none; color: inherit; display: flex; align-items: flex-start; } a.a1tools-info-box-link .a1tools-info-box-icon, a.a1tools-info-box-link .a1tools-info-box-title, a.a1tools-info-box-link .a1tools-info-box-value { transition: color 0.2s ease; } a.a1tools-info-box-link .a1tools-info-box-icon svg { transition: fill 0.2s ease; } /* Hover underline on value only */ a.a1tools-underline-hover:hover .a1tools-info-box-value { text-decoration: underline; } .a1tools-info-box-empty { font-style: italic; opacity: 0.6; } /* ── Contact Info (combined widget) ────────────────────── */ .a1tools-contact-info-widget { display: flex; flex-direction: column; transition: border-color 0.2s ease; } .a1tools-contact-info-item { display: flex; align-items: flex-start; } /* Ensure icons stay top-aligned even if theme/Elementor overrides flex defaults */ .a1tools-contact-info-widget .a1tools-contact-info-item, .a1tools-contact-info-widget a.a1tools-info-box-link { align-items: flex-start; } .a1tools-has-dividers > .a1tools-contact-info-item + .a1tools-contact-info-item { border-top: 1px solid #e0e0e0; padding-top: 20px; } '; wp_register_style( 'a1tools-elementor-info-box', false, array(), A1TOOLS_VERSION ); wp_enqueue_style( 'a1tools-elementor-info-box' ); wp_add_inline_style( 'a1tools-elementor-info-box', $css ); } add_action( 'wp_enqueue_scripts', 'a1tools_register_info_box_styles' ); add_action( 'elementor/editor/before_enqueue_styles', 'a1tools_register_info_box_styles' ); add_action( 'elementor/preview/enqueue_styles', 'a1tools_register_info_box_styles' ); /** * Register CTA button widget styles. * * @since 1.5.9 * @return void */ function a1tools_register_cta_styles() { $css = ' /* ── CTA Button ───────────────────────────────────────── */ .a1tools-cta-wrapper { display: flex; } .a1tools-cta-button { display: inline-flex; align-items: center; justify-content: center; text-decoration: none; cursor: pointer; box-sizing: border-box; transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; line-height: 1; color: inherit; } .a1tools-cta-button:visited { color: inherit; } .a1tools-cta-icon { display: inline-flex; align-items: center; justify-content: center; line-height: 1; } .a1tools-cta-icon i { color: inherit; } .a1tools-cta-icon svg { fill: currentColor; } /* ── CTA Animations (continuous) ──────────────────────── */ .a1tools-anim--pulse { animation: a1ctaPulse 1.5s ease infinite; } .a1tools-anim--heartbeat { animation: a1ctaHeartbeat 1.5s ease infinite; } .a1tools-anim--bounce { animation: a1ctaBounce 1.5s ease infinite; } .a1tools-anim--shake { animation: a1ctaShake 1.5s ease infinite; } .a1tools-anim--glow { animation: a1ctaGlow 1.5s ease infinite; } .a1tools-anim--wiggle { animation: a1ctaWiggle 1.5s ease infinite; } .a1tools-anim--flash { animation: a1ctaFlash 1.5s ease infinite; } .a1tools-anim--swing { animation: a1ctaSwing 1.5s ease infinite; transform-origin: top center; } .a1tools-anim--rubberband { animation: a1ctaRubberBand 1.5s ease infinite; } .a1tools-anim--tada { animation: a1ctaTada 1.5s ease infinite; } /* ── CTA Animations (hover-only) ─────────────────────── */ .a1tools-anim-hover { animation: none; } .a1tools-anim-hover--pulse:hover { animation: a1ctaPulse 1.5s ease infinite; } .a1tools-anim-hover--heartbeat:hover { animation: a1ctaHeartbeat 1.5s ease infinite; } .a1tools-anim-hover--bounce:hover { animation: a1ctaBounce 1.5s ease infinite; } .a1tools-anim-hover--shake:hover { animation: a1ctaShake 1.5s ease infinite; } .a1tools-anim-hover--glow:hover { animation: a1ctaGlow 1.5s ease infinite; } .a1tools-anim-hover--wiggle:hover { animation: a1ctaWiggle 1.5s ease infinite; } .a1tools-anim-hover--flash:hover { animation: a1ctaFlash 1.5s ease infinite; } .a1tools-anim-hover--swing:hover { animation: a1ctaSwing 1.5s ease infinite; transform-origin: top center; } .a1tools-anim-hover--rubberband:hover { animation: a1ctaRubberBand 1.5s ease infinite; } .a1tools-anim-hover--tada:hover { animation: a1ctaTada 1.5s ease infinite; } /* 1. Pulse — gentle scale up/down */ @keyframes a1ctaPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } } /* 2. Heartbeat — double-beat like a heart */ @keyframes a1ctaHeartbeat { 0% { transform: scale(1); } 14% { transform: scale(1.1); } 28% { transform: scale(1); } 42% { transform: scale(1.1); } 70% { transform: scale(1); } 100% { transform: scale(1); } } /* 3. Bounce — hops up and down */ @keyframes a1ctaBounce { 0%, 100% { transform: translateY(0); } 30% { transform: translateY(-8px); } 50% { transform: translateY(0); } 65% { transform: translateY(-4px); } 80% { transform: translateY(0); } } /* 4. Shake — side-to-side */ @keyframes a1ctaShake { 0%, 100% { transform: translateX(0); } 10% { transform: translateX(-5px); } 20% { transform: translateX(5px); } 30% { transform: translateX(-5px); } 40% { transform: translateX(5px); } 50% { transform: translateX(0); } } /* 5. Glow — pulsing box-shadow (uses --a1-glow-color CSS var) */ .a1tools-cta-button { --a1-glow-color: #ffffff; } @keyframes a1ctaGlow { 0%, 100% { box-shadow: 0 0 5px var(--a1-glow-color); opacity: 0.9; } 50% { box-shadow: 0 0 20px var(--a1-glow-color), 0 0 40px var(--a1-glow-color); opacity: 1; } } /* 6. Wiggle — rotates back and forth */ @keyframes a1ctaWiggle { 0%, 100% { transform: rotate(0deg); } 15% { transform: rotate(-4deg); } 30% { transform: rotate(4deg); } 45% { transform: rotate(-4deg); } 60% { transform: rotate(4deg); } 75% { transform: rotate(0deg); } } /* 7. Flash — opacity blink */ @keyframes a1ctaFlash { 0%, 50%, 100% { opacity: 1; } 25%, 75% { opacity: 0.4; } } /* 8. Swing — pendulum from top */ @keyframes a1ctaSwing { 0%, 100% { transform: rotate(0deg); } 20% { transform: rotate(8deg); } 40% { transform: rotate(-6deg); } 60% { transform: rotate(4deg); } 80% { transform: rotate(-2deg); } } /* 9. Rubber Band — stretch and snap */ @keyframes a1ctaRubberBand { 0%, 100% { transform: scaleX(1) scaleY(1); } 30% { transform: scaleX(1.15) scaleY(0.9); } 40% { transform: scaleX(0.9) scaleY(1.1); } 50% { transform: scaleX(1.05) scaleY(0.95); } 65% { transform: scaleX(0.98) scaleY(1.02); } 75% { transform: scaleX(1); } } /* 10. Tada — scale + rotation for attention */ @keyframes a1ctaTada { 0%, 100% { transform: scale(1) rotate(0deg); } 10% { transform: scale(0.95) rotate(-3deg); } 20% { transform: scale(0.95) rotate(-3deg); } 30% { transform: scale(1.08) rotate(3deg); } 40% { transform: scale(1.08) rotate(-3deg); } 50% { transform: scale(1.08) rotate(3deg); } 60% { transform: scale(1.08) rotate(-3deg); } 70% { transform: scale(1.08) rotate(3deg); } 80% { transform: scale(1) rotate(0deg); } } '; wp_register_style( 'a1tools-elementor-cta', false, array(), A1TOOLS_VERSION ); wp_enqueue_style( 'a1tools-elementor-cta' ); wp_add_inline_style( 'a1tools-elementor-cta', $css ); } add_action( 'wp_enqueue_scripts', 'a1tools_register_cta_styles' ); add_action( 'elementor/editor/before_enqueue_styles', 'a1tools_register_cta_styles' ); add_action( 'elementor/preview/enqueue_styles', 'a1tools_register_cta_styles' ); // ============================================================================ // ADMIN NOTICES // ============================================================================ /** * Display admin notice if site variables cannot be fetched. * * @since 1.0.0 * @return void */ function a1tools_admin_notices() { // Only show on plugins page. $screen = get_current_screen(); if ( ! $screen || 'plugins' !== $screen->id ) { return; } $variables = a1tools_get_site_variables(); if ( null === $variables ) { ?>

    'integer', 'default' => A1TOOLS_DEFAULT_CACHE_EXPIRY, 'sanitize_callback' => 'absint', ) ); // Form submission integration settings. register_setting( 'a1tools_settings', 'a1tools_form_enabled', array( 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'rest_sanitize_boolean', ) ); register_setting( 'a1tools_settings', 'a1tools_form_secret', array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ) ); register_setting( 'a1tools_settings', 'a1tools_form_board_id', array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ) ); register_setting( 'a1tools_settings', 'a1tools_form_group_id', array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ) ); // Google Maps JavaScript API key for interactive store locator. register_setting( 'a1tools_settings', 'a1tools_google_maps_api_key', array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ) ); // Custom marker icon URL for the store locator map. register_setting( 'a1tools_settings', 'a1tools_marker_icon_url', array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'esc_url_raw', ) ); } add_action( 'admin_init', 'a1tools_register_settings' ); /** * Enqueue admin styles and scripts. * * @since 1.3.2 * @param string $hook The current admin page. * @return void */ function a1tools_admin_enqueue_scripts( $hook ) { // Only load on our settings page. if ( 'toplevel_page_a1-tools' !== $hook ) { return; } // Register and enqueue admin styles. wp_register_style( 'a1tools-admin', false, array(), A1TOOLS_VERSION ); wp_enqueue_style( 'a1tools-admin' ); $admin_css = ' .a1tools-shortcode-cell { display: flex; align-items: center; gap: 8px; } .a1tools-shortcode-cell code { font-size: 11px; flex: 1; } .a1tools-copy-btn { background: none; border: 1px solid #ddd; border-radius: 4px; padding: 4px 8px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: #666; transition: all 0.2s ease; } .a1tools-copy-btn:hover { background: #f0f0f0; border-color: #999; color: #333; } .a1tools-copy-btn.copied { background: #46b450; border-color: #46b450; color: #fff; } .a1tools-copy-btn .dashicons { font-size: 14px; width: 14px; height: 14px; } '; wp_add_inline_style( 'a1tools-admin', $admin_css ); // Register and enqueue admin scripts. wp_register_script( 'a1tools-admin', false, array(), A1TOOLS_VERSION, true ); wp_enqueue_script( 'a1tools-admin' ); $admin_js = ' function a1toolsGenerateSecret() { var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; var secret = ""; for (var i = 0; i < 48; i++) { secret += chars.charAt(Math.floor(Math.random() * chars.length)); } document.getElementById("a1tools_form_secret").value = secret; } function a1toolsCopyShortcode(btn, shortcode) { navigator.clipboard.writeText(shortcode).then(function() { btn.classList.add("copied"); btn.innerHTML = \' Copied!\'; setTimeout(function() { btn.classList.remove("copied"); btn.innerHTML = \' Copy\'; }, 2000); }).catch(function(err) { var textarea = document.createElement("textarea"); textarea.value = shortcode; document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); document.body.removeChild(textarea); btn.classList.add("copied"); btn.innerHTML = \' Copied!\'; setTimeout(function() { btn.classList.remove("copied"); btn.innerHTML = \' Copy\'; }, 2000); }); } '; wp_add_inline_script( 'a1tools-admin', $admin_js ); } add_action( 'admin_enqueue_scripts', 'a1tools_admin_enqueue_scripts' ); /** * Render settings page. * * @since 1.0.9 * @return void */ function a1tools_settings_page() { // Handle cache clear action. if ( isset( $_POST['a1tools_clear_cache'] ) && check_admin_referer( 'a1tools_clear_cache_nonce' ) ) { a1tools_clear_cache(); echo '

    ' . esc_html__( 'Cache cleared successfully!', 'a1-tools' ) . '

    '; } $cache_expiry = (int) get_option( 'a1tools_cache_expiry', A1TOOLS_DEFAULT_CACHE_EXPIRY ); $cached_data = get_transient( A1TOOLS_CACHE_KEY ); $is_cached = false !== $cached_data; ?>





    array( 'business_name' => array( 'label' => 'Business Name', 'shortcode' => '[a1tools_var key="business_name"]' ), 'city_name' => array( 'label' => 'City Name', 'shortcode' => '[a1tools_city_name]' ), 'city_url' => array( 'label' => 'City Link', 'shortcode' => '[a1tools_city_link]' ), 'location_name' => array( 'label' => 'State', 'shortcode' => '[a1tools_state]' ), 'state_url' => array( 'label' => 'State Link', 'shortcode' => '[a1tools_state_link]' ), 'tagline' => array( 'label' => 'Tagline', 'shortcode' => '[a1tools_var key="tagline"]' ), 'google_maps_url' => array( 'label' => 'Google Maps URL', 'shortcode' => '[a1tools_google_map]' ), ), 'Contact Information' => array( 'phone_primary' => array( 'label' => 'Primary Phone', 'shortcode' => '[a1tools_var key="phone_primary"]' ), 'phone_secondary' => array( 'label' => 'Secondary Phone', 'shortcode' => '[a1tools_var key="phone_secondary"]' ), 'email_primary' => array( 'label' => 'Primary Email', 'shortcode' => '[a1tools_var key="email_primary"]' ), 'email_secondary' => array( 'label' => 'Secondary Email', 'shortcode' => '[a1tools_var key="email_secondary"]' ), ), 'Address' => array( 'address_line1' => array( 'label' => 'Address Line 1', 'shortcode' => '[a1tools_var key="address_line1"]' ), 'address_line2' => array( 'label' => 'Address Line 2', 'shortcode' => '[a1tools_var key="address_line2"]' ), 'city' => array( 'label' => 'City', 'shortcode' => '[a1tools_var key="city"]' ), 'state' => array( 'label' => 'State', 'shortcode' => '[a1tools_var key="state"]' ), 'zip' => array( 'label' => 'ZIP Code', 'shortcode' => '[a1tools_var key="zip"]' ), 'country' => array( 'label' => 'Country', 'shortcode' => '[a1tools_var key="country"]' ), 'address_formatted' => array( 'label' => 'Full Address (multi-line)', 'shortcode' => '[a1tools_address]' ), 'full_address' => array( 'label' => 'Full Address (single line)', 'shortcode' => '[a1tools_full_address]' ), ), 'Social Media' => array( 'facebook_url' => array( 'label' => 'Facebook', 'shortcode' => '[a1tools_var key="facebook_url"]' ), 'instagram_url' => array( 'label' => 'Instagram', 'shortcode' => '[a1tools_var key="instagram_url"]' ), 'youtube_url' => array( 'label' => 'YouTube', 'shortcode' => '[a1tools_var key="youtube_url"]' ), 'twitter_url' => array( 'label' => 'Twitter/X', 'shortcode' => '[a1tools_var key="twitter_url"]' ), 'linkedin_url' => array( 'label' => 'LinkedIn', 'shortcode' => '[a1tools_var key="linkedin_url"]' ), 'tiktok_url' => array( 'label' => 'TikTok', 'shortcode' => '[a1tools_var key="tiktok_url"]' ), 'yelp_url' => array( 'label' => 'Yelp', 'shortcode' => '[a1tools_var key="yelp_url"]' ), 'google_business_url' => array( 'label' => 'Google Business', 'shortcode' => '[a1tools_var key="google_business_url"]' ), 'pinterest_url' => array( 'label' => 'Pinterest', 'shortcode' => '[a1tools_var key="pinterest_url"]' ), 'bbb_url' => array( 'label' => 'BBB', 'shortcode' => '[a1tools_var key="bbb_url"]' ), 'nextdoor_url' => array( 'label' => 'Nextdoor', 'shortcode' => '[a1tools_var key="nextdoor_url"]' ), 'houzz_url' => array( 'label' => 'Houzz', 'shortcode' => '[a1tools_var key="houzz_url"]' ), 'angi_url' => array( 'label' => 'Angi', 'shortcode' => '[a1tools_var key="angi_url"]' ), 'thumbtack_url' => array( 'label' => 'Thumbtack', 'shortcode' => '[a1tools_var key="thumbtack_url"]' ), 'homeadvisor_url' => array( 'label' => 'HomeAdvisor', 'shortcode' => '[a1tools_var key="homeadvisor_url"]' ), ), ); ?>

    $fields ) : ?>

    $info ) : ?>

    'Monday', 'tue' => 'Tuesday', 'wed' => 'Wednesday', 'thu' => 'Thursday', 'fri' => 'Friday', 'sat' => 'Saturday', 'sun' => 'Sunday', ); foreach ( $days as $key => $label ) : $hours = isset( $variables['operating_hours'][ $key ] ) ? $variables['operating_hours'][ $key ] : ''; ?>

    [a1tools_hours] [a1tools_hours format="list"]

    ✓ — ✓ —

    [a1tools_franchise] [a1tools_franchise columns="2"] [a1tools_franchise id="X"]

    Franchise tab.', 'a1-tools' ); ?>

    ✓ — ✓ —

    [a1tools_store_locator]

    Stores tab.', 'a1-tools' ); ?>

    $wpd ) { if ( isset( $saved_city_data[ $wpid ] ) ) { $city_pages[ $wpid ] = array_merge( $wpd, $saved_city_data[ $wpid ] ); } else { $city_pages[ $wpid ] = $wpd; } } ?>

    $cp ) : ?>

    [a1tools_city_phone] [a1tools_city_phone link="yes"] [a1tools_city_address]

    Cities tab.', 'a1-tools' ); ?>

    __( 'Display social media icons from A1 Tools with customizable styling.', 'a1-tools' ), 'classname' => 'a1tools-social-widget', ) ); } /** * Frontend display. * * @param array $args Widget arguments. * @param array $instance Widget instance. */ public function widget( $args, $instance ) { $vars = a1tools_get_site_variables(); if ( ! $vars ) { return; } // Widget settings with defaults. $title = ! empty( $instance['title'] ) ? $instance['title'] : ''; $shape = ! empty( $instance['shape'] ) ? $instance['shape'] : 'rounded'; $style = ! empty( $instance['style'] ) ? $instance['style'] : 'default'; $size = ! empty( $instance['size'] ) ? (int) $instance['size'] : 40; $icon_size = ! empty( $instance['icon_size'] ) ? (int) $instance['icon_size'] : 18; $spacing = ! empty( $instance['spacing'] ) ? (int) $instance['spacing'] : 8; $alignment = ! empty( $instance['alignment'] ) ? $instance['alignment'] : 'left'; $custom_color = ! empty( $instance['custom_color'] ) ? $instance['custom_color'] : ''; $hover_effect = ! empty( $instance['hover_effect'] ) ? $instance['hover_effect'] : 'scale'; // Platform configurations with official colors. $platforms = array( 'facebook_url' => array( 'label' => 'Facebook', 'icon' => 'fab fa-facebook-f', 'color' => '#1877F2' ), 'instagram_url' => array( 'label' => 'Instagram', 'icon' => 'fab fa-instagram', 'color' => '#E4405F' ), 'youtube_url' => array( 'label' => 'YouTube', 'icon' => 'fab fa-youtube', 'color' => '#FF0000' ), 'twitter_url' => array( 'label' => 'X (Twitter)', 'icon' => 'svg-x', 'color' => '#000000' ), 'linkedin_url' => array( 'label' => 'LinkedIn', 'icon' => 'fab fa-linkedin-in', 'color' => '#0A66C2' ), 'tiktok_url' => array( 'label' => 'TikTok', 'icon' => 'fab fa-tiktok', 'color' => '#000000' ), 'yelp_url' => array( 'label' => 'Yelp', 'icon' => 'fab fa-yelp', 'color' => '#D32323' ), 'google_business_url' => array( 'label' => 'Google', 'icon' => 'fab fa-google', 'color' => '#4285F4' ), 'pinterest_url' => array( 'label' => 'Pinterest', 'icon' => 'fab fa-pinterest-p', 'color' => '#E60023' ), 'bbb_url' => array( 'label' => 'BBB', 'icon' => 'fas fa-shield-alt', 'color' => '#006CB7' ), 'nextdoor_url' => array( 'label' => 'Nextdoor', 'icon' => 'fas fa-home', 'color' => '#8ED500' ), 'houzz_url' => array( 'label' => 'Houzz', 'icon' => 'fab fa-houzz', 'color' => '#4DBC15' ), 'angi_url' => array( 'label' => 'Angi', 'icon' => 'fas fa-tools', 'color' => '#FF6153' ), 'thumbtack_url' => array( 'label' => 'Thumbtack', 'icon' => 'fas fa-thumbtack', 'color' => '#009FD9' ), 'homeadvisor_url' => array( 'label' => 'HomeAdvisor', 'icon' => 'fas fa-home', 'color' => '#EF6C00' ), ); // Check if any platforms have URLs. $has_platforms = false; foreach ( $platforms as $key => $data ) { if ( ! empty( $vars[ $key ] ) ) { $has_platforms = true; break; } } if ( ! $has_platforms ) { return; } // Generate unique ID for this widget instance. $widget_id = 'a1tools-social-' . $this->number; // Build inline styles. $container_styles = array( 'text-align' => $alignment, ); $link_styles = array( 'width' => $size . 'px', 'height' => $size . 'px', 'margin' => '0 ' . ( $spacing / 2 ) . 'px ' . $spacing . 'px', 'font-size' => $icon_size . 'px', 'display' => 'inline-flex', 'align-items' => 'center', 'justify-content' => 'center', 'text-decoration' => 'none', 'transition' => 'all 0.3s ease', ); // Shape styles. switch ( $shape ) { case 'circle': $link_styles['border-radius'] = '50%'; break; case 'square': $link_styles['border-radius'] = '0'; break; case 'rounded': default: $link_styles['border-radius'] = '8px'; break; } // Output widget. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Widget args are pre-escaped by WordPress. echo $args['before_widget']; if ( $title ) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Widget args are pre-escaped by WordPress. echo $args['before_title'] . esc_html( apply_filters( 'widget_title', $title ) ) . $args['after_title']; } // Build CSS for this widget instance. $css = '#' . esc_attr( $widget_id ) . ' { '; foreach ( $container_styles as $prop => $val ) { $css .= esc_attr( $prop ) . ':' . esc_attr( $val ) . ';'; } $css .= '}'; $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { '; foreach ( $link_styles as $prop => $val ) { $css .= esc_attr( $prop ) . ':' . esc_attr( $val ) . ';'; } $css .= '}'; // Style-specific CSS. if ( 'default' === $style || 'official' === $style ) { // Official colors or custom color background. $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { color: #fff; }'; } elseif ( 'outline' === $style ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { background: transparent; border: 2px solid; }'; } elseif ( 'minimal' === $style ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { background: transparent; }'; } elseif ( 'light' === $style ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { background: #f5f5f5; }'; } elseif ( 'dark' === $style ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { background: #333; color: #fff; }'; } // Hover effects. if ( 'scale' === $hover_effect ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon:hover { transform: scale(1.1); }'; } elseif ( 'lift' === $hover_effect ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon:hover { transform: translateY(-3px); box-shadow: 0 4px 12px rgba(0,0,0,0.2); }'; } elseif ( 'rotate' === $hover_effect ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon:hover { transform: rotate(15deg); }'; } elseif ( 'pulse' === $hover_effect ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon:hover { animation: a1tools-pulse 0.5s ease; }'; $css .= '@keyframes a1tools-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.15); } }'; } // Add CSS using WordPress enqueue system. wp_register_style( 'a1tools-social-widget-' . $this->number, false, array(), A1TOOLS_VERSION ); wp_enqueue_style( 'a1tools-social-widget-' . $this->number ); wp_add_inline_style( 'a1tools-social-widget-' . $this->number, $css ); // Output icons container. echo '
    '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Widget args are pre-escaped by WordPress. echo $args['after_widget']; } /** * Backend form. * * @param array $instance Widget instance. */ public function form( $instance ) { $title = ! empty( $instance['title'] ) ? $instance['title'] : ''; $shape = ! empty( $instance['shape'] ) ? $instance['shape'] : 'rounded'; $style = ! empty( $instance['style'] ) ? $instance['style'] : 'default'; $size = ! empty( $instance['size'] ) ? (int) $instance['size'] : 40; $icon_size = ! empty( $instance['icon_size'] ) ? (int) $instance['icon_size'] : 18; $spacing = ! empty( $instance['spacing'] ) ? (int) $instance['spacing'] : 8; $alignment = ! empty( $instance['alignment'] ) ? $instance['alignment'] : 'left'; $custom_color = ! empty( $instance['custom_color'] ) ? $instance['custom_color'] : ''; $hover_effect = ! empty( $instance['hover_effect'] ) ? $instance['hover_effect'] : 'scale'; ?>

    get( 'form_settings' ); $form_id = $form_settings['id'] ?? 'unknown'; $form_name = $form_settings['form_name'] ?? 'Contact Form'; // Get the raw form field values. $raw_fields = $record->get( 'fields' ); // Build a clean associative array: field_id => value. $fields = array(); foreach ( $raw_fields as $field_id => $field_data ) { $fields[ $field_id ] = $field_data['value'] ?? ''; } // Get source page URL. $referrer = isset( $_SERVER['HTTP_REFERER'] ) ? sanitize_url( $_SERVER['HTTP_REFERER'] ) : ''; // Build the API payload. $payload = array( 'action' => 'submit', 'form_id' => $form_id, 'form_name' => $form_name, 'fields' => $fields, 'source_url' => $referrer, 'source_ip' => a1tools_get_client_ip(), 'form_secret' => $form_secret, ); // API endpoint. $api_url = 'https://tools.a-1chimney.com/api/sunday/forms.php?action=submit'; /** * Filter the A1 Tools form submission API URL. * * @since 1.5.0 * @param string $api_url The API URL. */ $api_url = apply_filters( 'a1tools_form_api_url', $api_url ); // Defer the API call to PHP shutdown so it doesn't interfere with // Elementor's form processing pipeline (MailChimp, email, etc.). // The AJAX response is sent to the browser first, then our request fires. add_action( 'shutdown', function() use ( $api_url, $payload, $form_secret ) { $response = wp_remote_post( $api_url, array( 'timeout' => 15, 'httpversion' => '1.1', 'blocking' => false, 'headers' => array( 'Content-Type' => 'application/json', 'X-Form-Secret' => $form_secret, ), 'body' => wp_json_encode( $payload ), 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { error_log( '[A1 Tools] Form submission API error: ' . $response->get_error_message() ); } }); } // Only register the form hook when form forwarding is enabled. // This prevents any interference with other Elementor form actions (e.g., MailChimp). if ( get_option( 'a1tools_form_enabled', false ) ) { add_action( 'elementor_pro/forms/new_record', 'a1tools_handle_elementor_form_submission', 99, 2 ); } /** * Get client IP address, handling proxies. * * @since 1.5.0 * @return string Client IP. */ function a1tools_get_client_ip() { if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { $ips = explode( ',', sanitize_text_field( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ); return trim( $ips[0] ); } if ( ! empty( $_SERVER['HTTP_X_REAL_IP'] ) ) { return sanitize_text_field( $_SERVER['HTTP_X_REAL_IP'] ); } return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( $_SERVER['REMOTE_ADDR'] ) : '0.0.0.0'; }