=' ) ) { return; } if ( version_compare( $installed, '2.4.0', '<' ) ) { // Removed in 2.4.0: target board/group IDs (never sent to server; routing is server-side). delete_option( 'a1tools_form_board_id' ); delete_option( 'a1tools_form_group_id' ); } if ( version_compare( $installed, '2.4.2', '<' ) ) { // 2.4.2 fixes a TTL bug where get_option('a1tools_cache_expiry') was // multiplied by MINUTE_IN_SECONDS even though the admin form already // stored seconds. The "No caching (0)" option in particular passed 0 // to set_transient(), which WordPress treats as "no expiration" — so // every cached payload (site variables, city pages, franchises, etc.) // could persist indefinitely. Wipe all a1tools transients here so // the corrected logic rebuilds them with the right TTL. if ( function_exists( 'a1tools_clear_cache' ) ) { a1tools_clear_cache(); } } if ( version_compare( $installed, '2.5.17', '<' ) ) { // 2.5.17 changed the default Live Chat greeting from // "Hi! Thanks for reaching out — a team member will be with // you in just a moment." to a comma-joined "as soon as // possible" version. Elementor saves the resolved control // value into _elementor_data postmeta the first time a user // opens the widget panel, so most sites have the old literal // string baked into the JSON payload of every page that uses // the chat widget. This migration rewrites any postmeta row // containing the old phrase in-place. JSON-encoded form: the // em-dash is `—`. a1tools_migrate_chat_greeting_2_5_17(); } if ( version_compare( $installed, '2.5.25', '<' ) ) { // 2.5.25: the "No caching (0)" option in the admin dropdown is // removed because cache_expiry=0 was the root cause of the // 2026-05-25 outage — a few sites with caching disabled // generated enough request volume to exhaust the central // server's PHP-FPM pool. Coerce any existing 0 to the new // 30-minute default so the option matches what the dropdown // can actually save going forward. $existing_ttl = get_option( 'a1tools_cache_expiry', null ); if ( null !== $existing_ttl && (int) $existing_ttl === 0 ) { update_option( 'a1tools_cache_expiry', A1TOOLS_DEFAULT_CACHE_EXPIRY, false ); } } if ( version_compare( $installed, '2.5.26', '<' ) ) { // 2.5.26: register this site's cache-invalidation secret with // the central server so admin saves there can push transient // invalidations here in real time. Best-effort — failures get // retried automatically on the next upgrade pass, and the // plugin's local TTL is still the safety net. a1tools_register_invalidation_with_server(); } if ( version_compare( $installed, '2.6.0', '<' ) ) { // 2.6.0: the Review Funnel adds a new `review_url` site variable. // Flush the cached variables so the widget/shortcode pick it up on // the next page load instead of waiting for the TTL to expire. if ( function_exists( 'a1tools_clear_cache' ) ) { a1tools_clear_cache(); } } if ( version_compare( $installed, '3.3.3', '<' ) ) { // 3.3.3: A1 Team bios render from the team payload's `bio` field. Any // site still holding a team transient cached before `bio` was served // shows no bios until the TTL lapses, which reads as "Show Bio is // broken". Flush so the next page load refetches with `bio` present. // // This also repairs the 3.3.1/3.3.2 version-constant mismatch: // A1TOOLS_VERSION said 3.3.1 while the plugin header said 3.3.2, so // `a1tools_db_version` stuck at 3.3.1 and every upgrade pass re-ran. if ( function_exists( 'a1tools_clear_cache' ) ) { a1tools_clear_cache(); } } update_option( 'a1tools_db_version', A1TOOLS_VERSION, false ); } /** * Register this site's cache-invalidation secret with the central server. * * Generates a 64-char hex secret on first call (256 bits of entropy) and * persists it locally as the `a1tools_invalidate_secret` option. Then * POSTs it to `https://app.a-1chimney.com/api/plugin_register.php` * along with `site_url()` so the server stores it in * `wordpress_sites.cache_invalidation_secret` keyed to this site. * * If the central server already has a secret for this URL (e.g., a * previous registration ran), this call sends the prior secret in the * X-Existing-Secret header so the server can verify we're the same * install and rotate to the new value. * * Best-effort: network failures don't throw. The 2.5.26 upgrade hook * calls this once, and if it fails the next plugin upgrade pass will * retry. The CDN cache (5min) + transient TTL (30min) are still the * safety net even if registration never succeeds. * * @since 2.5.26 * @return bool true on successful registration, false otherwise. */ function a1tools_register_invalidation_with_server() { $existing_secret = (string) get_option( 'a1tools_invalidate_secret', '' ); $secret = $existing_secret; if ( '' === $secret ) { // First registration: generate fresh entropy. try { $secret = bin2hex( random_bytes( 32 ) ); // 64 hex chars, 256 bits. } catch ( Exception $e ) { // random_bytes can throw if no entropy source is available // — extremely rare on real servers, but handle it cleanly. if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'A1 Tools registration: could not generate secret: ' . $e->getMessage() ); } return false; } update_option( 'a1tools_invalidate_secret', $secret, false ); } $args = array( 'timeout' => 10, 'sslverify' => true, 'body' => array( 'url' => site_url(), 'secret' => $secret, 'plugin_version' => A1TOOLS_VERSION, ), ); if ( '' !== $existing_secret ) { // Re-registration. Prove we know the current secret so the // server lets us rotate. (No-op the first time around since // secret was just generated, but harmless to send.) $args['headers'] = array( 'X-Existing-Secret' => $existing_secret, ); } $response = wp_remote_post( 'https://app.a-1chimney.com/api/plugin_register.php', $args ); if ( is_wp_error( $response ) ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'A1 Tools registration HTTP error: ' . $response->get_error_message() ); } return false; } $code = wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'A1 Tools registration HTTP ' . $code . ': ' . wp_remote_retrieve_body( $response ) ); } return false; } return true; } /** * One-shot migration for 2.5.17 — rewrite the old Live Chat greeting * inside Elementor's _elementor_data postmeta JSON. Safe to re-run * (string replace is idempotent on already-migrated rows). Also * flushes Elementor's per-page CSS cache so the new text shows * without an editor save. * * @since 2.5.17 * @return void */ function a1tools_migrate_chat_greeting_2_5_17() { global $wpdb; // Elementor stores _elementor_data as JSON, which may encode the // em-dash either as the literal UTF-8 character `—` (rare — // happens when imported from a raw export) or as the JSON // Unicode-escape `—` (default for wp_json_encode, which is // what production Elementor actually writes). Migrate both — the // `\\u2014` here is a PHP single-quoted literal that resolves to // the 6-byte ASCII sequence `—` in the compiled string, // matching the raw bytes MySQL stores. $variants = array( '"greeting_message":"Hi! Thanks for reaching out — a team member will be with you in just a moment."' => '"greeting_message":"Hi! Thanks for reaching out, a team member will be with you as soon as possible."', '"greeting_message":"Hi! Thanks for reaching out \\u2014 a team member will be with you in just a moment."' => '"greeting_message":"Hi! Thanks for reaching out, a team member will be with you as soon as possible."', ); $updated = 0; foreach ( $variants as $old_json => $new_json ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value LIKE %s LIMIT 1000", '_elementor_data', '%' . $wpdb->esc_like( $old_json ) . '%' ) ); foreach ( $rows as $row ) { $new_value = str_replace( $old_json, $new_json, $row->meta_value ); if ( $new_value !== $row->meta_value ) { update_metadata_by_mid( 'post', $row->meta_id, $new_value ); $updated++; } } } // Flush Elementor's per-page CSS cache so the new text shows up // without each page needing a manual "Update" in the editor. if ( class_exists( '\Elementor\Plugin' ) && \Elementor\Plugin::$instance ) { try { if ( isset( \Elementor\Plugin::$instance->files_manager ) ) { \Elementor\Plugin::$instance->files_manager->clear_cache(); } } catch ( \Throwable $e ) {/* best-effort */} } if ( $updated > 0 ) { error_log( sprintf( '[A1 Tools] 2.5.17 greeting migration: rewrote %d Elementor postmeta row(s)', $updated ) ); } } add_action( 'admin_init', 'a1tools_maybe_upgrade' ); // Split-file includes (data, shortcodes, REST, Yoast, Elementor, admin, forms). require_once A1TOOLS_PLUGIN_DIR . 'includes/a1-tools-hours-grouping.php'; require_once A1TOOLS_PLUGIN_DIR . 'includes/a1-tools-data.php'; require_once A1TOOLS_PLUGIN_DIR . 'includes/a1-tools-shortcodes.php'; require_once A1TOOLS_PLUGIN_DIR . 'includes/a1-tools-rest-api.php'; require_once A1TOOLS_PLUGIN_DIR . 'includes/a1-tools-yoast.php'; require_once A1TOOLS_PLUGIN_DIR . 'includes/a1-tools-elementor-init.php'; require_once A1TOOLS_PLUGIN_DIR . 'includes/a1-tools-admin.php'; require_once A1TOOLS_PLUGIN_DIR . 'includes/a1-tools-forms.php'; require_once A1TOOLS_PLUGIN_DIR . 'includes/a1-tools-review-float.php'; // ============================================================================ // SOCIAL ICONS WIDGET // ============================================================================ /** * A1 Tools Social Icons Widget. * * @since 1.2.0 */ class A1Tools_Social_Icons_Widget extends WP_Widget { /** * Constructor. */ public function __construct() { parent::__construct( 'a1tools_social_icons', __( 'A1 Tools Social Icons', 'a1-tools' ), array( 'description' => __( 'Display social media icons from A1 Tools with customizable styling.', 'a1-tools' ), 'classname' => 'a1tools-social-widget', ) ); } /** * Frontend display. * * @param array $args Widget arguments. * @param array $instance Widget instance. */ public function widget( $args, $instance ) { $vars = a1tools_get_site_variables(); if ( ! $vars ) { return; } // Widget settings with defaults. $title = ! empty( $instance['title'] ) ? $instance['title'] : ''; $shape = ! empty( $instance['shape'] ) ? $instance['shape'] : 'rounded'; $style = ! empty( $instance['style'] ) ? $instance['style'] : 'default'; $size = ! empty( $instance['size'] ) ? (int) $instance['size'] : 40; $icon_size = ! empty( $instance['icon_size'] ) ? (int) $instance['icon_size'] : 18; $spacing = ! empty( $instance['spacing'] ) ? (int) $instance['spacing'] : 8; $alignment = ! empty( $instance['alignment'] ) ? $instance['alignment'] : 'left'; $custom_color = ! empty( $instance['custom_color'] ) ? $instance['custom_color'] : ''; $hover_effect = ! empty( $instance['hover_effect'] ) ? $instance['hover_effect'] : 'scale'; // Platform configurations with official colors. $platforms = array( 'facebook_url' => array( 'label' => 'Facebook', 'icon' => 'fab fa-facebook-f', 'color' => '#1877F2' ), 'instagram_url' => array( 'label' => 'Instagram', 'icon' => 'fab fa-instagram', 'color' => '#E4405F' ), 'youtube_url' => array( 'label' => 'YouTube', 'icon' => 'fab fa-youtube', 'color' => '#FF0000' ), 'twitter_url' => array( 'label' => 'X (Twitter)', 'icon' => 'svg-x', 'color' => '#000000' ), 'linkedin_url' => array( 'label' => 'LinkedIn', 'icon' => 'fab fa-linkedin-in', 'color' => '#0A66C2' ), 'tiktok_url' => array( 'label' => 'TikTok', 'icon' => 'fab fa-tiktok', 'color' => '#000000' ), 'yelp_url' => array( 'label' => 'Yelp', 'icon' => 'fab fa-yelp', 'color' => '#D32323' ), 'google_business_url' => array( 'label' => 'Google', 'icon' => 'fab fa-google', 'color' => '#4285F4' ), 'pinterest_url' => array( 'label' => 'Pinterest', 'icon' => 'fab fa-pinterest-p', 'color' => '#E60023' ), 'bbb_url' => array( 'label' => 'BBB', 'icon' => 'fas fa-shield-alt', 'color' => '#006CB7' ), 'nextdoor_url' => array( 'label' => 'Nextdoor', 'icon' => 'fas fa-home', 'color' => '#8ED500' ), 'houzz_url' => array( 'label' => 'Houzz', 'icon' => 'fab fa-houzz', 'color' => '#4DBC15' ), 'angi_url' => array( 'label' => 'Angi', 'icon' => 'fas fa-tools', 'color' => '#FF6153' ), 'thumbtack_url' => array( 'label' => 'Thumbtack', 'icon' => 'fas fa-thumbtack', 'color' => '#009FD9' ), 'homeadvisor_url' => array( 'label' => 'HomeAdvisor', 'icon' => 'fas fa-home', 'color' => '#EF6C00' ), ); // Check if any platforms have URLs. $has_platforms = false; foreach ( $platforms as $key => $data ) { if ( ! empty( $vars[ $key ] ) ) { $has_platforms = true; break; } } if ( ! $has_platforms ) { return; } // Generate unique ID for this widget instance. $widget_id = 'a1tools-social-' . $this->number; // Build inline styles. $container_styles = array( 'text-align' => $alignment, ); $link_styles = array( 'width' => $size . 'px', 'height' => $size . 'px', 'margin' => '0 ' . ( $spacing / 2 ) . 'px ' . $spacing . 'px', 'font-size' => $icon_size . 'px', 'display' => 'inline-flex', 'align-items' => 'center', 'justify-content' => 'center', 'text-decoration' => 'none', 'transition' => 'all 0.3s ease', ); // Shape styles. switch ( $shape ) { case 'circle': $link_styles['border-radius'] = '50%'; break; case 'square': $link_styles['border-radius'] = '0'; break; case 'rounded': default: $link_styles['border-radius'] = '8px'; break; } // Output widget. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Widget args are pre-escaped by WordPress. echo $args['before_widget']; if ( $title ) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Widget args are pre-escaped by WordPress. echo $args['before_title'] . esc_html( apply_filters( 'widget_title', $title ) ) . $args['after_title']; } // Build CSS for this widget instance. $css = '#' . esc_attr( $widget_id ) . ' { '; foreach ( $container_styles as $prop => $val ) { $css .= esc_attr( $prop ) . ':' . esc_attr( $val ) . ';'; } $css .= '}'; $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { '; foreach ( $link_styles as $prop => $val ) { $css .= esc_attr( $prop ) . ':' . esc_attr( $val ) . ';'; } $css .= '}'; // Style-specific CSS. if ( 'default' === $style || 'official' === $style ) { // Official colors or custom color background. $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { color: #fff; }'; } elseif ( 'outline' === $style ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { background: transparent; border: 2px solid; }'; } elseif ( 'minimal' === $style ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { background: transparent; }'; } elseif ( 'light' === $style ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { background: #f5f5f5; }'; } elseif ( 'dark' === $style ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon { background: #333; color: #fff; }'; } // Hover effects. if ( 'scale' === $hover_effect ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon:hover { transform: scale(1.1); }'; } elseif ( 'lift' === $hover_effect ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon:hover { transform: translateY(-3px); box-shadow: 0 4px 12px rgba(0,0,0,0.2); }'; } elseif ( 'rotate' === $hover_effect ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon:hover { transform: rotate(15deg); }'; } elseif ( 'pulse' === $hover_effect ) { $css .= '#' . esc_attr( $widget_id ) . ' .a1tools-social-icon:hover { animation: a1tools-pulse 0.5s ease; }'; $css .= '@keyframes a1tools-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.15); } }'; } // Add CSS using WordPress enqueue system. wp_register_style( 'a1tools-social-widget-' . $this->number, false, array(), A1TOOLS_VERSION ); wp_enqueue_style( 'a1tools-social-widget-' . $this->number ); wp_add_inline_style( 'a1tools-social-widget-' . $this->number, $css ); // Output icons container. echo '
'; foreach ( $platforms as $key => $data ) { if ( ! empty( $vars[ $key ] ) ) { // Determine background/border color. $item_color = $custom_color ? $custom_color : ( 'official' === $style || 'default' === $style ? $data['color'] : '' ); $inline_style = ''; if ( 'default' === $style || 'official' === $style ) { $inline_style = 'background-color:' . esc_attr( $item_color ) . ';'; } elseif ( 'outline' === $style ) { $icon_color = $custom_color ? $custom_color : $data['color']; $inline_style = 'border-color:' . esc_attr( $icon_color ) . ';color:' . esc_attr( $icon_color ) . ';'; } elseif ( 'minimal' === $style ) { $icon_color = $custom_color ? $custom_color : $data['color']; $inline_style = 'color:' . esc_attr( $icon_color ) . ';'; } elseif ( 'light' === $style ) { $icon_color = $custom_color ? $custom_color : $data['color']; $inline_style = 'color:' . esc_attr( $icon_color ) . ';'; } echo ''; echo wp_kses( a1tools_get_icon_html( $data['icon'] ), a1tools_icon_allowed_tags() ); echo '' . esc_html( $data['label'] ) . ''; echo ''; } } echo '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Widget args are pre-escaped by WordPress. echo $args['after_widget']; } /** * Backend form. * * @param array $instance Widget instance. */ public function form( $instance ) { $title = ! empty( $instance['title'] ) ? $instance['title'] : ''; $shape = ! empty( $instance['shape'] ) ? $instance['shape'] : 'rounded'; $style = ! empty( $instance['style'] ) ? $instance['style'] : 'default'; $size = ! empty( $instance['size'] ) ? (int) $instance['size'] : 40; $icon_size = ! empty( $instance['icon_size'] ) ? (int) $instance['icon_size'] : 18; $spacing = ! empty( $instance['spacing'] ) ? (int) $instance['spacing'] : 8; $alignment = ! empty( $instance['alignment'] ) ? $instance['alignment'] : 'left'; $custom_color = ! empty( $instance['custom_color'] ) ? $instance['custom_color'] : ''; $hover_effect = ! empty( $instance['hover_effect'] ) ? $instance['hover_effect'] : 'scale'; ?>