'', '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 = esc_html( 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', 'photo_shape' => 'circle', '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' => '', '_elementor' => '', ), $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']; $photo_shape = in_array( $atts['photo_shape'], array( 'circle', 'square' ), true ) ? $atts['photo_shape'] : 'circle'; $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( 6, intval( $atts['columns'] ) ) ); $show_cat_heading = 'yes' === $atts['show_category_heading']; $category_order = array_filter( array_map( 'trim', explode( ',', $atts['category_order'] ) ) ); $is_elementor = 'yes' === ( $atts['_elementor'] ?? '' ); $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 && ! $is_elementor ) { // Only output inline grid columns for non-Elementor usage. // Elementor handles columns via its own selectors to support responsive breakpoints. $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'] : ''; $card_class = 'a1tools-team-card'; if ( 'square' === $photo_shape && $show_photo ) { $card_class .= ' a1tools-team-card--square-photo'; } $output .= '
    '; // Photo. if ( $show_photo ) { $photo_class = 'a1tools-team-photo a1tools-team-photo--' . $photo_shape; 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 ) . '
    '; } } // Wrap text content for square photo layout (padding on text, not on photo). $is_square = ( 'square' === $photo_shape && $show_photo ); if ( $is_square ) { $output .= '
    '; } // 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 .= '
    '; } if ( $is_square ) { $output .= '
    '; // .a1tools-team-card-content } $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' ); // ============================================================================ // POST TITLE SHORTCODE // ============================================================================ /** * Shortcode: [a1tools_post_title] * * Outputs the current post/page title. Useful in templates, Elementor, * or anywhere a shortcode can be used. * * Attributes: * tag - HTML tag to wrap the title (default: none, outputs plain text) * Example: tag="h1", tag="h2", tag="span" * class - CSS class(es) for the wrapper tag (default: none) * id - Specific post ID to get the title for (default: current post) * before - Text/HTML to prepend before the title * after - Text/HTML to append after the title * * @since 2.0.7 */ function a1tools_shortcode_post_title( $atts ) { $atts = shortcode_atts( array( 'tag' => '', 'class' => '', 'id' => 0, 'before' => '', 'after' => '', ), $atts, 'a1tools_post_title' ); $post_id = absint( $atts['id'] ); if ( $post_id > 0 ) { $title = get_the_title( $post_id ); } else { $title = get_the_title(); } if ( empty( $title ) ) { return ''; } $title = esc_html( $title ); $output = $atts['before'] . $title . $atts['after']; // Wrap in tag if specified if ( ! empty( $atts['tag'] ) ) { $tag = sanitize_key( $atts['tag'] ); $class = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; $output = '<' . $tag . $class . '>' . $output . ''; } return $output; } add_shortcode( 'a1tools_post_title', 'a1tools_shortcode_post_title' ); // ============================================================================ // SEO LOCATION SHORTCODES // ============================================================================ /** * Shortcode: [a1tools_location_phone] * Outputs the SEO location-specific phone number for the current page. * Falls back to site primary phone if no location-specific phone is set. * * Attributes: * - default: Fallback text if no phone found * - link: 'yes' to wrap in tel: link * - class: CSS class for the element * - page_id: Override automatic page ID detection * * @since 2.1.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_location_phone( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'link' => 'no', 'class' => '', 'page_id' => '', ), $atts, 'a1tools_location_phone' ); $page_id = ! empty( $atts['page_id'] ) ? intval( $atts['page_id'] ) : get_the_ID(); $locations = a1tools_get_seo_locations(); // Get location-specific phone. $phone = ''; if ( ! empty( $locations ) && isset( $locations[ $page_id ] ) ) { $phone = isset( $locations[ $page_id ]['phone'] ) ? $locations[ $page_id ]['phone'] : ''; } // Fallback to default or site primary 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_location_phone', 'a1tools_shortcode_location_phone' ); /** * Shortcode: [a1tools_location_web] * Outputs the SEO location-specific website URL for the current page. * * Attributes: * - default: Fallback text if no URL found * - link: 'yes' to wrap in anchor tag * - text: Custom link text (used when link=yes) * - target: Link target attribute (default: _blank) * - class: CSS class for the element * - page_id: Override automatic page ID detection * * @since 2.1.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_location_web( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'link' => 'no', 'text' => '', 'target' => '_blank', 'class' => '', 'page_id' => '', ), $atts, 'a1tools_location_web' ); $page_id = ! empty( $atts['page_id'] ) ? intval( $atts['page_id'] ) : get_the_ID(); $locations = a1tools_get_seo_locations(); $url = ''; if ( ! empty( $locations ) && isset( $locations[ $page_id ] ) ) { $url = isset( $locations[ $page_id ]['location_web'] ) ? $locations[ $page_id ]['location_web'] : ''; } if ( empty( $url ) ) { return esc_html( $atts['default'] ); } if ( 'yes' === $atts['link'] ) { // Priority: text attribute > full_address field > URL. $label = ''; if ( ! empty( $atts['text'] ) ) { $label = esc_html( $atts['text'] ); } elseif ( ! empty( $locations[ $page_id ]['full_address'] ) ) { $label = esc_html( $locations[ $page_id ]['full_address'] ); } else { $label = esc_html( $url ); } $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . $label . ''; } return esc_html( $url ); } add_shortcode( 'a1tools_location_web', 'a1tools_shortcode_location_web' ); /** * Shortcode: [a1tools_city_web] * Outputs the SEO location-specific city website URL for the current page. * * Attributes: * - default: Fallback text if no URL found * - link: 'yes' to wrap in anchor tag * - text: Custom link text (used when link=yes) * - target: Link target attribute (default: _blank) * - class: CSS class for the element * - page_id: Override automatic page ID detection * * @since 2.1.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_city_web( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'link' => 'no', 'text' => '', 'target' => '_blank', 'class' => '', 'page_id' => '', ), $atts, 'a1tools_city_web' ); $page_id = ! empty( $atts['page_id'] ) ? intval( $atts['page_id'] ) : get_the_ID(); $locations = a1tools_get_seo_locations(); $url = ''; if ( ! empty( $locations ) && isset( $locations[ $page_id ] ) ) { $url = isset( $locations[ $page_id ]['city_web'] ) ? $locations[ $page_id ]['city_web'] : ''; } if ( empty( $url ) ) { return esc_html( $atts['default'] ); } if ( 'yes' === $atts['link'] ) { // Priority: text attribute > city field > URL. $label = ''; if ( ! empty( $atts['text'] ) ) { $label = esc_html( $atts['text'] ); } elseif ( ! empty( $locations[ $page_id ]['city'] ) ) { $label = esc_html( $locations[ $page_id ]['city'] ); } else { $label = esc_html( $url ); } $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . $label . ''; } return esc_html( $url ); } add_shortcode( 'a1tools_city_web', 'a1tools_shortcode_city_web' ); /** * Shortcode: [a1tools_state_web] * Outputs the SEO location-specific state website URL for the current page. * * Attributes: * - default: Fallback text if no URL found * - link: 'yes' to wrap in anchor tag * - text: Custom link text (used when link=yes) * - target: Link target attribute (default: _blank) * - class: CSS class for the element * - page_id: Override automatic page ID detection * * @since 2.1.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_state_web( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'link' => 'no', 'text' => '', 'target' => '_blank', 'class' => '', 'page_id' => '', ), $atts, 'a1tools_state_web' ); $page_id = ! empty( $atts['page_id'] ) ? intval( $atts['page_id'] ) : get_the_ID(); $locations = a1tools_get_seo_locations(); $url = ''; if ( ! empty( $locations ) && isset( $locations[ $page_id ] ) ) { $url = isset( $locations[ $page_id ]['state_web'] ) ? $locations[ $page_id ]['state_web'] : ''; } if ( empty( $url ) ) { return esc_html( $atts['default'] ); } if ( 'yes' === $atts['link'] ) { // Priority: text attribute > state field > URL. $label = ''; if ( ! empty( $atts['text'] ) ) { $label = esc_html( $atts['text'] ); } elseif ( ! empty( $locations[ $page_id ]['state'] ) ) { $label = esc_html( $locations[ $page_id ]['state'] ); } else { $label = esc_html( $url ); } $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . $label . ''; } return esc_html( $url ); } add_shortcode( 'a1tools_state_web', 'a1tools_shortcode_state_web' ); /** * Shortcode: [a1tools_location_city] * Outputs the city name for the current SEO City page. * * Attributes: * - default: Fallback text if no city found * - class: CSS class for the wrapper span * - page_id: Override automatic page ID detection * * @since 2.1.1 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_location_city( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'class' => '', 'page_id' => '', ), $atts, 'a1tools_location_city' ); $page_id = ! empty( $atts['page_id'] ) ? intval( $atts['page_id'] ) : get_the_ID(); $locations = a1tools_get_seo_locations(); $city = ''; if ( ! empty( $locations ) && isset( $locations[ $page_id ] ) ) { $city = isset( $locations[ $page_id ]['city'] ) ? $locations[ $page_id ]['city'] : ''; } if ( empty( $city ) ) { $city = ! empty( $atts['default'] ) ? $atts['default'] : a1tools_get_variable( 'city_name', '' ); } if ( empty( $city ) ) { return ''; } $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . esc_html( $city ) . ''; } add_shortcode( 'a1tools_location_city', 'a1tools_shortcode_location_city' ); /** * Shortcode: [a1tools_location_address] * Outputs the full address for the current SEO City page. * * Attributes: * - default: Fallback text if no address found * - class: CSS class for the wrapper span * - page_id: Override automatic page ID detection * * @since 2.1.1 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_location_address( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'class' => '', 'page_id' => '', ), $atts, 'a1tools_location_address' ); $page_id = ! empty( $atts['page_id'] ) ? intval( $atts['page_id'] ) : get_the_ID(); $locations = a1tools_get_seo_locations(); $address = ''; if ( ! empty( $locations ) && isset( $locations[ $page_id ] ) ) { $address = isset( $locations[ $page_id ]['full_address'] ) ? $locations[ $page_id ]['full_address'] : ''; } if ( empty( $address ) ) { $address = ! empty( $atts['default'] ) ? $atts['default'] : a1tools_get_variable( 'full_address', '' ); } if ( empty( $address ) ) { return ''; } $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . esc_html( $address ) . ''; } add_shortcode( 'a1tools_location_address', 'a1tools_shortcode_location_address' ); /** * Shortcode: [a1tools_location_state] * Outputs the state for the current SEO City page. * * Attributes: * - default: Fallback text if no state found * - class: CSS class for the wrapper span * - page_id: Override automatic page ID detection * * @since 2.1.1 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_location_state( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'class' => '', 'page_id' => '', ), $atts, 'a1tools_location_state' ); $page_id = ! empty( $atts['page_id'] ) ? intval( $atts['page_id'] ) : get_the_ID(); $locations = a1tools_get_seo_locations(); $state = ''; if ( ! empty( $locations ) && isset( $locations[ $page_id ] ) ) { $state = isset( $locations[ $page_id ]['state'] ) ? $locations[ $page_id ]['state'] : ''; } if ( empty( $state ) ) { $state = ! empty( $atts['default'] ) ? $atts['default'] : a1tools_get_variable( 'state', '' ); } if ( empty( $state ) ) { return ''; } $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . esc_html( $state ) . ''; } add_shortcode( 'a1tools_location_state', 'a1tools_shortcode_location_state' ); /** * Shortcode: [a1tools_map_address] * Outputs the SEO location-specific map address for the current page. * Falls back to site full_address if no location-specific address is set. * * Attributes: * - default: Fallback text if no address found * - link: 'yes' to wrap in Google Maps link * - target: Link target attribute (default: _blank) * - class: CSS class for the element * - page_id: Override automatic page ID detection * * @since 2.1.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_map_address( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'link' => 'no', 'target' => '_blank', 'class' => '', 'page_id' => '', ), $atts, 'a1tools_map_address' ); $page_id = ! empty( $atts['page_id'] ) ? intval( $atts['page_id'] ) : get_the_ID(); $locations = a1tools_get_seo_locations(); // Get location-specific map address. $address = ''; if ( ! empty( $locations ) && isset( $locations[ $page_id ] ) ) { $address = isset( $locations[ $page_id ]['map_address'] ) ? $locations[ $page_id ]['map_address'] : ''; } // Fallback to default or site full_address. if ( empty( $address ) ) { if ( ! empty( $atts['default'] ) ) { $address = $atts['default']; } else { $address = a1tools_get_variable( 'full_address', '' ); } } if ( empty( $address ) ) { return ''; } if ( 'yes' === $atts['link'] ) { $maps_url = 'https://www.google.com/maps/search/' . urlencode( $address ); $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . esc_html( $address ) . ''; } $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . esc_html( $address ) . ''; } add_shortcode( 'a1tools_map_address', 'a1tools_shortcode_map_address' ); /** * Shortcode: [a1tools_viewpoint] * Outputs a Google Street View iframe for the current SEO location page. * Supports both full ' . ''; } add_shortcode( 'a1tools_viewpoint', 'a1tools_shortcode_viewpoint' ); /** * Shortcode: [a1tools_location_list] * Renders a linked list of SEO 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 * - 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 * - accordion: 'yes' to enable accordion behavior on groups * - accordion_default: 'collapsed' or 'expanded' * - accordion_mode: 'single' or 'multiple' * - accordion_icon: 'yes' to show expand/collapse icon * - accordion_speed: Animation speed in ms * - show_count: 'yes' to show city count per group * - show_search: 'yes' to show search filter input * - search_placeholder: Placeholder text for search input * * @since 2.1.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_location_list( $atts ) { $atts = shortcode_atts( array( 'layout' => 'list', 'columns' => '3', 'show_groups' => 'yes', 'group' => '', '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 locations...', '_elementor' => '', ), $atts, 'a1tools_location_list' ); // Get ALL WordPress SEO city pages from the local WP database (includes // every published page by "SEO City" authors). $wp_seo_pages = a1tools_get_wp_seo_city_pages(); // Get saved data from the A1 Tools API (phone, address, web URLs, group — // only for locations that have been edited in the app). Keyed by wp_page_id. $saved_data = a1tools_get_seo_locations(); if ( ! is_array( $saved_data ) ) { $saved_data = array(); } // Merge: start with all WP pages, overlay saved data. // If WP query returned results, use it as the base (shows ALL SEO city pages). // Otherwise fall back to API-only data (only pages with saved data). $base_pages = ! empty( $wp_seo_pages ) ? $wp_seo_pages : $saved_data; if ( empty( $base_pages ) ) { return ''; } $locations = array(); foreach ( $base_pages as $wp_page_id => $wp_data ) { $permalink = get_permalink( intval( $wp_page_id ) ); if ( empty( $permalink ) ) { continue; // Skip locations whose WP page no longer exists. } // Overlay saved data if this location has been edited in the app. if ( ! empty( $wp_seo_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; $locations[] = $wp_data; } // Filter by specific group. if ( ! empty( $atts['group'] ) ) { $filter_group = $atts['group']; $locations = array_filter( $locations, function ( $loc ) use ( $filter_group ) { return isset( $loc['group_name'] ) && $loc['group_name'] === $filter_group; } ); } if ( empty( $locations ) ) { return ''; } // Sort by group_order, group_name, then page_title. usort( $locations, 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 ) { $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 a1tools-location-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 locations...'; $output .= '
    ' . '' . '
    '; } // Helper to render a single location link. $render_link = function ( $loc ) use ( $atts, $target ) { $label = isset( $loc['page_title'] ) ? $loc['page_title'] : ''; if ( 'yes' === $atts['append_state'] && ! empty( $atts['state'] ) ) { $label .= ', ' . $atts['state']; } return '
  • ' . esc_html( $label ) . '
  • '; }; if ( $show_groups ) { // Group locations by group_name. $groups = array(); $ungrouped = array(); foreach ( $locations as $loc ) { $gn = isset( $loc['group_name'] ) ? $loc['group_name'] : ''; if ( ! empty( $gn ) ) { if ( ! isset( $groups[ $gn ] ) ) { $groups[ $gn ] = array(); } $groups[ $gn ][] = $loc; } else { $ungrouped[] = $loc; } } // Render each group. foreach ( $groups as $group_name => $group_locs ) { $loc_count = count( $group_locs ); $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( $loc_count ) . ')'; } $output .= '

    '; // Wrap list in accordion content div when accordion is enabled. if ( $accordion ) { $display = $acc_expanded ? '' : ' style="display:none;"'; $output .= '
    '; } $output .= '
      '; foreach ( $group_locs as $loc ) { $output .= $render_link( $loc ); } $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 $loc ) { $output .= $render_link( $loc ); } $output .= '
    '; if ( $accordion ) { $output .= '
    '; } $output .= '
    '; } } else { // Flat list. $output .= '
      '; foreach ( $locations as $loc ) { $output .= $render_link( $loc ); } $output .= '
    '; } $output .= '
    '; // Note: Base styles and JS for accordion/search are already output by // a1tools_shortcode_city_list() since both use the same CSS classes. // If the city_list shortcode is not present on the page, output them here. static $loc_styles_output = false; if ( ! $loc_styles_output ) { $loc_styles_output = true; $output .= ''; } // Inline JS for accordion + search (output once per page). static $loc_js_output = false; if ( ! $loc_js_output && ( $accordion || $show_search ) ) { $loc_js_output = true; $output .= ''; } return $output; } add_shortcode( 'a1tools_location_list', 'a1tools_shortcode_location_list' );