'', '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 only safe HTML tags. $allowed_sep_tags = array( 'br' => array(), 'span' => array( 'class' => array(), 'style' => array() ), 'hr' => array( 'class' => array() ), ); $separator = wp_kses( $atts['separator'], $allowed_sep_tags ); return implode( $separator, array_map( 'esc_html', $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 ''; } /** * Allowed-tags array for safely echoing icon HTML. * * Permits Font Awesome tags AND inline / markup. Use this * instead of wp_kses_post() at icon render sites — wp_kses_post() strips * / by default, which would render inline-SVG icons (e.g. X / * Twitter) as blank tiles. * * @since 2.4.1 * @return array */ function a1tools_icon_allowed_tags() { return array( 'i' => array( 'class' => true, 'aria-hidden' => true, ), 'svg' => array( 'viewbox' => true, 'aria-hidden' => true, 'fill' => true, 'class' => true, 'xmlns' => true, 'width' => true, 'height' => true, ), 'path' => array( 'd' => true, 'fill' => true, ), ); } /** * 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_licence] * Outputs the business licence number. * * @since 2.3.0 * @return string */ function a1tools_shortcode_licence() { $licence = a1tools_get_variable( 'licence', '' ); if ( empty( $licence ) ) { return ''; } return esc_html( $licence ); } add_shortcode( 'a1tools_licence', 'a1tools_shortcode_licence' ); /** * Shortcode: [a1tools_content key="slug"] * Outputs a reusable content block authored in the SEO app, looked up by slug. * Content is rich text / HTML, sanitized on output with wp_kses_post. * * Attributes: * - key: The content block slug (required). * - default: Fallback text if the block is missing or empty. * - wpautop: 'yes' (default) to apply paragraph/line-break formatting, 'no' for raw. * * @since 3.1.0 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_content( $atts ) { $atts = shortcode_atts( array( 'key' => '', 'default' => '', 'wpautop' => 'yes', ), $atts, 'a1tools_content' ); $key = sanitize_key( $atts['key'] ); if ( '' === $key ) { return esc_html( $atts['default'] ); } $blocks = a1tools_get_site_content_blocks(); $content = ''; if ( is_array( $blocks ) && isset( $blocks[ $key ] ) && ! empty( $blocks[ $key ]['content'] ) ) { $content = $blocks[ $key ]['content']; } if ( '' === $content ) { return esc_html( $atts['default'] ); } // Allow shortcodes nested inside a content block (e.g. [a1tools_phone]). $content = do_shortcode( $content ); if ( 'no' !== $atts['wpautop'] ) { $content = wpautop( $content ); } return wp_kses_post( $content ); } add_shortcode( 'a1tools_content', 'a1tools_shortcode_content' ); /** * Shortcode: [a1tools_business_name] * Outputs the business name. * * @since 2.6.9 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_business_name( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'class' => '', ), $atts, 'a1tools_business_name' ); $value = a1tools_get_variable( 'business_name', $atts['default'] ); if ( '' === $value ) { return ''; } if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $value ) . ''; } return esc_html( $value ); } add_shortcode( 'a1tools_business_name', 'a1tools_shortcode_business_name' ); /** * Shortcode: [a1tools_tagline] * Outputs the business tagline. * * @since 2.6.9 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_tagline( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'class' => '', ), $atts, 'a1tools_tagline' ); $value = a1tools_get_variable( 'tagline', $atts['default'] ); if ( '' === $value ) { return ''; } if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $value ) . ''; } return esc_html( $value ); } add_shortcode( 'a1tools_tagline', 'a1tools_shortcode_tagline' ); /** * Shortcode: [a1tools_phone] * Outputs the primary phone number. Use link="yes" for a tel: link. * * @since 2.6.9 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_phone( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'link' => 'no', 'class' => '', ), $atts, 'a1tools_phone' ); $value = a1tools_get_variable( 'phone_primary', $atts['default'] ); if ( '' === $value ) { return ''; } if ( 'yes' === $atts['link'] ) { $clean = preg_replace( '/[^0-9+]/', '', $value ); $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . esc_html( $value ) . ''; } if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $value ) . ''; } return esc_html( $value ); } add_shortcode( 'a1tools_phone', 'a1tools_shortcode_phone' ); /** * Shortcode: [a1tools_phone_secondary] * Outputs the secondary phone number. Use link="yes" for a tel: link. * * @since 2.6.9 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_phone_secondary( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'link' => 'no', 'class' => '', ), $atts, 'a1tools_phone_secondary' ); $value = a1tools_get_variable( 'phone_secondary', $atts['default'] ); if ( '' === $value ) { return ''; } if ( 'yes' === $atts['link'] ) { $clean = preg_replace( '/[^0-9+]/', '', $value ); $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . esc_html( $value ) . ''; } if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $value ) . ''; } return esc_html( $value ); } add_shortcode( 'a1tools_phone_secondary', 'a1tools_shortcode_phone_secondary' ); /** * Shortcode: [a1tools_email] * Outputs the primary email address. Use link="yes" for a mailto: link. * * @since 2.6.9 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_email( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'link' => 'no', 'class' => '', ), $atts, 'a1tools_email' ); $value = a1tools_get_variable( 'email_primary', $atts['default'] ); if ( '' === $value ) { return ''; } if ( 'yes' === $atts['link'] ) { $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . esc_html( antispambot( $value ) ) . ''; } if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $value ) . ''; } return esc_html( $value ); } add_shortcode( 'a1tools_email', 'a1tools_shortcode_email' ); /** * Shortcode: [a1tools_email_secondary] * Outputs the secondary email address. Use link="yes" for a mailto: link. * * @since 2.6.9 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_email_secondary( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'link' => 'no', 'class' => '', ), $atts, 'a1tools_email_secondary' ); $value = a1tools_get_variable( 'email_secondary', $atts['default'] ); if ( '' === $value ) { return ''; } if ( 'yes' === $atts['link'] ) { $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . esc_html( antispambot( $value ) ) . ''; } if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $value ) . ''; } return esc_html( $value ); } add_shortcode( 'a1tools_email_secondary', 'a1tools_shortcode_email_secondary' ); /** * Shortcode: [a1tools_review_link] * Outputs the review URL. Use link="yes" to render a clickable anchor * (with an optional text="..." attribute for the label). * * @since 2.6.9 * @param array $atts Shortcode attributes. * @return string Shortcode output. */ function a1tools_shortcode_review_link( $atts ) { $atts = shortcode_atts( array( 'default' => '', 'link' => 'no', 'text' => '', 'class' => '', ), $atts, 'a1tools_review_link' ); $value = a1tools_get_variable( 'review_url', $atts['default'] ); if ( '' === $value ) { return ''; } if ( 'yes' === $atts['link'] ) { $label = ! empty( $atts['text'] ) ? $atts['text'] : $value; $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; return '' . esc_html( $label ) . ''; } return esc_url( $value ); } add_shortcode( 'a1tools_review_link', 'a1tools_shortcode_review_link' ); /** * 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 ) { return a1tools_seo_location_field( $atts, 'a1tools_city_phone', 'phone', array( 'link_type' => 'tel', 'fallback_var' => 'phone_primary', 'source' => 'city_pages', ) ); } 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. // Priority order, designed so a franchise with reliable data always wins: // 1. Name + full address — Google resolves the actual place and renders // the business info card (rich result with reviews, hours, photo). // 2. Stored lat/lng — when no address is available, drop a marker at // the exact coordinates instead of doing a name-only search (which // Google often resolves to an unrelated business with the same name // somewhere else in the country, e.g. another "A1 Chimney" in FL). // 3. Skip the map entirely — better to show no map than a misleading // one when we have neither an address nor coordinates. if ( 'yes' === $atts['show_map'] ) { $has_address_field = ! empty( $franchise['address_line1'] ) || ! empty( $franchise['city'] ) || ! empty( $franchise['state'] ) || ! empty( $franchise['zip'] ); $has_coords = is_numeric( $lat ) && is_numeric( $lng ); $embed_query = ''; if ( $has_address_field ) { $embed_parts = array( $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 ); } elseif ( $has_coords ) { $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). // Layout note: cards are flex columns with the directions link pinned to // the bottom (`margin-top: auto`), and the title has a `min-height` sized // for 2 lines so single-line and wrapping titles (e.g. "A1 Chimney - Mount // Laurel Township") don't shift the map iframe to a different y-coord // across columns. The grid still stretches all cards to the row height. $output .= ''; return $output; } add_shortcode( 'a1tools_franchise', 'a1tools_shortcode_franchise' ); /** * Resolve the public review URL for the current site. * * Priority: explicit override → site `review_url` variable → `google_business_url` * → `a1tools_fallback_review_url` option → (a-1chimney.com hosts only) the main * A-1 Chimney Google review link. The last step is host-gated so other brands * running this plugin never inherit a chimney review link, and the gate matches * the exact apex or a real subdomain only (suffix-spoof domains like * `evila-1chimney.com` do NOT match). Shared by the Review Funnel and the * Smiley Reviews widget. * * @since 2.6.0 * @since 3.2.8 Added the fallback-option + host-gated main-site link chain. * @param string $override Optional widget-level override URL. * @return string Review URL or '' when none configured. */ function a1tools_resolve_review_url( $override = '' ) { $override = trim( (string) $override ); if ( $override !== '' ) { return $override; } $vars = a1tools_get_site_variables(); if ( is_array( $vars ) ) { if ( ! empty( $vars['review_url'] ) ) { return $vars['review_url']; } if ( ! empty( $vars['google_business_url'] ) ) { return $vars['google_business_url']; } } // Site-level fallback: lets any install pin a review link without touching // the site-variable system (empty by default → no behaviour change). $fallback = trim( (string) get_option( 'a1tools_fallback_review_url', '' ) ); if ( $fallback !== '' ) { return $fallback; } // A-1 Chimney fleet safety net: subdomains without their own Google listing // still get the main A-1 Chimney review link, so the "Leave a Review on // Google" button always renders on this fleet. $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); if ( 'a-1chimney.com' === $host || '.a-1chimney.com' === substr( $host, -15 ) ) { return 'https://g.page/r/CcX5qDyxxycSEBM/review'; } return ''; } /** * Render the Review Funnel. * * Shared by the Elementor widget (class-a1-tools-review-widget.php) and the * [a1tools_review_widget] shortcode so both paths emit identical markup. The * visitor-side behaviour (star selection, panel reveal, submit) lives in * assets/review.js; this only builds the DOM + data-* contract. * * @since 2.6.0 * @param array $s Normalised settings (Elementor control IDs / shortcode atts). * @return string HTML. */ function a1tools_render_review_funnel( array $s ) { // Normalise the review-URL override (Elementor passes an array, the // shortcode a plain string). $override = isset( $s['review_url_override'] ) ? ( is_array( $s['review_url_override'] ) ? ( $s['review_url_override']['url'] ?? '' ) : $s['review_url_override'] ) : ''; $review_url = a1tools_resolve_review_url( $override ); // Resolve the CRM company. Mirrors the web-form widget: auto-resolve from // the A1 Tools API when the admin option is still 0. $company_id = (int) get_option( 'a1tools_form_crm_company_id', 0 ); if ( $company_id === 0 && function_exists( 'a1tools_resolve_crm_company_id' ) ) { $company_id = (int) a1tools_resolve_crm_company_id(); } $rest_url = esc_url_raw( rest_url( 'a1-tools/v1/submit-review' ) ); $page_url = esc_url( is_singular() ? get_permalink() : home_url( add_query_arg( null, null ) ) ); $threshold = ( isset( $s['positive_threshold'] ) && (int) $s['positive_threshold'] === 5 ) ? 5 : 4; // Defaults mirror the Elementor control defaults so the shortcode works // with zero attributes. $prompt_title = $s['prompt_title'] ?? __( 'How was your experience?', 'a1-tools' ); $prompt_subtitle = $s['prompt_subtitle'] ?? __( 'Tap a star to rate us.', 'a1-tools' ); $pos_title = $s['positive_title'] ?? __( 'Thank you! 🎉', 'a1-tools' ); $pos_message = $s['positive_message'] ?? __( "We'd love it if you shared your experience on Google. It only takes a moment.", 'a1-tools' ); $pos_feedback_label = $s['positive_feedback_label'] ?? __( 'What did we do well?', 'a1-tools' ); $pos_submit = $s['positive_submit_label'] ?? __( 'Send', 'a1-tools' ); $pos_intro_title = $s['positive_intro_title'] ?? __( 'So glad we hit the mark! 🎉', 'a1-tools' ); $pos_intro_message = $s['positive_intro_message'] ?? __( 'Tell us what we did well.', 'a1-tools' ); $google_prompt = $s['google_prompt'] ?? __( "Thanks for sharing your experience! If you'd like, leave us a quick review on Google too. We've copied your note so you can paste it right in.", 'a1-tools' ); $pos_btn = $s['positive_button_label'] ?? __( 'Rate us on Google', 'a1-tools' ); $neg_title = $s['negative_title'] ?? __( "We're sorry we missed the mark", 'a1-tools' ); $neg_message = $s['negative_message'] ?? __( 'Tell us what went wrong and how we can make it right.', 'a1-tools' ); // De-gated (FTC-compliant): low raters are ALSO offered the public Google // review after their private feedback — the public option is never hidden. $neg_google_prompt = $s['negative_google_prompt'] ?? __( "If you'd like, you can also share your experience publicly on Google. It only takes a moment.", 'a1-tools' ); $name_label = $s['name_label'] ?? __( 'Your name', 'a1-tools' ); $phone_label = $s['phone_label'] ?? __( 'Phone', 'a1-tools' ); $email_label = $s['email_label'] ?? __( 'Email', 'a1-tools' ); $feedback_label = $s['feedback_label'] ?? __( 'What happened?', 'a1-tools' ); $neg_submit = $s['negative_submit_label'] ?? __( 'Send feedback', 'a1-tools' ); $success_message = $s['success_message'] ?? __( 'Thank you — we\'ve received your feedback and will be in touch shortly.', 'a1-tools' ); $error_message = $s['error_message'] ?? __( 'Sorry, something went wrong. Please try again or call us.', 'a1-tools' ); ob_start(); ?>

    __( 'How was your experience?', 'a1-tools' ), 'prompt_subtitle' => __( 'Tap a star to rate us.', 'a1-tools' ), 'positive_threshold' => '4', 'positive_title' => __( 'Thank you! 🎉', 'a1-tools' ), 'positive_message' => __( "We'd love it if you shared your experience on Google. It only takes a moment.", 'a1-tools' ), 'positive_feedback_label' => __( 'What did we do well?', 'a1-tools' ), 'positive_submit_label' => __( 'Send', 'a1-tools' ), 'positive_intro_title' => __( 'So glad we hit the mark! 🎉', 'a1-tools' ), 'positive_intro_message' => __( 'Tell us what we did well.', 'a1-tools' ), 'google_prompt' => __( "Thanks for sharing your experience! If you'd like, leave us a quick review on Google too. We've copied your note so you can paste it right in.", 'a1-tools' ), 'positive_button_label' => __( 'Rate us on Google', 'a1-tools' ), 'review_url_override' => '', 'negative_title' => __( "We're sorry we missed the mark", 'a1-tools' ), 'negative_message' => __( 'Tell us what went wrong and how we can make it right.', 'a1-tools' ), 'name_label' => __( 'Your name', 'a1-tools' ), 'phone_label' => __( 'Phone', 'a1-tools' ), 'email_label' => __( 'Email', 'a1-tools' ), 'feedback_label' => __( 'What happened?', 'a1-tools' ), 'negative_submit_label' => __( 'Send feedback', 'a1-tools' ), 'success_message' => __( 'Thank you — we\'ve received your feedback and will be in touch shortly.', 'a1-tools' ), 'error_message' => __( 'Sorry, something went wrong. Please try again or call us.', 'a1-tools' ), ), $atts, 'a1tools_review_widget' ); // Ensure assets are present when used as a bare shortcode (outside Elementor). if ( wp_style_is( 'a1tools-review', 'registered' ) ) { wp_enqueue_style( 'a1tools-review' ); } if ( wp_script_is( 'a1tools-review', 'registered' ) ) { wp_enqueue_script( 'a1tools-review' ); } return a1tools_render_review_funnel( $atts ); } add_shortcode( 'a1tools_review_widget', 'a1tools_shortcode_review_widget' ); /** * 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', 'center_rows' => 'no', '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'] ?? '' ); $center_rows = ! $is_list && 'yes' === $atts['center_rows']; $wrapper_class = $is_list ? 'a1tools-team-list' : 'a1tools-team-grid'; if ( $center_rows ) { $wrapper_class .= ' a1tools-team-grid--centered'; } if ( ! empty( $atts['class'] ) ) { $wrapper_class .= ' ' . sanitize_html_class( $atts['class'] ); } $grid_style = ''; if ( ! $is_list ) { if ( $is_elementor ) { // Elementor's responsive selector sets grid-template-columns for the // (non-centered) grid per breakpoint. But the centered flex layout // reads --a1-team-cols, and Elementor does NOT reliably emit // per-element custom properties (this is what made centered rows // silently fall back to 3 columns — #26). So set --a1-team-cols // inline here as the single source of truth; do NOT hard-code // grid-template-columns (that would defeat Elementor's responsive // grid). Tablet/phone column counts come from the base-CSS media // queries below. $grid_style = ' style="--a1-team-cols:' . $columns . ';"'; } else { $grid_style = ' style="grid-template-columns:repeat(' . $columns . ',1fr);--a1-team-cols:' . $columns . ';"'; } } // 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 // ============================================================================ /** * Shared renderer for the per-page location field shortcodes. * * Loads the per-page location data, pulls a single field, applies the * family-specific fallback, and renders the value — optionally wrapped in a * link. Each link_type mirrors its original shortcode behavior exactly: * * - 'web': Empty value returns the escaped default. link="yes" wraps in an * anchor whose label is the text attribute > label_field > URL; * plain output is unwrapped text (class only applies to the anchor). * - 'tel': A non-empty default is returned as plain text, otherwise the * fallback_var site variable is used. link="yes" builds a tel: * anchor; plain output wraps in a span only when class is set. * - 'maps': Falls back to default > fallback_var site variable. link="yes" * builds a Google Maps search anchor; plain output always wraps * in a span. * - '': Falls back to default > fallback_var site variable; always * wraps in a span. * * @since 3.2.10 * @param array $atts Raw shortcode attributes. * @param string $tag Shortcode tag (for the shortcode_atts_{$tag} filter). * @param string $field Location data field to output. * @param array $opts { * Rendering options. * * @type string $link_type 'web', 'tel', 'maps', or '' (plain span). * @type string $label_field Location field used as link label fallback (link_type 'web'). * @type string $fallback_var Site variable used when the location field is empty. * @type string $source 'locations' (SEO locations) or 'city_pages'. * } * @return string Shortcode output. */ function a1tools_seo_location_field( $atts, $tag, $field, $opts = array() ) { $opts = array_merge( array( 'link_type' => '', 'label_field' => '', 'fallback_var' => '', 'source' => 'locations', ), $opts ); // Attribute defaults vary by link type (identical to the original shortcodes). $defaults = array( 'default' => '', 'class' => '', 'page_id' => '', ); if ( 'web' === $opts['link_type'] ) { $defaults['link'] = 'no'; $defaults['text'] = ''; $defaults['target'] = '_blank'; } elseif ( 'tel' === $opts['link_type'] ) { $defaults['link'] = 'no'; } elseif ( 'maps' === $opts['link_type'] ) { $defaults['link'] = 'no'; $defaults['target'] = '_blank'; } $atts = shortcode_atts( $defaults, $atts, $tag ); $page_id = ! empty( $atts['page_id'] ) ? intval( $atts['page_id'] ) : get_the_ID(); $locations = 'city_pages' === $opts['source'] ? a1tools_get_site_city_pages() : a1tools_get_seo_locations(); // Get the location-specific field value. $value = ''; if ( ! empty( $locations ) && isset( $locations[ $page_id ] ) ) { $value = isset( $locations[ $page_id ][ $field ] ) ? $locations[ $page_id ][ $field ] : ''; } // Family-specific fallback handling. if ( empty( $value ) ) { if ( 'web' === $opts['link_type'] ) { return esc_html( $atts['default'] ); } if ( 'tel' === $opts['link_type'] ) { if ( ! empty( $atts['default'] ) ) { return esc_html( $atts['default'] ); } $value = a1tools_get_variable( $opts['fallback_var'], '' ); } else { $value = ! empty( $atts['default'] ) ? $atts['default'] : a1tools_get_variable( $opts['fallback_var'], '' ); } if ( empty( $value ) ) { return ''; } } $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; if ( 'web' === $opts['link_type'] ) { if ( 'yes' === $atts['link'] ) { // Priority: text attribute > label field > URL. if ( ! empty( $atts['text'] ) ) { $label = esc_html( $atts['text'] ); } elseif ( ! empty( $locations[ $page_id ][ $opts['label_field'] ] ) ) { $label = esc_html( $locations[ $page_id ][ $opts['label_field'] ] ); } else { $label = esc_html( $value ); } return '' . $label . ''; } return esc_html( $value ); } if ( 'tel' === $opts['link_type'] ) { // Output as tel: link if requested. if ( 'yes' === $atts['link'] ) { $clean = preg_replace( '/[^0-9+]/', '', $value ); return '' . esc_html( $value ) . ''; } if ( ! empty( $atts['class'] ) ) { return '' . esc_html( $value ) . ''; } return esc_html( $value ); } if ( 'maps' === $opts['link_type'] && 'yes' === $atts['link'] ) { $maps_url = 'https://www.google.com/maps/search/' . urlencode( $value ); return '' . esc_html( $value ) . ''; } // Plain output — always span-wrapped. return '' . esc_html( $value ) . ''; } /** * 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 ) { return a1tools_seo_location_field( $atts, 'a1tools_location_phone', 'phone', array( 'link_type' => 'tel', 'fallback_var' => 'phone_primary', ) ); } 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 ) { return a1tools_seo_location_field( $atts, 'a1tools_location_web', 'location_web', array( 'link_type' => 'web', 'label_field' => 'full_address', ) ); } 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 ) { return a1tools_seo_location_field( $atts, 'a1tools_city_web', 'city_web', array( 'link_type' => 'web', 'label_field' => 'city', ) ); } 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 ) { return a1tools_seo_location_field( $atts, 'a1tools_state_web', 'state_web', array( 'link_type' => 'web', 'label_field' => 'state', ) ); } 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 ) { return a1tools_seo_location_field( $atts, 'a1tools_location_city', 'city', array( 'fallback_var' => 'city_name', ) ); } 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 ) { return a1tools_seo_location_field( $atts, 'a1tools_location_address', 'full_address', array( 'fallback_var' => 'full_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 ) { return a1tools_seo_location_field( $atts, 'a1tools_location_state', 'state', array( 'fallback_var' => '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 ) { return a1tools_seo_location_field( $atts, 'a1tools_map_address', 'map_address', array( 'link_type' => 'maps', 'fallback_var' => 'full_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' ); /** * Shortcode: [a1tools_ekomi_seal] * Renders the official eKomi review seal / widget (SmartWidgets embed). * * eKomi widgets are keyed by the account's numeric customer id (the eKomi * "shop id") plus a per-widget token created in the eKomi dashboard. Provide * the token via the `token` attribute or the `a1tools_ekomi_widget_token` * option; the customer id defaults to A1's account (154959) and may be * overridden with `customer` or the `a1tools_ekomi_customer_id` option. * * Renders NOTHING until a token is configured, so this is safe to ship before * the seal is actually placed. Example: * [a1tools_ekomi_seal token="sf66558f899aa2e38d"] * * @since 2.7.0 * @param array $atts Shortcode attributes. * @return string Widget embed HTML, or '' when not configured. */ function a1tools_shortcode_ekomi_seal( $atts ) { $atts = shortcode_atts( array( 'token' => get_option( 'a1tools_ekomi_widget_token', '' ), 'customer' => get_option( 'a1tools_ekomi_customer_id', '154959' ), 'lang' => 'en', ), $atts, 'a1tools_ekomi_seal' ); $token = preg_replace( '/[^A-Za-z0-9]/', '', (string) $atts['token'] ); $customer = preg_replace( '/[^0-9]/', '', (string) $atts['customer'] ); $lang = preg_replace( '/[^a-z]/', '', strtolower( (string) $atts['lang'] ) ); if ( '' === $lang ) { $lang = 'en'; } // Nothing to render without a configured widget. Leave an admin-only HTML // comment so it's discoverable in source; render nothing for visitors. if ( '' === $token || '' === $customer ) { return current_user_can( 'manage_options' ) ? '' : ''; } ob_start(); ?>
    Franchise editor). * * One dispatcher serves every field: WordPress passes the shortcode tag as the * third argument, which maps to a franchise data key. * * Registered tags -> field: * a1tools_franchise_name -> name * a1tools_franchise_phone -> phone (link="yes" wraps in tel:) * a1tools_franchise_address -> full multi-line address (virtual) * a1tools_franchise_address_line1 -> address_line1 * a1tools_franchise_address_line2 -> address_line2 * a1tools_franchise_city -> city * a1tools_franchise_state -> state * a1tools_franchise_zip -> zip * a1tools_franchise_country -> country * a1tools_franchise_maps_url -> google_maps_url (link="yes" = Get Directions) * a1tools_franchise_latitude -> latitude * a1tools_franchise_longitude -> longitude * * @since 2.7.0 * @param array $atts Shortcode attributes (id, default, link, class). * @param string $content Unused. * @param string $tag Invoked shortcode tag. * @return string Shortcode output. */ function a1tools_shortcode_franchise_field( $atts, $content, $tag ) { $atts = shortcode_atts( array( 'id' => '', 'default' => '', 'link' => '', 'class' => '', ), $atts, $tag ); $target_id = intval( $atts['id'] ); if ( $target_id <= 0 ) { return esc_html( $atts['default'] ); } $franchises = a1tools_get_site_franchises(); if ( empty( $franchises ) || ! is_array( $franchises ) ) { return esc_html( $atts['default'] ); } $franchise = null; foreach ( $franchises as $f ) { if ( isset( $f['id'] ) && intval( $f['id'] ) === $target_id ) { $franchise = $f; break; } } if ( null === $franchise ) { return esc_html( $atts['default'] ); } $field = str_replace( 'a1tools_franchise_', '', $tag ); $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; // Virtual "address" field: full multi-line address, same assembly as the card. if ( 'address' === $field ) { $parts = array(); if ( ! empty( $franchise['address_line1'] ) ) { $parts[] = esc_html( $franchise['address_line1'] ); } if ( ! empty( $franchise['address_line2'] ) ) { $parts[] = esc_html( $franchise['address_line2'] ); } $csz = array(); foreach ( array( 'city', 'state', 'zip' ) as $k ) { if ( ! empty( $franchise[ $k ] ) ) { $csz[] = $franchise[ $k ]; } } if ( ! empty( $csz ) ) { $parts[] = esc_html( implode( ', ', $csz ) ); } if ( empty( $parts ) ) { return esc_html( $atts['default'] ); } return '' . implode( '
    ', $parts ) . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- parts escaped above. } $key_map = array( 'name' => 'name', 'phone' => 'phone', 'address_line1' => 'address_line1', 'address_line2' => 'address_line2', 'city' => 'city', 'state' => 'state', 'zip' => 'zip', 'country' => 'country', 'maps_url' => 'google_maps_url', 'latitude' => 'latitude', 'longitude' => 'longitude', ); if ( ! isset( $key_map[ $field ] ) ) { return esc_html( $atts['default'] ); } $value = isset( $franchise[ $key_map[ $field ] ] ) ? (string) $franchise[ $key_map[ $field ] ] : ''; if ( '' === $value ) { return esc_html( $atts['default'] ); } if ( 'yes' === $atts['link'] && 'phone' === $field ) { $clean = preg_replace( '/[^0-9+]/', '', $value ); return '' . esc_html( $value ) . ''; } if ( 'yes' === $atts['link'] && 'maps_url' === $field ) { return '' . esc_html__( 'Get Directions', 'a1-tools' ) . ''; } $value_out = ( 'maps_url' === $field ) ? esc_url( $value ) : esc_html( $value ); if ( ! empty( $atts['class'] ) ) { return '' . $value_out . ''; } return $value_out; } foreach ( array( 'a1tools_franchise_name', 'a1tools_franchise_phone', 'a1tools_franchise_address', 'a1tools_franchise_address_line1', 'a1tools_franchise_address_line2', 'a1tools_franchise_city', 'a1tools_franchise_state', 'a1tools_franchise_zip', 'a1tools_franchise_country', 'a1tools_franchise_maps_url', 'a1tools_franchise_latitude', 'a1tools_franchise_longitude', ) as $a1tools_ff_tag ) { add_shortcode( $a1tools_ff_tag, 'a1tools_shortcode_franchise_field' ); } /** * Per-field service shortcodes (auto-detecting). * * Service pages are WordPress pages (keyed by wp_page_id in the A1 Tools API), * so — like [a1tools_city_*] / [a1tools_location_*] — these detect the current * page via get_the_ID(). Use the optional `page_id` attribute to target a * specific service page from elsewhere. * * Registered tags -> field: * a1tools_service_title -> page_title (falls back to the WP page title) * a1tools_service_description -> description * a1tools_service_phone -> phone (link="yes" wraps in tel:) * a1tools_service_icon -> icon * a1tools_service_image -> image_url * * @since 2.7.0 * @param array $atts Shortcode attributes (page_id, default, link, class). * @param string $content Unused. * @param string $tag Invoked shortcode tag. * @return string Shortcode output. */ function a1tools_shortcode_service_field( $atts, $content, $tag ) { $atts = shortcode_atts( array( 'page_id' => '', 'default' => '', 'link' => '', 'class' => '', ), $atts, $tag ); $page_id = ! empty( $atts['page_id'] ) ? intval( $atts['page_id'] ) : intval( get_the_ID() ); $field = str_replace( 'a1tools_service_', '', $tag ); $services = a1tools_get_site_services(); $data = ( is_array( $services ) && isset( $services[ $page_id ] ) ) ? $services[ $page_id ] : array(); $key_map = array( 'title' => 'page_title', 'description' => 'description', 'phone' => 'phone', 'icon' => 'icon', 'image' => 'image_url', ); if ( ! isset( $key_map[ $field ] ) ) { return esc_html( $atts['default'] ); } $value = isset( $data[ $key_map[ $field ] ] ) ? (string) $data[ $key_map[ $field ] ] : ''; // Title falls back to the current WordPress page title. if ( 'title' === $field && '' === $value ) { $value = get_the_title( $page_id ); } if ( '' === $value ) { return esc_html( $atts['default'] ); } $class_attr = ! empty( $atts['class'] ) ? ' class="' . esc_attr( $atts['class'] ) . '"' : ''; if ( 'phone' === $field && 'yes' === $atts['link'] ) { $clean = preg_replace( '/[^0-9+]/', '', $value ); return '' . esc_html( $value ) . ''; } if ( 'image' === $field ) { $value_out = esc_url( $value ); } elseif ( 'icon' === $field ) { $value_out = wp_kses_post( $value ); } else { $value_out = esc_html( $value ); } if ( ! empty( $atts['class'] ) ) { return '' . $value_out . ''; } return $value_out; } foreach ( array( 'a1tools_service_title', 'a1tools_service_description', 'a1tools_service_phone', 'a1tools_service_icon', 'a1tools_service_image', ) as $a1tools_sf_tag ) { add_shortcode( $a1tools_sf_tag, 'a1tools_shortcode_service_field' ); }