Changeset 3473970
- Timestamp:
- 03/03/2026 08:20:25 PM (4 weeks ago)
- Location:
- a1-tools/trunk
- Files:
-
- 3 edited
-
a1-tools.php (modified) (10 diffs)
-
includes/class-a1-tools-stores-widget.php (modified) (3 diffs)
-
readme.txt (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
a1-tools/trunk/a1-tools.php
r3473954 r3473970 4 4 * Plugin URI: https://tools.a-1chimney.com 5 5 * 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. 56 * Version: 1.7.6 7 7 * Requires at least: 5.0 8 8 * Requires PHP: 7.4 … … 21 21 22 22 // Plugin constants. 23 define( 'A1TOOLS_VERSION', '1.7. 5' );23 define( 'A1TOOLS_VERSION', '1.7.6' ); 24 24 define( 'A1TOOLS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); 25 25 define( 'A1TOOLS_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); … … 1082 1082 /** 1083 1083 * 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. 1085 1086 * 1086 1087 * @since 1.7.0 1088 * @updated 1.7.6 Google Maps JS API with custom markers, image cards, professional layout. 1087 1089 * @param array $atts Shortcode attributes. 1088 1090 * @return string Shortcode output. … … 1091 1093 $atts = shortcode_atts( 1092 1094 array( 1093 'map_height' => ' 400',1095 'map_height' => '500', 1094 1096 'show_search' => 'yes', 1095 1097 'show_filters' => 'yes', 1098 'show_images' => 'yes', 1099 'marker_icon' => '', 1100 'api_key' => '', 1096 1101 'class' => 'a1tools-store-locator', 1097 1102 ), … … 1105 1110 } 1106 1111 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. 1108 1118 $states = array(); 1109 1119 foreach ( $stores as $store ) { … … 1114 1124 sort( $states ); 1115 1125 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'] ); 1117 1169 1118 1170 $output = '<div id="' . esc_attr( $unique_id ) . '" class="' . esc_attr( $atts['class'] ) . '">'; 1119 1171 1120 // Search & filters1172 // ── Search & Filters ── 1121 1173 if ( 'yes' === $atts['show_search'] || 'yes' === $atts['show_filters'] ) { 1122 1174 $output .= '<div class="a1tools-sl-controls">'; 1123 1175 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>'; 1125 1180 } 1126 1181 if ( 'yes' === $atts['show_filters'] && ! empty( $states ) ) { 1127 1182 $output .= '<select class="a1tools-sl-state-filter">'; 1128 $output .= '<option value="">' . esc_html__( ' AllStates', 'a1-tools' ) . '</option>';1183 $output .= '<option value="">' . esc_html__( 'States', 'a1-tools' ) . '</option>'; 1129 1184 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>'; 1131 1186 } 1132 1187 $output .= '</select>'; … … 1135 1190 } 1136 1191 1137 // Two-panel layout1192 // ── Two-panel layout ── 1138 1193 $output .= '<div class="a1tools-sl-layout">'; 1139 1194 1140 // Left: Store cards 1195 // Left: Store cards (scrollable). 1141 1196 $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 ? ' · ' : '' ) . '<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>'; 1207 1240 } 1208 1241 $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. 1241 1250 $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>'; 1246 1252 $output .= '</div>'; 1247 1253 … … 1249 1255 $output .= '</div>'; // .a1tools-store-locator 1250 1256 1251 // Inline styles1257 // ── Inline styles ── 1252 1258 $output .= '<style> 1253 1259 .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; } 1257 1265 .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; } 1259 1268 .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; } 1262 1274 .a1tools-sl-card-name { margin: 0 0 6px; font-size: 1.1em; } 1263 1275 .a1tools-sl-card-address, .a1tools-sl-card-phone, .a1tools-sl-card-email { margin: 0 0 4px; font-size: 0.9em; color: #666; } 1264 1276 .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; } 1267 1279 .a1tools-sl-card-links a:hover { text-decoration: underline; } 1268 1280 .a1tools-sl-card.hidden { display: none; } 1269 1281 @media (max-width: 768px) { 1270 1282 .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; } 1272 1287 } 1273 1288 </style>'; 1274 1289 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 1276 1312 $output .= '<script> 1277 1313 (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 ── 1288 1429 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 }); 1299 1501 } 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 } 1316 1514 })(); 1317 1515 </script>'; … … 2227 2425 'default' => 0, 2228 2426 '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', 2229 2441 ) ); 2230 2442 } … … 2464 2676 2465 2677 <?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' ) ); ?> 2466 2714 </form> 2467 2715 -
a1-tools/trunk/includes/class-a1-tools-stores-widget.php
r3473863 r3473970 8 8 * @package A1_Tools 9 9 * @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. 11 11 */ 12 12 … … 91 91 'return_value' => 'yes', 92 92 '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, 93 129 ) 94 130 ); … … 427 463 428 464 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', 430 466 'show_search' => $settings['show_search'] ?? 'yes', 431 467 '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'] ?? '', 432 471 ) ); 433 472 } -
a1-tools/trunk/readme.txt
r3473954 r3473970 4 4 Requires at least: 5.0 5 5 Tested up to: 6.9 6 Stable tag: 1.7. 56 Stable tag: 1.7.6 7 7 Requires PHP: 7.4 8 8 License: GPLv2 or later … … 151 151 152 152 == 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 153 165 154 166 = 1.7.5 =
Note: See TracChangeset
for help on using the changeset viewer.