Plugin Directory

Changeset 3473970


Ignore:
Timestamp:
03/03/2026 08:20:25 PM (4 weeks ago)
Author:
a1tools
Message:

v1.7.6 - Interactive store locator with Google Maps JS API, custom markers, store images, auto-fill features

Location:
a1-tools/trunk
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • a1-tools/trunk/a1-tools.php

    r3473954 r3473970  
    44 * Plugin URI:        https://tools.a-1chimney.com
    55 * Description:       Connects your WordPress site to the A1 Tools platform for centralized management of contact information, social media links, and business details.
    6  * Version:           1.7.5
     6 * Version:           1.7.6
    77 * Requires at least: 5.0
    88 * Requires PHP:      7.4
     
    2121
    2222// Plugin constants.
    23 define( 'A1TOOLS_VERSION', '1.7.5' );
     23define( 'A1TOOLS_VERSION', '1.7.6' );
    2424define( 'A1TOOLS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    2525define( 'A1TOOLS_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
     
    10821082/**
    10831083 * Shortcode: [a1tools_store_locator]
    1084  * Outputs a searchable store locator with cards and map embeds.
     1084 * Interactive store locator with Google Maps JS API, custom markers, and split-panel layout.
     1085 * Falls back to iframe embeds when no Google Maps API key is configured.
    10851086 *
    10861087 * @since 1.7.0
     1088 * @updated 1.7.6 Google Maps JS API with custom markers, image cards, professional layout.
    10871089 * @param array $atts Shortcode attributes.
    10881090 * @return string Shortcode output.
     
    10911093    $atts = shortcode_atts(
    10921094        array(
    1093             'map_height'   => '400',
     1095            'map_height'   => '500',
    10941096            'show_search'  => 'yes',
    10951097            'show_filters' => 'yes',
     1098            'show_images'  => 'yes',
     1099            'marker_icon'  => '',
     1100            'api_key'      => '',
    10961101            'class'        => 'a1tools-store-locator',
    10971102        ),
     
    11051110    }
    11061111
    1107     // Collect unique states for filter dropdown
     1112    // Resolve API key and marker icon (shortcode attr > WP option > empty).
     1113    $api_key     = ! empty( $atts['api_key'] ) ? $atts['api_key'] : get_option( 'a1tools_google_maps_api_key', '' );
     1114    $marker_icon = ! empty( $atts['marker_icon'] ) ? $atts['marker_icon'] : get_option( 'a1tools_marker_icon_url', '' );
     1115    $use_js_api  = ! empty( $api_key );
     1116
     1117    // Collect unique states for filter dropdown.
    11081118    $states = array();
    11091119    foreach ( $stores as $store ) {
     
    11141124    sort( $states );
    11151125
    1116     $unique_id = 'a1sl-' . wp_unique_id();
     1126    // Build JSON data for JavaScript.
     1127    $stores_json = array();
     1128    foreach ( $stores as $store ) {
     1129        $addr_parts = array();
     1130        if ( ! empty( $store['address_line1'] ) ) {
     1131            $addr_parts[] = $store['address_line1'];
     1132        }
     1133        if ( ! empty( $store['address_line2'] ) ) {
     1134            $addr_parts[] = $store['address_line2'];
     1135        }
     1136        $csz = array();
     1137        if ( ! empty( $store['city'] ) ) {
     1138            $csz[] = $store['city'];
     1139        }
     1140        if ( ! empty( $store['state'] ) ) {
     1141            $csz[] = $store['state'];
     1142        }
     1143        if ( ! empty( $store['zip'] ) ) {
     1144            $csz[] = $store['zip'];
     1145        }
     1146        if ( ! empty( $csz ) ) {
     1147            $addr_parts[] = implode( ', ', $csz );
     1148        }
     1149
     1150        $stores_json[] = array(
     1151            'id'      => intval( $store['id'] ?? 0 ),
     1152            'name'    => $store['name'] ?? '',
     1153            'address' => implode( ', ', $addr_parts ),
     1154            'city'    => strtolower( $store['city'] ?? '' ),
     1155            'state'   => strtolower( $store['state'] ?? '' ),
     1156            'zip'     => $store['zip'] ?? '',
     1157            'phone'   => $store['phone'] ?? '',
     1158            'email'   => $store['email'] ?? '',
     1159            'website' => $store['website_url'] ?? '',
     1160            'mapsUrl' => $store['google_maps_url'] ?? '',
     1161            'image'   => $store['image_url'] ?? '',
     1162            'lat'     => ! empty( $store['latitude'] ) ? floatval( $store['latitude'] ) : null,
     1163            'lng'     => ! empty( $store['longitude'] ) ? floatval( $store['longitude'] ) : null,
     1164        );
     1165    }
     1166
     1167    $unique_id  = 'a1sl-' . wp_unique_id();
     1168    $map_height = intval( $atts['map_height'] );
    11171169
    11181170    $output = '<div id="' . esc_attr( $unique_id ) . '" class="' . esc_attr( $atts['class'] ) . '">';
    11191171
    1120     // Search & filters
     1172    // ── Search & Filters ──
    11211173    if ( 'yes' === $atts['show_search'] || 'yes' === $atts['show_filters'] ) {
    11221174        $output .= '<div class="a1tools-sl-controls">';
    11231175        if ( 'yes' === $atts['show_search'] ) {
    1124             $output .= '<input type="text" class="a1tools-sl-search" placeholder="' . esc_attr__( 'Search by name, city, state, or zip...', 'a1-tools' ) . '" />';
     1176            $output .= '<div class="a1tools-sl-search-wrap">';
     1177            $output .= '<input type="text" class="a1tools-sl-search" placeholder="' . esc_attr__( 'Enter address / city', 'a1-tools' ) . '" />';
     1178            $output .= '<span class="a1tools-sl-search-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>';
     1179            $output .= '</div>';
    11251180        }
    11261181        if ( 'yes' === $atts['show_filters'] && ! empty( $states ) ) {
    11271182            $output .= '<select class="a1tools-sl-state-filter">';
    1128             $output .= '<option value="">' . esc_html__( 'All States', 'a1-tools' ) . '</option>';
     1183            $output .= '<option value="">' . esc_html__( 'States', 'a1-tools' ) . '</option>';
    11291184            foreach ( $states as $state ) {
    1130                 $output .= '<option value="' . esc_attr( $state ) . '">' . esc_html( $state ) . '</option>';
     1185                $output .= '<option value="' . esc_attr( strtolower( $state ) ) . '">' . esc_html( $state ) . '</option>';
    11311186            }
    11321187            $output .= '</select>';
     
    11351190    }
    11361191
    1137     // Two-panel layout
     1192    // ── Two-panel layout ──
    11381193    $output .= '<div class="a1tools-sl-layout">';
    11391194
    1140     // Left: Store cards
     1195    // Left: Store cards (scrollable).
    11411196    $output .= '<div class="a1tools-sl-list">';
    1142     foreach ( $stores as $store ) {
    1143         $name    = esc_html( $store['name'] ?? '' );
    1144         $addr1   = esc_html( $store['address_line1'] ?? '' );
    1145         $addr2   = esc_html( $store['address_line2'] ?? '' );
    1146         $city    = esc_html( $store['city'] ?? '' );
    1147         $state   = esc_html( $store['state'] ?? '' );
    1148         $zip     = esc_html( $store['zip'] ?? '' );
    1149         $phone   = esc_html( $store['phone'] ?? '' );
    1150         $email   = esc_html( $store['email'] ?? '' );
    1151         $web_url = esc_url( $store['website_url'] ?? '' );
    1152         $lat     = esc_attr( $store['latitude'] ?? '' );
    1153         $lng     = esc_attr( $store['longitude'] ?? '' );
    1154 
    1155         $full_addr = $addr1;
    1156         if ( $addr2 ) {
    1157             $full_addr .= ', ' . $addr2;
    1158         }
    1159         $csz = trim( $city . ( $city && $state ? ', ' : '' ) . $state . ( $zip ? ' ' . $zip : '' ) );
    1160         if ( $csz ) {
    1161             $full_addr .= ( $full_addr ? ', ' : '' ) . $csz;
    1162         }
    1163 
    1164         // Build map query for this store: name + address for place resolution.
    1165         $sq_parts = array();
    1166         if ( ! empty( $store['name'] ) ) {
    1167             $sq_parts[] = $store['name'];
    1168         }
    1169         if ( ! empty( $store['address_line1'] ) ) {
    1170             $sq_parts[] = $store['address_line1'];
    1171         }
    1172         if ( ! empty( $store['city'] ) ) {
    1173             $sq_parts[] = $store['city'];
    1174         }
    1175         if ( ! empty( $store['state'] ) ) {
    1176             $sq_parts[] = $store['state'];
    1177         }
    1178         if ( ! empty( $store['zip'] ) ) {
    1179             $sq_parts[] = $store['zip'];
    1180         }
    1181         $store_query = implode( ', ', $sq_parts );
    1182         if ( empty( $store_query ) && $lat && $lng ) {
    1183             $store_query = $lat . ',' . $lng;
    1184         }
    1185 
    1186         $output .= '<div class="a1tools-sl-card" data-name="' . esc_attr( strtolower( $store['name'] ?? '' ) ) . '" data-city="' . esc_attr( strtolower( $store['city'] ?? '' ) ) . '" data-state="' . esc_attr( strtolower( $store['state'] ?? '' ) ) . '" data-zip="' . esc_attr( $store['zip'] ?? '' ) . '" data-lat="' . $lat . '" data-lng="' . $lng . '" data-query="' . esc_attr( $store_query ) . '">';
    1187         $output .= '<h4 class="a1tools-sl-card-name">' . $name . '</h4>';
    1188         if ( $full_addr ) {
    1189             $output .= '<p class="a1tools-sl-card-address">' . esc_html( $full_addr ) . '</p>';
    1190         }
    1191         if ( $phone ) {
    1192             $phone_clean = preg_replace( '/[^0-9+]/', '', $store['phone'] );
    1193             $output .= '<p class="a1tools-sl-card-phone"><a href="tel:' . esc_attr( $phone_clean ) . '">' . $phone . '</a></p>';
    1194         }
    1195         if ( $email ) {
    1196             $output .= '<p class="a1tools-sl-card-email"><a href="mailto:' . esc_attr( $store['email'] ) . '">' . $email . '</a></p>';
    1197         }
    1198         $links = '';
    1199         if ( $lat && $lng ) {
    1200             $links .= '<a href="https://www.google.com/maps/search/?api=1&query=' . $lat . ',' . $lng . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'Get Directions', 'a1-tools' ) . '</a>';
    1201         }
    1202         if ( $web_url ) {
    1203             $links .= ( $links ? ' &middot; ' : '' ) . '<a href="' . $web_url . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'Visit Website', 'a1-tools' ) . '</a>';
    1204         }
    1205         if ( $links ) {
    1206             $output .= '<div class="a1tools-sl-card-links">' . $links . '</div>';
     1197    $output .= '<div class="a1tools-sl-results-label">' . esc_html__( 'Results', 'a1-tools' ) . '</div>';
     1198
     1199    foreach ( $stores_json as $idx => $s ) {
     1200        $data_attrs = ' data-idx="' . $idx . '"'
     1201            . ' data-name="' . esc_attr( strtolower( $s['name'] ) ) . '"'
     1202            . ' data-city="' . esc_attr( $s['city'] ) . '"'
     1203            . ' data-state="' . esc_attr( $s['state'] ) . '"'
     1204            . ' data-zip="' . esc_attr( $s['zip'] ) . '"';
     1205
     1206        $output .= '<div class="a1tools-sl-card"' . $data_attrs . '>';
     1207
     1208        // Store image.
     1209        if ( 'yes' === $atts['show_images'] && ! empty( $s['image'] ) ) {
     1210            $output .= '<div class="a1tools-sl-card-image"><img src="' . esc_url( $s['image'] ) . '" alt="' . esc_attr( $s['name'] ) . '" loading="lazy" /></div>';
     1211        }
     1212
     1213        $output .= '<div class="a1tools-sl-card-content">';
     1214        $output .= '<h4 class="a1tools-sl-card-name">' . esc_html( $s['name'] ) . '</h4>';
     1215
     1216        if ( ! empty( $s['address'] ) ) {
     1217            $output .= '<p class="a1tools-sl-card-address">' . esc_html( $s['address'] ) . '</p>';
     1218        }
     1219        if ( ! empty( $s['phone'] ) ) {
     1220            $phone_clean = preg_replace( '/[^0-9+]/', '', $s['phone'] );
     1221            $output .= '<p class="a1tools-sl-card-phone"><strong>' . esc_html__( 'Phone:', 'a1-tools' ) . '</strong> <a href="tel:' . esc_attr( $phone_clean ) . '">' . esc_html( $s['phone'] ) . '</a></p>';
     1222        }
     1223        if ( ! empty( $s['email'] ) ) {
     1224            $output .= '<p class="a1tools-sl-card-email"><strong>' . esc_html__( 'Email:', 'a1-tools' ) . '</strong> <a href="mailto:' . esc_attr( $s['email'] ) . '">' . esc_html( $s['email'] ) . '</a></p>';
     1225        }
     1226
     1227        // Links.
     1228        $dir_url = '';
     1229        if ( ! empty( $s['mapsUrl'] ) ) {
     1230            $dir_url = $s['mapsUrl'];
     1231        } elseif ( null !== $s['lat'] && null !== $s['lng'] ) {
     1232            $dir_url = 'https://www.google.com/maps/search/?api=1&query=' . $s['lat'] . ',' . $s['lng'];
     1233        }
     1234        $output .= '<div class="a1tools-sl-card-links">';
     1235        if ( ! empty( $dir_url ) ) {
     1236            $output .= '<a href="' . esc_url( $dir_url ) . '" target="_blank" rel="noopener noreferrer" class="a1tools-sl-directions-link">' . esc_html__( 'Get directions', 'a1-tools' ) . '</a>';
     1237        }
     1238        if ( ! empty( $s['website'] ) ) {
     1239            $output .= '<a href="' . esc_url( $s['website'] ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'Visit Website', 'a1-tools' ) . '</a>';
    12071240        }
    12081241        $output .= '</div>';
    1209     }
    1210     $output .= '</div>';
    1211 
    1212     // Right: Map iframe — use first store's name + address for place resolution.
    1213     $first_query = '';
    1214     foreach ( $stores as $store ) {
    1215         $fq_parts = array();
    1216         if ( ! empty( $store['name'] ) ) {
    1217             $fq_parts[] = $store['name'];
    1218         }
    1219         if ( ! empty( $store['address_line1'] ) ) {
    1220             $fq_parts[] = $store['address_line1'];
    1221         }
    1222         if ( ! empty( $store['city'] ) ) {
    1223             $fq_parts[] = $store['city'];
    1224         }
    1225         if ( ! empty( $store['state'] ) ) {
    1226             $fq_parts[] = $store['state'];
    1227         }
    1228         if ( ! empty( $store['zip'] ) ) {
    1229             $fq_parts[] = $store['zip'];
    1230         }
    1231         $fq = implode( ', ', $fq_parts );
    1232         if ( empty( $fq ) && ! empty( $store['latitude'] ) && ! empty( $store['longitude'] ) ) {
    1233             $fq = $store['latitude'] . ',' . $store['longitude'];
    1234         }
    1235         if ( ! empty( $fq ) ) {
    1236             $first_query = $fq;
    1237             break;
    1238         }
    1239     }
    1240 
     1242
     1243        $output .= '</div>'; // .card-content
     1244        $output .= '</div>'; // .card
     1245    }
     1246
     1247    $output .= '</div>'; // .a1tools-sl-list
     1248
     1249    // Right: Interactive map.
    12411250    $output .= '<div class="a1tools-sl-map">';
    1242     if ( ! empty( $first_query ) ) {
    1243         $embed_url = 'https://maps.google.com/maps?q=' . urlencode( $first_query ) . '&z=12&output=embed';
    1244         $output .= '<iframe class="a1tools-sl-map-iframe" src="' . esc_url( $embed_url ) . '" width="100%" height="' . esc_attr( $atts['map_height'] ) . '" style="border:0;border-radius:8px;" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe>';
    1245     }
     1251    $output .= '<div class="a1tools-sl-map-canvas" id="' . esc_attr( $unique_id ) . '-map" style="width:100%;height:' . esc_attr( $map_height ) . 'px;border-radius:8px;"></div>';
    12461252    $output .= '</div>';
    12471253
     
    12491255    $output .= '</div>'; // .a1tools-store-locator
    12501256
    1251     // Inline styles
     1257    // ── Inline styles ──
    12521258    $output .= '<style>
    12531259        .a1tools-store-locator { font-family: inherit; }
    1254         .a1tools-sl-controls { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
    1255         .a1tools-sl-search { flex: 1; min-width: 200px; padding: 10px 14px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
    1256         .a1tools-sl-state-filter { padding: 10px 14px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; min-width: 150px; }
     1260        .a1tools-sl-controls { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
     1261        .a1tools-sl-search-wrap { position: relative; flex: 1; min-width: 220px; }
     1262        .a1tools-sl-search { width: 100%; padding: 12px 14px 12px 40px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; box-sizing: border-box; }
     1263        .a1tools-sl-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: #999; pointer-events: none; display: flex; }
     1264        .a1tools-sl-state-filter { padding: 12px 14px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; min-width: 120px; }
    12571265        .a1tools-sl-layout { display: flex; gap: 20px; }
    1258         .a1tools-sl-list { flex: 1; max-height: ' . esc_attr( $atts['map_height'] ) . 'px; overflow-y: auto; }
     1266        .a1tools-sl-list { flex: 0 0 420px; max-height: ' . esc_attr( $map_height ) . 'px; overflow-y: auto; padding-right: 8px; }
     1267        .a1tools-sl-results-label { font-weight: 600; font-size: 1.1em; margin-bottom: 12px; }
    12591268        .a1tools-sl-map { flex: 1; min-width: 300px; }
    1260         .a1tools-sl-card { padding: 16px; border: 1px solid #eee; border-radius: 8px; margin-bottom: 12px; cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s; }
    1261         .a1tools-sl-card:hover, .a1tools-sl-card.active { border-color: #333; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
     1269        .a1tools-sl-card { display: flex; gap: 14px; padding: 16px; border: 1px solid #eee; border-radius: 8px; margin-bottom: 12px; cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s; }
     1270        .a1tools-sl-card:hover, .a1tools-sl-card.active { border-color: #e67e22; box-shadow: 0 2px 12px rgba(230,126,34,0.15); }
     1271        .a1tools-sl-card-image { flex: 0 0 160px; border-radius: 6px; overflow: hidden; }
     1272        .a1tools-sl-card-image img { width: 100%; height: 120px; object-fit: cover; display: block; }
     1273        .a1tools-sl-card-content { flex: 1; min-width: 0; }
    12621274        .a1tools-sl-card-name { margin: 0 0 6px; font-size: 1.1em; }
    12631275        .a1tools-sl-card-address, .a1tools-sl-card-phone, .a1tools-sl-card-email { margin: 0 0 4px; font-size: 0.9em; color: #666; }
    12641276        .a1tools-sl-card-phone a, .a1tools-sl-card-email a { text-decoration: none; }
    1265         .a1tools-sl-card-links { margin-top: 8px; font-size: 0.85em; }
    1266         .a1tools-sl-card-links a { text-decoration: none; color: #0066cc; }
     1277        .a1tools-sl-card-links { margin-top: 8px; font-size: 0.85em; display: flex; gap: 12px; }
     1278        .a1tools-sl-card-links a { text-decoration: none; color: #e67e22; font-weight: 500; }
    12671279        .a1tools-sl-card-links a:hover { text-decoration: underline; }
    12681280        .a1tools-sl-card.hidden { display: none; }
    12691281        @media (max-width: 768px) {
    12701282            .a1tools-sl-layout { flex-direction: column; }
    1271             .a1tools-sl-list { max-height: 400px; }
     1283            .a1tools-sl-list { flex: none; max-height: 400px; }
     1284            .a1tools-sl-card { flex-direction: column; }
     1285            .a1tools-sl-card-image { flex: none; }
     1286            .a1tools-sl-card-image img { height: 180px; }
    12721287        }
    12731288    </style>';
    12741289
    1275     // Client-side filtering JS
     1290    // ── JavaScript ──
     1291    $stores_js = wp_json_encode( $stores_json );
     1292    $marker_js = $marker_icon ? "'" . esc_js( $marker_icon ) . "'" : 'null';
     1293
     1294    if ( $use_js_api ) {
     1295        // Enqueue Google Maps JS API (only once per page).
     1296        $output .= '<script>
     1297        if (!window._a1slGmapsLoading) {
     1298            window._a1slGmapsLoading = true;
     1299            var s = document.createElement("script");
     1300            s.src = "https://maps.googleapis.com/maps/api/js?key=' . esc_js( $api_key ) . '&callback=_a1slGmapsReady&loading=async";
     1301            s.async = true; s.defer = true;
     1302            document.head.appendChild(s);
     1303            window._a1slGmapsQueue = [];
     1304            window._a1slGmapsReady = function() {
     1305                window._a1slGmapsLoaded = true;
     1306                window._a1slGmapsQueue.forEach(function(fn) { fn(); });
     1307            };
     1308        }
     1309        </script>';
     1310    }
     1311
    12761312    $output .= '<script>
    12771313    (function(){
    1278         var container = document.getElementById("' . esc_js( $unique_id ) . '");
    1279         if (!container) return;
    1280         var searchInput = container.querySelector(".a1tools-sl-search");
    1281         var stateFilter = container.querySelector(".a1tools-sl-state-filter");
    1282         var cards = container.querySelectorAll(".a1tools-sl-card");
    1283         var iframe = container.querySelector(".a1tools-sl-map-iframe");
    1284 
    1285         function filterCards() {
    1286             var q = searchInput ? searchInput.value.toLowerCase() : "";
    1287             var stateVal = stateFilter ? stateFilter.value.toLowerCase() : "";
     1314        var containerId = "' . esc_js( $unique_id ) . '";
     1315        var stores = ' . $stores_js . ';
     1316        var markerIcon = ' . $marker_js . ';
     1317        var useJsApi = ' . ( $use_js_api ? 'true' : 'false' ) . ';
     1318
     1319        function init() {
     1320            var container = document.getElementById(containerId);
     1321            if (!container) return;
     1322
     1323            var searchInput = container.querySelector(".a1tools-sl-search");
     1324            var stateFilter = container.querySelector(".a1tools-sl-state-filter");
     1325            var cards = container.querySelectorAll(".a1tools-sl-card");
     1326            var mapCanvas = document.getElementById(containerId + "-map");
     1327
     1328            var map = null;
     1329            var markers = [];
     1330            var infoWindow = null;
     1331
     1332            // ── Initialize map ──
     1333            if (useJsApi && mapCanvas && typeof google !== "undefined" && google.maps) {
     1334                initGoogleMap();
     1335            } else if (useJsApi && mapCanvas) {
     1336                // Wait for Google Maps API to load.
     1337                if (window._a1slGmapsQueue) {
     1338                    window._a1slGmapsQueue.push(initGoogleMap);
     1339                }
     1340            } else if (mapCanvas) {
     1341                // Fallback: embed iframe for first store with lat/lng.
     1342                var firstStore = stores.find(function(s) { return s.lat && s.lng; });
     1343                if (firstStore) {
     1344                    var embedQ = firstStore.name + ", " + firstStore.address;
     1345                    mapCanvas.innerHTML = \'<iframe src="https://maps.google.com/maps?q=\' + encodeURIComponent(embedQ) + \'&z=12&output=embed" width="100%" height="100%" style="border:0;border-radius:8px;" allowfullscreen loading="lazy"></iframe>\';
     1346                }
     1347            }
     1348
     1349            function initGoogleMap() {
     1350                // Compute bounds.
     1351                var bounds = new google.maps.LatLngBounds();
     1352                var hasMarkers = false;
     1353                stores.forEach(function(s) {
     1354                    if (s.lat && s.lng) {
     1355                        bounds.extend(new google.maps.LatLng(s.lat, s.lng));
     1356                        hasMarkers = true;
     1357                    }
     1358                });
     1359
     1360                var center = hasMarkers ? bounds.getCenter() : new google.maps.LatLng(39.8283, -98.5795); // US center.
     1361                map = new google.maps.Map(mapCanvas, {
     1362                    center: center,
     1363                    zoom: hasMarkers ? 5 : 4,
     1364                    mapTypeControl: true,
     1365                    streetViewControl: false,
     1366                    fullscreenControl: true,
     1367                });
     1368
     1369                infoWindow = new google.maps.InfoWindow();
     1370
     1371                // Place markers.
     1372                stores.forEach(function(s, idx) {
     1373                    if (!s.lat || !s.lng) return;
     1374
     1375                    var markerOpts = {
     1376                        position: new google.maps.LatLng(s.lat, s.lng),
     1377                        map: map,
     1378                        title: s.name,
     1379                    };
     1380
     1381                    if (markerIcon) {
     1382                        markerOpts.icon = {
     1383                            url: markerIcon,
     1384                            scaledSize: new google.maps.Size(50, 50),
     1385                            anchor: new google.maps.Point(25, 50),
     1386                        };
     1387                    }
     1388
     1389                    var marker = new google.maps.Marker(markerOpts);
     1390                    marker._storeIdx = idx;
     1391                    markers.push(marker);
     1392
     1393                    // Info window on marker click.
     1394                    marker.addListener("click", function() {
     1395                        var html = \'<div style="max-width:260px;font-family:inherit;">\';
     1396                        html += \'<strong style="font-size:14px;">\' + s.name + \'</strong>\';
     1397                        if (s.address) html += \'<br><span style="color:#666;font-size:12px;">\' + s.address + \'</span>\';
     1398                        if (s.phone) html += \'<br><span style="font-size:12px;">Phone: \' + s.phone + \'</span>\';
     1399                        var dirUrl = s.mapsUrl || (s.lat && s.lng ? "https://www.google.com/maps/search/?api=1&query=" + s.lat + "," + s.lng : "");
     1400                        if (dirUrl) html += \'<br><a href="\' + dirUrl + \'" target="_blank" style="color:#e67e22;font-size:12px;">Get directions</a>\';
     1401                        html += \'</div>\';
     1402                        infoWindow.setContent(html);
     1403                        infoWindow.open(map, marker);
     1404
     1405                        // Highlight corresponding card.
     1406                        highlightCard(idx);
     1407                    });
     1408                });
     1409
     1410                if (hasMarkers) {
     1411                    map.fitBounds(bounds);
     1412                    // Don\'t zoom too far in if only one marker.
     1413                    google.maps.event.addListenerOnce(map, "idle", function() {
     1414                        if (map.getZoom() > 15) map.setZoom(15);
     1415                    });
     1416                }
     1417            }
     1418
     1419            function highlightCard(idx) {
     1420                cards.forEach(function(c) { c.classList.remove("active"); });
     1421                var card = container.querySelector(\'.a1tools-sl-card[data-idx="\' + idx + \'"]\');
     1422                if (card) {
     1423                    card.classList.add("active");
     1424                    card.scrollIntoView({ behavior: "smooth", block: "nearest" });
     1425                }
     1426            }
     1427
     1428            // ── Card click → pan map to marker ──
    12881429            cards.forEach(function(card) {
    1289                 var name = card.getAttribute("data-name") || "";
    1290                 var city = card.getAttribute("data-city") || "";
    1291                 var state = card.getAttribute("data-state") || "";
    1292                 var zip = card.getAttribute("data-zip") || "";
    1293                 var matchSearch = !q || name.indexOf(q) !== -1 || city.indexOf(q) !== -1 || state.indexOf(q) !== -1 || zip.indexOf(q) !== -1;
    1294                 var matchState = !stateVal || state === stateVal;
    1295                 if (matchSearch && matchState) {
    1296                     card.classList.remove("hidden");
    1297                 } else {
    1298                     card.classList.add("hidden");
     1430                card.addEventListener("click", function() {
     1431                    var idx = parseInt(card.getAttribute("data-idx"));
     1432                    var s = stores[idx];
     1433                    cards.forEach(function(c) { c.classList.remove("active"); });
     1434                    card.classList.add("active");
     1435
     1436                    if (map && s && s.lat && s.lng) {
     1437                        map.panTo(new google.maps.LatLng(s.lat, s.lng));
     1438                        map.setZoom(15);
     1439                        // Open info window for this marker.
     1440                        var marker = markers.find(function(m) { return m._storeIdx === idx; });
     1441                        if (marker && infoWindow) {
     1442                            var html = \'<div style="max-width:260px;font-family:inherit;">\';
     1443                            html += \'<strong style="font-size:14px;">\' + s.name + \'</strong>\';
     1444                            if (s.address) html += \'<br><span style="color:#666;font-size:12px;">\' + s.address + \'</span>\';
     1445                            if (s.phone) html += \'<br><span style="font-size:12px;">Phone: \' + s.phone + \'</span>\';
     1446                            var dirUrl = s.mapsUrl || (s.lat && s.lng ? "https://www.google.com/maps/search/?api=1&query=" + s.lat + "," + s.lng : "");
     1447                            if (dirUrl) html += \'<br><a href="\' + dirUrl + \'" target="_blank" style="color:#e67e22;font-size:12px;">Get directions</a>\';
     1448                            html += \'</div>\';
     1449                            infoWindow.setContent(html);
     1450                            infoWindow.open(map, marker);
     1451                        }
     1452                    } else if (!useJsApi && mapCanvas) {
     1453                        var embedQ = s ? (s.name + ", " + s.address) : "";
     1454                        if (embedQ) {
     1455                            mapCanvas.innerHTML = \'<iframe src="https://maps.google.com/maps?q=\' + encodeURIComponent(embedQ) + \'&z=15&output=embed" width="100%" height="100%" style="border:0;border-radius:8px;" allowfullscreen loading="lazy"></iframe>\';
     1456                        }
     1457                    }
     1458                });
     1459            });
     1460
     1461            // ── Search & filter ──
     1462            function filterCards() {
     1463                var q = searchInput ? searchInput.value.toLowerCase() : "";
     1464                var stateVal = stateFilter ? stateFilter.value : "";
     1465                var visibleBounds = map ? new google.maps.LatLngBounds() : null;
     1466                var hasVisible = false;
     1467
     1468                cards.forEach(function(card) {
     1469                    var idx = parseInt(card.getAttribute("data-idx"));
     1470                    var s = stores[idx];
     1471                    var nameMatch = !q || s.name.toLowerCase().indexOf(q) !== -1;
     1472                    var cityMatch = !q || s.city.indexOf(q) !== -1;
     1473                    var stateMatch2 = !q || s.state.indexOf(q) !== -1;
     1474                    var zipMatch = !q || s.zip.indexOf(q) !== -1;
     1475                    var addrMatch = !q || s.address.toLowerCase().indexOf(q) !== -1;
     1476                    var matchSearch = nameMatch || cityMatch || stateMatch2 || zipMatch || addrMatch;
     1477                    var matchState = !stateVal || s.state === stateVal;
     1478
     1479                    if (matchSearch && matchState) {
     1480                        card.classList.remove("hidden");
     1481                        // Show marker.
     1482                        if (markers[idx]) markers[idx].setVisible(true);
     1483                        if (visibleBounds && s.lat && s.lng) {
     1484                            visibleBounds.extend(new google.maps.LatLng(s.lat, s.lng));
     1485                            hasVisible = true;
     1486                        }
     1487                    } else {
     1488                        card.classList.add("hidden");
     1489                        // Hide marker.
     1490                        var m = markers.find(function(mk) { return mk._storeIdx === idx; });
     1491                        if (m) m.setVisible(false);
     1492                    }
     1493                });
     1494
     1495                // Fit map to visible markers.
     1496                if (map && hasVisible) {
     1497                    map.fitBounds(visibleBounds);
     1498                    google.maps.event.addListenerOnce(map, "idle", function() {
     1499                        if (map.getZoom() > 15) map.setZoom(15);
     1500                    });
    12991501                }
    1300             });
    1301         }
    1302 
    1303         if (searchInput) searchInput.addEventListener("input", filterCards);
    1304         if (stateFilter) stateFilter.addEventListener("change", filterCards);
    1305 
    1306         cards.forEach(function(card) {
    1307             card.addEventListener("click", function() {
    1308                 cards.forEach(function(c) { c.classList.remove("active"); });
    1309                 card.classList.add("active");
    1310                 var query = card.getAttribute("data-query");
    1311                 if (iframe && query) {
    1312                     iframe.src = "https://maps.google.com/maps?q=" + encodeURIComponent(query) + "&z=15&output=embed";
    1313                 }
    1314             });
    1315         });
     1502            }
     1503
     1504            if (searchInput) searchInput.addEventListener("input", filterCards);
     1505            if (stateFilter) stateFilter.addEventListener("change", filterCards);
     1506        }
     1507
     1508        // Run init when DOM ready.
     1509        if (document.readyState === "loading") {
     1510            document.addEventListener("DOMContentLoaded", init);
     1511        } else {
     1512            init();
     1513        }
    13161514    })();
    13171515    </script>';
     
    22272425        'default'           => 0,
    22282426        'sanitize_callback' => 'absint',
     2427    ) );
     2428
     2429    // Google Maps JavaScript API key for interactive store locator.
     2430    register_setting( 'a1tools_settings', 'a1tools_google_maps_api_key', array(
     2431        'type'              => 'string',
     2432        'default'           => '',
     2433        'sanitize_callback' => 'sanitize_text_field',
     2434    ) );
     2435
     2436    // Custom marker icon URL for the store locator map.
     2437    register_setting( 'a1tools_settings', 'a1tools_marker_icon_url', array(
     2438        'type'              => 'string',
     2439        'default'           => '',
     2440        'sanitize_callback' => 'esc_url_raw',
    22292441    ) );
    22302442}
     
    24642676
    24652677            <?php submit_button( __( 'Save Form Settings', 'a1-tools' ) ); ?>
     2678        </form>
     2679
     2680        <hr>
     2681
     2682        <h2><?php esc_html_e( 'Store Locator Map Settings', 'a1-tools' ); ?></h2>
     2683        <form method="post" action="options.php">
     2684            <?php settings_fields( 'a1tools_settings' ); ?>
     2685            <table class="form-table">
     2686                <tr>
     2687                    <th scope="row">
     2688                        <label for="a1tools_google_maps_api_key"><?php esc_html_e( 'Google Maps API Key', 'a1-tools' ); ?></label>
     2689                    </th>
     2690                    <td>
     2691                        <input type="text" id="a1tools_google_maps_api_key" name="a1tools_google_maps_api_key"
     2692                            value="<?php echo esc_attr( get_option( 'a1tools_google_maps_api_key', '' ) ); ?>"
     2693                            class="regular-text" placeholder="AIza...">
     2694                        <p class="description">
     2695                            <?php esc_html_e( 'Required for the interactive store locator map with custom markers. Get a key from the Google Cloud Console (enable Maps JavaScript API).', 'a1-tools' ); ?>
     2696                        </p>
     2697                    </td>
     2698                </tr>
     2699                <tr>
     2700                    <th scope="row">
     2701                        <label for="a1tools_marker_icon_url"><?php esc_html_e( 'Custom Marker Icon URL', 'a1-tools' ); ?></label>
     2702                    </th>
     2703                    <td>
     2704                        <input type="url" id="a1tools_marker_icon_url" name="a1tools_marker_icon_url"
     2705                            value="<?php echo esc_attr( get_option( 'a1tools_marker_icon_url', '' ) ); ?>"
     2706                            class="regular-text" placeholder="https://example.com/marker-icon.png">
     2707                        <p class="description">
     2708                            <?php esc_html_e( 'URL to a custom marker icon (PNG, recommended 50x50px). Leave empty for the default Google Maps pin.', 'a1-tools' ); ?>
     2709                        </p>
     2710                    </td>
     2711                </tr>
     2712            </table>
     2713            <?php submit_button( __( 'Save Map Settings', 'a1-tools' ) ); ?>
    24662714        </form>
    24672715
  • a1-tools/trunk/includes/class-a1-tools-stores-widget.php

    r3473863 r3473970  
    88 * @package A1_Tools
    99 * @since   1.7.0
    10  * @updated 1.7.2 Full styling controls (border, shadow, typography, hover, spacing).
     10 * @updated 1.7.6 Google Maps JS API with custom markers, image cards, professional layout.
    1111 */
    1212
     
    9191                'return_value' => 'yes',
    9292                'default'      => 'yes',
     93            )
     94        );
     95
     96        $this->add_control(
     97            'show_images',
     98            array(
     99                'label'        => __( 'Show Store Images', 'a1-tools' ),
     100                'type'         => \Elementor\Controls_Manager::SWITCHER,
     101                'label_on'     => __( 'Yes', 'a1-tools' ),
     102                'label_off'    => __( 'No', 'a1-tools' ),
     103                'return_value' => 'yes',
     104                'default'      => 'yes',
     105            )
     106        );
     107
     108        $this->add_control(
     109            'marker_icon',
     110            array(
     111                'label'       => __( 'Custom Marker Icon URL', 'a1-tools' ),
     112                'type'        => \Elementor\Controls_Manager::TEXT,
     113                'default'     => '',
     114                'placeholder' => __( 'Uses plugin settings or default pin', 'a1-tools' ),
     115                'description' => __( 'URL to a custom map marker image (PNG, ~50x50px). Leave empty to use Plugin Settings value.', 'a1-tools' ),
     116                'label_block' => true,
     117            )
     118        );
     119
     120        $this->add_control(
     121            'api_key',
     122            array(
     123                'label'       => __( 'Google Maps API Key', 'a1-tools' ),
     124                'type'        => \Elementor\Controls_Manager::TEXT,
     125                'default'     => '',
     126                'placeholder' => __( 'Uses plugin settings value', 'a1-tools' ),
     127                'description' => __( 'Override the Google Maps API key for this widget. Leave empty to use Plugin Settings value.', 'a1-tools' ),
     128                'label_block' => true,
    93129            )
    94130        );
     
    427463
    428464        echo a1tools_shortcode_store_locator( array( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
    429             'map_height'   => $settings['map_height']['size'] ?? '400',
     465            'map_height'   => $settings['map_height']['size'] ?? '500',
    430466            'show_search'  => $settings['show_search'] ?? 'yes',
    431467            'show_filters' => $settings['show_filters'] ?? 'yes',
     468            'show_images'  => $settings['show_images'] ?? 'yes',
     469            'marker_icon'  => $settings['marker_icon'] ?? '',
     470            'api_key'      => $settings['api_key'] ?? '',
    432471        ) );
    433472    }
  • a1-tools/trunk/readme.txt

    r3473954 r3473970  
    44Requires at least: 5.0
    55Tested up to: 6.9
    6 Stable tag: 1.7.5
     6Stable tag: 1.7.6
    77Requires PHP: 7.4
    88License: GPLv2 or later
     
    151151
    152152== Changelog ==
     153
     154= 1.7.6 =
     155* New: Interactive store locator powered by Google Maps JavaScript API with custom markers
     156* New: Professional split-panel layout with scrollable store cards (with images) and interactive map
     157* New: Custom marker icon support (set via Plugin Settings or Elementor widget)
     158* New: Google Maps API Key setting in Plugin Settings page
     159* New: Store editor now includes Full Address auto-fill, Google Maps URL field, and auto lat/lng extraction
     160* New: google_maps_url field added to group_stores database table
     161* Store cards now show images, phone, email, directions link, and website link
     162* Map pans and zooms when clicking store cards; info windows show on marker click
     163* Search filters both card list and map markers simultaneously
     164* Falls back to iframe embeds gracefully when no Google Maps API key is configured
    153165
    154166= 1.7.5 =
Note: See TracChangeset for help on using the changeset viewer.