Add ghost text autocomplete location search to homepage hero

Replaces community dropdown with text input supporting city/ZIP search:
- Ghost text autocomplete shows inline suggestion as user types
- Tab to accept, auto-fill on blur, Enter uses partial match
- Geolocation button for "Use My Location" searches
- AJAX endpoint returns MN/IA cities and zipcodes with 1-hour cache
- MLS query now supports lat/lng/radius for distance-based filtering
- Updated Census Bureau 2023 Gazetteer data (32,329 US cities)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hanson.xyz Dev
2025-12-29 00:50:45 -06:00
parent 183e1b92c9
commit 5ca2e29c72
13 changed files with 33288 additions and 29965 deletions
@@ -0,0 +1,17 @@
# Geo Data Sources
## us_cities.csv
US Census Bureau 2023 Gazetteer Places File
- **Source:** https://www2.census.gov/geo/docs/maps-data/data/gazetteer/2023_Gazetteer/2023_Gaz_place_national.zip
- **Records:** 32,329 cities/places
- **Last Updated:** December 2025
- **Format:** CSV with columns: ID, STATE_CODE, STATE_NAME, CITY, COUNTY, LATITUDE, LONGITUDE
## us_zipcodes.csv
US ZIP code centroids with latitude/longitude coordinates.
- **Records:** 33,144 ZIP codes
- **Format:** CSV with columns: zipcode, latitude, longitude
+32329 -29880
View File
File diff suppressed because it is too large Load Diff
+195 -7
View File
@@ -50,6 +50,85 @@ class MLS_Query {
return "(street_number IS NULL OR (street_number != 'TBD' AND street_number NOT LIKE 'TBD %'))";
}
/**
* Get coordinates for a zip code from geo table
*
* @param string $zipcode The zip code to look up
* @return array|null Array with 'latitude' and 'longitude' or null if not found
*/
private function get_zipcode_coordinates($zipcode) {
global $wpdb;
$table = $this->db->geo_zipcodes_table();
$result = $wpdb->get_row($wpdb->prepare(
"SELECT latitude, longitude FROM {$table} WHERE zipcode = %s",
$zipcode
));
if ($result) {
return array(
'latitude' => (float) $result->latitude,
'longitude' => (float) $result->longitude,
);
}
return null;
}
/**
* Get coordinates for a city from geo table
* Searches for city in MN and IA states (matching MLS_ALLOWED_STATES)
*
* @param string $city The city name to look up
* @return array|null Array with 'latitude' and 'longitude' or null if not found
*/
private function get_city_coordinates($city) {
global $wpdb;
$table = $this->db->geo_cities_table();
// Search in allowed states (MN, IA)
$allowed_states = defined('MLS_ALLOWED_STATES') ? MLS_ALLOWED_STATES : array('MN', 'IA');
$placeholders = implode(',', array_fill(0, count($allowed_states), '%s'));
$values = array_merge(array($city), $allowed_states);
$result = $wpdb->get_row($wpdb->prepare(
"SELECT latitude, longitude FROM {$table} WHERE city = %s AND state_code IN ({$placeholders}) LIMIT 1",
$values
));
if ($result) {
return array(
'latitude' => (float) $result->latitude,
'longitude' => (float) $result->longitude,
);
}
return null;
}
/**
* Build SQL for distance-based filtering using Haversine formula
* Returns properties within specified miles of a center point
*
* @param float $lat Center latitude
* @param float $lng Center longitude
* @param float $miles Radius in miles
* @return string SQL expression for distance filter
*/
private function get_distance_filter_sql($lat, $lng, $miles) {
// Haversine formula: distance in miles
// 3959 is Earth's radius in miles
return sprintf(
"(3959 * acos(cos(radians(%f)) * cos(radians(latitude)) * cos(radians(longitude) - radians(%f)) + sin(radians(%f)) * sin(radians(latitude)))) <= %f",
$lat,
$lng,
$lat,
$miles
);
}
/**
* Get properties matching criteria
*
@@ -65,6 +144,9 @@ class MLS_Query {
'city' => null,
'county' => null,
'postal_code' => null,
'center_lat' => null, // Latitude for radius search
'center_lng' => null, // Longitude for radius search
'radius' => 30, // Miles for radius search (default 30)
'min_price' => null,
'max_price' => null,
'min_beds' => null,
@@ -125,21 +207,57 @@ class MLS_Query {
$values[] = $args['property_type'];
}
// City and postal_code are mutually exclusive - city takes priority
if ($args['city']) {
// Look up city coordinates for radius search
$city_coords = $this->get_city_coordinates($args['city']);
if ($city_coords) {
// Match exact city OR within 15 miles of city center
$distance_filter = $this->get_distance_filter_sql(
$city_coords['latitude'],
$city_coords['longitude'],
15 // miles
);
$where[] = "(city = %s OR ({$distance_filter}))";
$values[] = $args['city'];
} else {
// Fallback to exact match if city not in geo table
$where[] = 'city = %s';
$values[] = $args['city'];
}
} elseif ($args['postal_code']) {
// Only apply postal_code filter if city is not set
// Look up zip code coordinates for radius search
$zip_coords = $this->get_zipcode_coordinates($args['postal_code']);
if ($zip_coords) {
// Match exact zip code OR within 20 miles of zip code center
$distance_filter = $this->get_distance_filter_sql(
$zip_coords['latitude'],
$zip_coords['longitude'],
20 // miles
);
$where[] = "(postal_code = %s OR ({$distance_filter}))";
$values[] = $args['postal_code'];
} else {
// Fallback to exact match if zip code not in geo table
$where[] = 'postal_code = %s';
$values[] = $args['postal_code'];
}
} elseif ($args['center_lat'] && $args['center_lng']) {
// Direct lat/lng radius search (from homepage location search)
$distance_filter = $this->get_distance_filter_sql(
(float) $args['center_lat'],
(float) $args['center_lng'],
(int) $args['radius']
);
$where[] = "({$distance_filter})";
}
if ($args['county']) {
$where[] = 'county = %s';
$values[] = $args['county'];
}
if ($args['postal_code']) {
$where[] = 'postal_code = %s';
$values[] = $args['postal_code'];
}
if ($args['min_price']) {
$where[] = 'list_price >= %d';
$values[] = (int) $args['min_price'];
@@ -428,10 +546,52 @@ class MLS_Query {
$values[] = $args['property_type'];
}
// City and postal_code are mutually exclusive - city takes priority
if (!empty($args['city'])) {
// Look up city coordinates for radius search
$city_coords = $this->get_city_coordinates($args['city']);
if ($city_coords) {
// Match exact city OR within 15 miles of city center
$distance_filter = $this->get_distance_filter_sql(
$city_coords['latitude'],
$city_coords['longitude'],
15 // miles
);
$where[] = "(city = %s OR ({$distance_filter}))";
$values[] = $args['city'];
} else {
// Fallback to exact match if city not in geo table
$where[] = 'city = %s';
$values[] = $args['city'];
}
} elseif (!empty($args['postal_code'])) {
// Only apply postal_code filter if city is not set
// Look up zip code coordinates for radius search
$zip_coords = $this->get_zipcode_coordinates($args['postal_code']);
if ($zip_coords) {
// Match exact zip code OR within 20 miles of zip code center
$distance_filter = $this->get_distance_filter_sql(
$zip_coords['latitude'],
$zip_coords['longitude'],
20 // miles
);
$where[] = "(postal_code = %s OR ({$distance_filter}))";
$values[] = $args['postal_code'];
} else {
// Fallback to exact match if zip code not in geo table
$where[] = 'postal_code = %s';
$values[] = $args['postal_code'];
}
} elseif (!empty($args['center_lat']) && !empty($args['center_lng'])) {
// Direct lat/lng radius search (from homepage location search)
$radius = !empty($args['radius']) ? (int) $args['radius'] : 30;
$distance_filter = $this->get_distance_filter_sql(
(float) $args['center_lat'],
(float) $args['center_lng'],
$radius
);
$where[] = "({$distance_filter})";
}
if (!empty($args['county'])) {
$where[] = 'county = %s';
@@ -644,15 +804,43 @@ class MLS_Query {
$values[] = $args['property_type'];
}
// City and postal_code are mutually exclusive - city takes priority
if (!empty($args['city'])) {
// Look up city coordinates for radius search
$city_coords = $this->get_city_coordinates($args['city']);
if ($city_coords) {
// Match exact city OR within 15 miles of city center
$distance_filter = $this->get_distance_filter_sql(
$city_coords['latitude'],
$city_coords['longitude'],
15 // miles
);
$where[] = "(city = %s OR ({$distance_filter}))";
$values[] = $args['city'];
} else {
// Fallback to exact match if city not in geo table
$where[] = 'city = %s';
$values[] = $args['city'];
}
if (!empty($args['postal_code'])) {
} elseif (!empty($args['postal_code'])) {
// Only apply postal_code filter if city is not set
// Look up zip code coordinates for radius search
$zip_coords = $this->get_zipcode_coordinates($args['postal_code']);
if ($zip_coords) {
// Match exact zip code OR within 20 miles of zip code center
$distance_filter = $this->get_distance_filter_sql(
$zip_coords['latitude'],
$zip_coords['longitude'],
20 // miles
);
$where[] = "(postal_code = %s OR ({$distance_filter}))";
$values[] = $args['postal_code'];
} else {
// Fallback to exact match if zip code not in geo table
$where[] = 'postal_code = %s';
$values[] = $args['postal_code'];
}
}
if (!empty($args['min_price'])) {
$where[] = 'list_price >= %d';
@@ -124,6 +124,9 @@ $initial_filters = array(
'min_price' => isset($_GET['min_price']) ? intval($_GET['min_price']) : '',
'max_price' => isset($_GET['max_price']) ? intval($_GET['max_price']) : '',
'min_beds' => isset($_GET['beds']) ? intval($_GET['beds']) : '',
'center_lat' => isset($_GET['lat']) ? floatval($_GET['lat']) : '',
'center_lng' => isset($_GET['lng']) ? floatval($_GET['lng']) : '',
'radius' => isset($_GET['radius']) ? intval($_GET['radius']) : 30,
);
// Get total property count with coordinates for display
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -36,6 +36,11 @@ function homeproz_ajax_filter_properties() {
$paged = isset($_POST['paged']) ? intval($_POST['paged']) : 1;
$cards_only = isset($_POST['cards_only']) && $_POST['cards_only'] === 'true';
// Location search params (from homepage)
$center_lat = isset($_POST['center_lat']) ? floatval($_POST['center_lat']) : '';
$center_lng = isset($_POST['center_lng']) ? floatval($_POST['center_lng']) : '';
$radius = isset($_POST['radius']) ? intval($_POST['radius']) : 30;
// Map bounds and center (for map-synced list view)
$bounds = null;
$center = null;
@@ -64,6 +69,12 @@ function homeproz_ajax_filter_properties() {
if ($zip) {
$filter_args['postal_code'] = $zip;
}
// Location search from homepage (takes precedence)
if ($center_lat && $center_lng) {
$filter_args['center_lat'] = $center_lat;
$filter_args['center_lng'] = $center_lng;
$filter_args['radius'] = $radius;
}
if ($min_price) {
$filter_args['min_price'] = $min_price;
}
@@ -256,6 +267,70 @@ function homeproz_ajax_get_filter_bounds() {
add_action('wp_ajax_homeproz_get_filter_bounds', 'homeproz_ajax_get_filter_bounds');
add_action('wp_ajax_nopriv_homeproz_get_filter_bounds', 'homeproz_ajax_get_filter_bounds');
/**
* Get all MN/IA cities and zipcodes for location autocomplete
* Returns complete dataset with 1-hour cache header for client-side filtering
*/
function homeproz_ajax_get_locations() {
global $wpdb;
// Set cache headers - 1 hour
header('Cache-Control: public, max-age=3600');
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 3600) . ' GMT');
$cities_table = $wpdb->prefix . 'mls_geo_cities';
$zips_table = $wpdb->prefix . 'mls_geo_zipcodes';
// Get MN and IA cities
$cities = $wpdb->get_results(
"SELECT city, state_code, latitude, longitude
FROM {$cities_table}
WHERE state_code IN ('MN', 'IA')
ORDER BY city ASC"
);
// Get MN and IA zipcodes by range
// MN: 550xx-567xx, IA: 500xx-528xx
$zipcodes = $wpdb->get_results(
"SELECT zipcode, latitude, longitude
FROM {$zips_table}
WHERE (zipcode BETWEEN '50000' AND '52899')
OR (zipcode BETWEEN '55000' AND '56799')
ORDER BY zipcode ASC"
);
// Format cities for autocomplete
$formatted_cities = array();
foreach ($cities as $city) {
$formatted_cities[] = array(
'type' => 'city',
'label' => $city->city . ', ' . $city->state_code,
'value' => $city->city,
'lat' => (float) $city->latitude,
'lng' => (float) $city->longitude,
);
}
// Format zipcodes for autocomplete
$formatted_zips = array();
foreach ($zipcodes as $zip) {
$formatted_zips[] = array(
'type' => 'zip',
'label' => $zip->zipcode,
'value' => $zip->zipcode,
'lat' => (float) $zip->latitude,
'lng' => (float) $zip->longitude,
);
}
wp_send_json_success(array(
'cities' => $formatted_cities,
'zipcodes' => $formatted_zips,
));
}
add_action('wp_ajax_homeproz_get_locations', 'homeproz_ajax_get_locations');
add_action('wp_ajax_nopriv_homeproz_get_locations', 'homeproz_ajax_get_locations');
/**
* Localize script data for AJAX
*/
@@ -267,5 +342,12 @@ function homeproz_localize_ajax_data() {
'archiveUrl' => get_post_type_archive_link('property'),
));
}
// Location search data for homepage
if (is_front_page()) {
wp_localize_script('homeproz-script', 'homeprozLocations', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
));
}
}
add_action('wp_enqueue_scripts', 'homeproz_localize_ajax_data', 20);
@@ -0,0 +1,370 @@
/**
* Hero Location Search - Ghost Text Autocomplete
*
* Shows inline ghost text suggestion as user types.
* Tab to accept, auto-fills on blur or Enter.
*
* @package HomeProz
*/
(function($) {
'use strict';
// Configuration
var MIN_CHARS = 2;
// State
var locationsData = null;
var isLoading = false;
/**
* Initialize location search on all hero forms
*/
function init() {
var $forms = $('.hero-location-search');
if (!$forms.length) return;
// Preload locations data
loadLocations(function() {});
// Initialize each form
$forms.each(function() {
initForm($(this));
});
}
/**
* Initialize a single form with ghost text autocomplete
*/
function initForm($form) {
var $input = $form.find('.hero-location-input');
var $latInput = $form.find('input[name="lat"]');
var $lngInput = $form.find('input[name="lng"]');
var $geoBtn = $form.find('.hero-geolocation-btn');
// Create ghost text element
var $ghost = $('<span class="hero-location-ghost"></span>');
$input.after($ghost);
// Position ghost text to match input
positionGhost($input, $ghost);
// Current suggestion state
var currentSuggestion = null;
/**
* Find best matching suggestion
*/
function findSuggestion(query) {
if (!locationsData || query.length < MIN_CHARS) {
return null;
}
var q = query.toLowerCase();
var isZip = /^\d/.test(query);
if (isZip) {
// Search zipcodes - prefix match
for (var i = 0; i < locationsData.zipcodes.length; i++) {
var zip = locationsData.zipcodes[i];
if (zip.label.indexOf(query) === 0) {
return zip;
}
}
} else {
// Search cities - prefix match (case insensitive)
for (var j = 0; j < locationsData.cities.length; j++) {
var city = locationsData.cities[j];
if (city.label.toLowerCase().indexOf(q) === 0) {
return city;
}
}
}
return null;
}
/**
* Update ghost text display
*/
function updateGhost() {
var query = $input.val();
currentSuggestion = findSuggestion(query);
if (currentSuggestion && query.length >= MIN_CHARS) {
// Show ghost: invisible typed text + visible completion
var typed = query;
var completion = currentSuggestion.label.substring(typed.length);
var ghostHtml = '<span class="ghost-typed">' + escapeHtml(typed) + '</span>' +
'<span class="ghost-completion">' + escapeHtml(completion) + '</span>';
$ghost.html(ghostHtml).show();
} else {
$ghost.empty().hide();
currentSuggestion = null;
}
}
/**
* Accept the current suggestion
*/
function acceptSuggestion() {
if (currentSuggestion) {
$input.val(currentSuggestion.label);
$latInput.val(currentSuggestion.lat);
$lngInput.val(currentSuggestion.lng);
$ghost.empty().hide();
currentSuggestion = null;
return true;
}
return false;
}
/**
* Try to match partial input to suggestion on submit
*/
function resolveInput() {
var query = $input.val().trim();
// Already have coordinates set
if ($latInput.val() && $lngInput.val()) {
return true;
}
// Try exact match first
var exact = findExactMatch(query);
if (exact) {
$input.val(exact.label);
$latInput.val(exact.lat);
$lngInput.val(exact.lng);
return true;
}
// If we have a ghost suggestion that starts with what user typed, use it
if (currentSuggestion) {
$input.val(currentSuggestion.label);
$latInput.val(currentSuggestion.lat);
$lngInput.val(currentSuggestion.lng);
return true;
}
// Try to find any match
var suggestion = findSuggestion(query);
if (suggestion) {
$input.val(suggestion.label);
$latInput.val(suggestion.lat);
$lngInput.val(suggestion.lng);
return true;
}
return false;
}
/**
* Find exact match (case insensitive)
*/
function findExactMatch(query) {
if (!locationsData || !query) return null;
var q = query.toLowerCase();
// Check zipcodes
for (var i = 0; i < locationsData.zipcodes.length; i++) {
if (locationsData.zipcodes[i].label === query) {
return locationsData.zipcodes[i];
}
}
// Check cities
for (var j = 0; j < locationsData.cities.length; j++) {
if (locationsData.cities[j].label.toLowerCase() === q) {
return locationsData.cities[j];
}
}
return null;
}
// Input event - update ghost text
$input.on('input', function() {
// Clear coordinates when user types
$latInput.val('');
$lngInput.val('');
updateGhost();
});
// Tab key - accept suggestion
$input.on('keydown', function(e) {
if (e.keyCode === 9 && currentSuggestion) { // Tab
e.preventDefault();
acceptSuggestion();
$input.blur().focus(); // Trigger re-render
}
});
// Enter key - accept suggestion if present, then allow form submit
$input.on('keydown', function(e) {
if (e.keyCode === 13) { // Enter
resolveInput();
}
});
// Blur - auto-accept suggestion
$input.on('blur', function() {
if (currentSuggestion) {
acceptSuggestion();
}
$ghost.hide();
});
// Focus - show ghost if applicable
$input.on('focus', function() {
positionGhost($input, $ghost);
updateGhost();
});
// Form submit - resolve input
$form.on('submit', function(e) {
var resolved = resolveInput();
// If no location resolved and input is empty, allow through without location filter
var query = $input.val().trim();
if (!resolved && query === '') {
$latInput.prop('disabled', true);
$lngInput.prop('disabled', true);
$form.find('input[name="radius"]').prop('disabled', true);
return true;
}
// If can't resolve, prevent submit and show feedback
if (!resolved && query !== '') {
e.preventDefault();
$input.addClass('has-error');
setTimeout(function() {
$input.removeClass('has-error');
}, 1000);
return false;
}
});
// Geolocation button
$geoBtn.on('click', function() {
handleGeolocation($form, $input, $latInput, $lngInput, $(this), $ghost);
});
// Window resize - reposition ghost
$(window).on('resize', function() {
positionGhost($input, $ghost);
});
}
/**
* Position ghost text element to overlay input
*/
function positionGhost($input, $ghost) {
var inputStyle = window.getComputedStyle($input.get(0));
$ghost.css({
'position': 'absolute',
'top': $input.position().top + 'px',
'left': $input.position().left + 'px',
'width': $input.outerWidth() + 'px',
'height': $input.outerHeight() + 'px',
'padding': inputStyle.padding,
'font': inputStyle.font,
'line-height': inputStyle.lineHeight,
'pointer-events': 'none',
'overflow': 'hidden',
'white-space': 'nowrap',
'display': 'flex',
'align-items': 'center',
'box-sizing': 'border-box'
});
}
/**
* Load locations data (cached)
*/
function loadLocations(callback) {
if (locationsData) {
callback(locationsData);
return;
}
if (isLoading) {
var checkInterval = setInterval(function() {
if (locationsData) {
clearInterval(checkInterval);
callback(locationsData);
}
}, 100);
return;
}
isLoading = true;
$.ajax({
url: homeprozLocations.ajaxUrl,
type: 'GET',
data: { action: 'homeproz_get_locations' },
dataType: 'json',
success: function(response) {
if (response.success && response.data) {
locationsData = response.data;
callback(locationsData);
}
},
complete: function() {
isLoading = false;
}
});
}
/**
* Handle geolocation button click
*/
function handleGeolocation($form, $input, $latInput, $lngInput, $btn, $ghost) {
if (!navigator.geolocation) {
alert('Geolocation is not supported by your browser');
return;
}
$btn.addClass('is-loading').prop('disabled', true);
navigator.geolocation.getCurrentPosition(
function(position) {
$input.val('My Location');
$latInput.val(position.coords.latitude);
$lngInput.val(position.coords.longitude);
$ghost.empty().hide();
$btn.removeClass('is-loading').prop('disabled', false);
$form.submit();
},
function(error) {
$btn.removeClass('is-loading').prop('disabled', false);
var msg = 'Unable to get your location.';
if (error.code === error.PERMISSION_DENIED) {
msg = 'Location access denied. Please enable location services.';
}
alert(msg);
},
{
enableHighAccuracy: false,
timeout: 10000,
maximumAge: 300000
}
);
}
/**
* Escape HTML
*/
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Initialize when DOM is ready
$(document).ready(init);
})(jQuery);
+23 -21
View File
@@ -26,16 +26,6 @@ $show_location_search = isset($args['show_location_search']) ? $args['show_locat
$size_class = $size === 'small' ? 'hero-section--small' : 'hero-section--large';
$style = $background_image ? 'background-image: url(' . esc_url($background_image) . ');' : '';
// Get locations for dropdown if needed
$locations = array();
if ($show_location_search) {
$locations = get_terms(array(
'taxonomy' => 'property_location',
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
));
}
?>
<section class="hero-section hero-section--card <?php echo esc_attr($size_class); ?>" <?php echo $style ? 'style="' . esc_attr($style) . '"' : ''; ?>>
@@ -52,18 +42,30 @@ if ($show_location_search) {
<p class="hero-section-subtitle"><?php echo esc_html($subtitle); ?></p>
<?php endif; ?>
<?php if ($show_location_search && !empty($locations) && !is_wp_error($locations)) : ?>
<form class="hero-location-search" action="<?php echo esc_url(home_url('/properties/')); ?>" method="get">
<?php if ($show_location_search) : ?>
<form class="hero-location-search" action="<?php echo esc_url(home_url('/properties/')); ?>" method="get" id="hero-location-form-mobile">
<div class="hero-location-search-inner">
<label for="hero-location-select" class="screen-reader-text">Select a community</label>
<select name="location" id="hero-location-select" class="hero-location-select">
<option value="">Select a Community</option>
<?php foreach ($locations as $location) : ?>
<option value="<?php echo esc_attr($location->slug); ?>">
<?php echo esc_html($location->name); ?>
</option>
<?php endforeach; ?>
</select>
<div class="hero-location-input-wrap">
<label for="hero-location-input-mobile" class="screen-reader-text">City or ZIP code</label>
<input
type="text"
id="hero-location-input-mobile"
class="hero-location-input"
placeholder="City or ZIP code..."
autocomplete="off"
>
<div class="hero-location-dropdown" id="hero-location-dropdown-mobile"></div>
</div>
<input type="hidden" name="lat" id="hero-location-lat-mobile">
<input type="hidden" name="lng" id="hero-location-lng-mobile">
<input type="hidden" name="radius" value="30">
<button type="button" class="btn btn-icon hero-geolocation-btn" id="hero-geolocation-btn-mobile" title="Use my location">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
<circle cx="12" cy="12" r="8"/>
</svg>
</button>
<button type="submit" class="btn btn-primary hero-search-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="8"/>
+75 -11
View File
@@ -161,15 +161,24 @@
gap: 0;
background-color: var(--color-bg-card);
border-radius: 0.5rem;
overflow: hidden;
overflow: visible;
@media (max-width: 480px) {
flex-direction: column;
flex-wrap: wrap;
}
}
.hero-section--card .hero-location-select {
.hero-section--card .hero-location-input-wrap {
flex: 1;
position: relative;
@media (max-width: 480px) {
flex: 1 1 100%;
}
}
.hero-section--card .hero-location-input {
width: 100%;
padding: 0.75rem 0.875rem;
font-size: 0.9rem;
font-family: var(--font-body);
@@ -177,16 +186,70 @@
background-color: var(--color-bg-card);
border: none;
outline: none;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23B0B0B0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
&::placeholder {
color: var(--color-text-muted);
}
&:focus {
box-shadow: inset 0 0 0 2px var(--color-accent);
}
@media (max-width: 480px) {
text-align: center;
border-radius: 0.5rem 0.5rem 0 0;
}
&.has-error {
animation: shake-card 0.3s ease-in-out;
box-shadow: inset 0 0 0 2px var(--color-accent);
}
}
@keyframes shake-card {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
// Ghost text autocomplete overlay for card variant
.hero-section--card .hero-location-ghost {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
white-space: nowrap;
display: none;
align-items: center;
box-sizing: border-box;
padding: 0.75rem 0.875rem;
font-size: 0.9rem;
font-family: var(--font-body);
line-height: 1.5;
.ghost-typed {
opacity: 0;
}
.ghost-completion {
color: var(--color-text-muted);
opacity: 0.6;
}
}
.hero-section--card .hero-location-dropdown {
font-size: 0.9rem;
}
.hero-section--card .hero-geolocation-btn {
padding: 0.75rem;
@media (max-width: 480px) {
flex: 1;
}
}
.hero-section--card .hero-search-btn {
@@ -194,12 +257,13 @@
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0;
border-radius: 0 0.5rem 0.5rem 0;
white-space: nowrap;
font-size: 0.8rem;
@media (max-width: 480px) {
width: 100%;
flex: 1;
justify-content: center;
border-radius: 0 0 0.5rem 0;
}
}
@@ -43,11 +43,6 @@ $size_class = $size === 'small' ? 'hero-section--small' : 'hero-section--large';
// Prepare gallery data attribute for JS
$gallery_data = !empty($gallery_images) ? esc_attr(wp_json_encode($gallery_images)) : '';
// Get locations for dropdown if needed (only locations with active/pending properties)
$locations = array();
if ($show_location_search) {
$locations = homeproz_get_active_locations();
}
?>
<section class="hero-section hero-section--split <?php echo esc_attr($size_class); ?>">
@@ -65,18 +60,30 @@ if ($show_location_search) {
<p class="hero-section-subtitle"><?php echo esc_html($subtitle); ?></p>
<?php endif; ?>
<?php if ($show_location_search && !empty($locations) && !is_wp_error($locations)) : ?>
<form class="hero-location-search" action="<?php echo esc_url(home_url('/properties/')); ?>" method="get">
<?php if ($show_location_search) : ?>
<form class="hero-location-search" action="<?php echo esc_url(home_url('/properties/')); ?>" method="get" id="hero-location-form">
<div class="hero-location-search-inner">
<label for="hero-location-select" class="screen-reader-text">Select a community</label>
<select name="property_location" id="hero-location-select" class="hero-location-select">
<option value="">Select a Community</option>
<?php foreach ($locations as $location) : ?>
<option value="<?php echo esc_attr($location->slug); ?>">
<?php echo esc_html($location->name); ?>
</option>
<?php endforeach; ?>
</select>
<div class="hero-location-input-wrap">
<label for="hero-location-input" class="screen-reader-text">City or ZIP code</label>
<input
type="text"
id="hero-location-input"
class="hero-location-input"
placeholder="City or ZIP code..."
autocomplete="off"
>
<div class="hero-location-dropdown" id="hero-location-dropdown"></div>
</div>
<input type="hidden" name="lat" id="hero-location-lat">
<input type="hidden" name="lng" id="hero-location-lng">
<input type="hidden" name="radius" value="30">
<button type="button" class="btn btn-icon hero-geolocation-btn" id="hero-geolocation-btn" title="Use my location">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
<circle cx="12" cy="12" r="8"/>
</svg>
</button>
<button type="submit" class="btn btn-primary hero-search-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="8"/>
@@ -144,16 +144,26 @@
gap: 0;
background-color: var(--color-bg-card);
border-radius: 0.5rem;
overflow: hidden;
overflow: visible;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
@media (max-width: 480px) {
flex-direction: column;
flex-wrap: wrap;
}
}
.hero-location-select {
.hero-location-input-wrap {
flex: 1;
position: relative;
min-width: 180px;
@media (max-width: 480px) {
flex: 1 1 100%;
}
}
.hero-location-input {
width: 100%;
padding: 0.875rem 1rem;
font-size: 1rem;
font-family: var(--font-body);
@@ -161,35 +171,154 @@
background-color: var(--color-bg-card);
border: none;
outline: none;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23B0B0B0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
min-width: 180px;
&::placeholder {
color: var(--color-text-muted);
}
&:focus {
box-shadow: inset 0 0 0 2px var(--color-accent);
}
option {
@media (max-width: 480px) {
text-align: center;
border-radius: 0.5rem 0.5rem 0 0;
}
&.has-error {
animation: shake 0.3s ease-in-out;
box-shadow: inset 0 0 0 2px var(--color-accent);
}
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
// Ghost text autocomplete overlay
.hero-location-ghost {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
white-space: nowrap;
display: none;
align-items: center;
box-sizing: border-box;
padding: 0.875rem 1rem;
font-size: 1rem;
font-family: var(--font-body);
line-height: 1.5;
.ghost-typed {
opacity: 0;
}
.ghost-completion {
color: var(--color-text-muted);
opacity: 0.6;
}
}
.hero-location-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: var(--color-bg-card);
border-radius: 0 0 0.5rem 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 100;
max-height: 280px;
overflow-y: auto;
}
.hero-location-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
color: var(--color-text);
&:hover,
&.is-focused {
background-color: var(--color-bg-dark);
}
strong {
color: var(--color-accent-light);
}
}
.hero-location-item-icon {
flex-shrink: 0;
color: var(--color-text-muted);
display: flex;
align-items: center;
}
.hero-location-item-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hero-location-no-results {
padding: 1rem;
color: var(--color-text-muted);
text-align: center;
font-size: 0.875rem;
}
.hero-geolocation-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.875rem;
background-color: var(--color-bg-dark);
border: none;
color: var(--color-text-muted);
cursor: pointer;
border-radius: 0;
&:hover {
background-color: var(--color-accent);
color: white;
}
&.is-loading {
opacity: 0.6;
cursor: wait;
svg {
animation: spin 1s linear infinite;
}
}
@media (max-width: 480px) {
text-align: center;
background-position: right 1.5rem center;
flex: 1;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hero-search-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1.25rem;
border-radius: 0;
border-radius: 0 0.5rem 0.5rem 0;
white-space: nowrap;
svg {
@@ -197,7 +326,8 @@
}
@media (max-width: 480px) {
width: 100%;
flex: 1;
justify-content: center;
border-radius: 0 0 0.5rem 0;
}
}
@@ -31,6 +31,11 @@ $current_min_price = isset($_GET['min_price']) ? intval($_GET['min_price']) : ''
$current_max_price = isset($_GET['max_price']) ? intval($_GET['max_price']) : '';
$current_beds = isset($_GET['beds']) ? intval($_GET['beds']) : '';
// Location search params (from homepage)
$center_lat = isset($_GET['lat']) ? floatval($_GET['lat']) : '';
$center_lng = isset($_GET['lng']) ? floatval($_GET['lng']) : '';
$radius = isset($_GET['radius']) ? intval($_GET['radius']) : 30;
// Pagination
$paged = get_query_var('paged') ? get_query_var('paged') : 1;
$per_page = 12;
@@ -46,6 +51,12 @@ if ($current_type) {
if ($current_location) {
$filter_args['city'] = $current_location;
}
// Use lat/lng radius search if provided (takes precedence over location)
if ($center_lat && $center_lng) {
$filter_args['center_lat'] = $center_lat;
$filter_args['center_lng'] = $center_lng;
$filter_args['radius'] = $radius;
}
if ($current_min_price) {
$filter_args['min_price'] = $current_min_price;
}
@@ -76,7 +87,7 @@ $paged_properties = mls_get_properties($mls_args);
<div class="properties-meta">
<p class="properties-count">
<?php if ($total > 0) : ?>
Showing <strong><?php echo esc_html($total); ?></strong>
Showing <strong><?php echo esc_html(number_format($total)); ?></strong>
<?php echo $total === 1 ? 'property' : 'properties'; ?>
<?php else : ?>
No properties found