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:
@@ -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
|
||||||
Regular → Executable
+32329
-29880
File diff suppressed because it is too large
Load Diff
Regular → Executable
+204
-16
@@ -50,6 +50,85 @@ class MLS_Query {
|
|||||||
return "(street_number IS NULL OR (street_number != 'TBD' AND street_number NOT LIKE 'TBD %'))";
|
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
|
* Get properties matching criteria
|
||||||
*
|
*
|
||||||
@@ -65,6 +144,9 @@ class MLS_Query {
|
|||||||
'city' => null,
|
'city' => null,
|
||||||
'county' => null,
|
'county' => null,
|
||||||
'postal_code' => 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,
|
'min_price' => null,
|
||||||
'max_price' => null,
|
'max_price' => null,
|
||||||
'min_beds' => null,
|
'min_beds' => null,
|
||||||
@@ -125,9 +207,50 @@ class MLS_Query {
|
|||||||
$values[] = $args['property_type'];
|
$values[] = $args['property_type'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// City and postal_code are mutually exclusive - city takes priority
|
||||||
if ($args['city']) {
|
if ($args['city']) {
|
||||||
$where[] = 'city = %s';
|
// Look up city coordinates for radius search
|
||||||
$values[] = $args['city'];
|
$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']) {
|
if ($args['county']) {
|
||||||
@@ -135,11 +258,6 @@ class MLS_Query {
|
|||||||
$values[] = $args['county'];
|
$values[] = $args['county'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($args['postal_code']) {
|
|
||||||
$where[] = 'postal_code = %s';
|
|
||||||
$values[] = $args['postal_code'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($args['min_price']) {
|
if ($args['min_price']) {
|
||||||
$where[] = 'list_price >= %d';
|
$where[] = 'list_price >= %d';
|
||||||
$values[] = (int) $args['min_price'];
|
$values[] = (int) $args['min_price'];
|
||||||
@@ -428,9 +546,51 @@ class MLS_Query {
|
|||||||
$values[] = $args['property_type'];
|
$values[] = $args['property_type'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// City and postal_code are mutually exclusive - city takes priority
|
||||||
if (!empty($args['city'])) {
|
if (!empty($args['city'])) {
|
||||||
$where[] = 'city = %s';
|
// Look up city coordinates for radius search
|
||||||
$values[] = $args['city'];
|
$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'])) {
|
if (!empty($args['county'])) {
|
||||||
@@ -644,14 +804,42 @@ class MLS_Query {
|
|||||||
$values[] = $args['property_type'];
|
$values[] = $args['property_type'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// City and postal_code are mutually exclusive - city takes priority
|
||||||
if (!empty($args['city'])) {
|
if (!empty($args['city'])) {
|
||||||
$where[] = 'city = %s';
|
// Look up city coordinates for radius search
|
||||||
$values[] = $args['city'];
|
$city_coords = $this->get_city_coordinates($args['city']);
|
||||||
}
|
if ($city_coords) {
|
||||||
|
// Match exact city OR within 15 miles of city center
|
||||||
if (!empty($args['postal_code'])) {
|
$distance_filter = $this->get_distance_filter_sql(
|
||||||
$where[] = 'postal_code = %s';
|
$city_coords['latitude'],
|
||||||
$values[] = $args['postal_code'];
|
$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'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($args['min_price'])) {
|
if (!empty($args['min_price'])) {
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ $initial_filters = array(
|
|||||||
'min_price' => isset($_GET['min_price']) ? intval($_GET['min_price']) : '',
|
'min_price' => isset($_GET['min_price']) ? intval($_GET['min_price']) : '',
|
||||||
'max_price' => isset($_GET['max_price']) ? intval($_GET['max_price']) : '',
|
'max_price' => isset($_GET['max_price']) ? intval($_GET['max_price']) : '',
|
||||||
'min_beds' => isset($_GET['beds']) ? intval($_GET['beds']) : '',
|
'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
|
// Get total property count with coordinates for display
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
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;
|
$paged = isset($_POST['paged']) ? intval($_POST['paged']) : 1;
|
||||||
$cards_only = isset($_POST['cards_only']) && $_POST['cards_only'] === 'true';
|
$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)
|
// Map bounds and center (for map-synced list view)
|
||||||
$bounds = null;
|
$bounds = null;
|
||||||
$center = null;
|
$center = null;
|
||||||
@@ -64,6 +69,12 @@ function homeproz_ajax_filter_properties() {
|
|||||||
if ($zip) {
|
if ($zip) {
|
||||||
$filter_args['postal_code'] = $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) {
|
if ($min_price) {
|
||||||
$filter_args['min_price'] = $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_homeproz_get_filter_bounds', 'homeproz_ajax_get_filter_bounds');
|
||||||
add_action('wp_ajax_nopriv_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
|
* Localize script data for AJAX
|
||||||
*/
|
*/
|
||||||
@@ -267,5 +342,12 @@ function homeproz_localize_ajax_data() {
|
|||||||
'archiveUrl' => get_post_type_archive_link('property'),
|
'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);
|
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);
|
||||||
Regular → Executable
+23
-21
@@ -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';
|
$size_class = $size === 'small' ? 'hero-section--small' : 'hero-section--large';
|
||||||
$style = $background_image ? 'background-image: url(' . esc_url($background_image) . ');' : '';
|
$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) . '"' : ''; ?>>
|
<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>
|
<p class="hero-section-subtitle"><?php echo esc_html($subtitle); ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($show_location_search && !empty($locations) && !is_wp_error($locations)) : ?>
|
<?php if ($show_location_search) : ?>
|
||||||
<form class="hero-location-search" action="<?php echo esc_url(home_url('/properties/')); ?>" method="get">
|
<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">
|
<div class="hero-location-search-inner">
|
||||||
<label for="hero-location-select" class="screen-reader-text">Select a community</label>
|
<div class="hero-location-input-wrap">
|
||||||
<select name="location" id="hero-location-select" class="hero-location-select">
|
<label for="hero-location-input-mobile" class="screen-reader-text">City or ZIP code</label>
|
||||||
<option value="">Select a Community</option>
|
<input
|
||||||
<?php foreach ($locations as $location) : ?>
|
type="text"
|
||||||
<option value="<?php echo esc_attr($location->slug); ?>">
|
id="hero-location-input-mobile"
|
||||||
<?php echo esc_html($location->name); ?>
|
class="hero-location-input"
|
||||||
</option>
|
placeholder="City or ZIP code..."
|
||||||
<?php endforeach; ?>
|
autocomplete="off"
|
||||||
</select>
|
>
|
||||||
|
<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">
|
<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">
|
<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"/>
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
|||||||
Regular → Executable
+75
-11
@@ -161,15 +161,24 @@
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
background-color: var(--color-bg-card);
|
background-color: var(--color-bg-card);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@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;
|
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;
|
padding: 0.75rem 0.875rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
@@ -177,16 +186,70 @@
|
|||||||
background-color: var(--color-bg-card);
|
background-color: var(--color-bg-card);
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
|
||||||
appearance: none;
|
&::placeholder {
|
||||||
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");
|
color: var(--color-text-muted);
|
||||||
background-repeat: no-repeat;
|
}
|
||||||
background-position: right 0.75rem center;
|
|
||||||
padding-right: 2rem;
|
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
box-shadow: inset 0 0 0 2px var(--color-accent);
|
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 {
|
.hero-section--card .hero-search-btn {
|
||||||
@@ -194,12 +257,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 0;
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
justify-content: center;
|
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
|
// Prepare gallery data attribute for JS
|
||||||
$gallery_data = !empty($gallery_images) ? esc_attr(wp_json_encode($gallery_images)) : '';
|
$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); ?>">
|
<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>
|
<p class="hero-section-subtitle"><?php echo esc_html($subtitle); ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($show_location_search && !empty($locations) && !is_wp_error($locations)) : ?>
|
<?php if ($show_location_search) : ?>
|
||||||
<form class="hero-location-search" action="<?php echo esc_url(home_url('/properties/')); ?>" method="get">
|
<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">
|
<div class="hero-location-search-inner">
|
||||||
<label for="hero-location-select" class="screen-reader-text">Select a community</label>
|
<div class="hero-location-input-wrap">
|
||||||
<select name="property_location" id="hero-location-select" class="hero-location-select">
|
<label for="hero-location-input" class="screen-reader-text">City or ZIP code</label>
|
||||||
<option value="">Select a Community</option>
|
<input
|
||||||
<?php foreach ($locations as $location) : ?>
|
type="text"
|
||||||
<option value="<?php echo esc_attr($location->slug); ?>">
|
id="hero-location-input"
|
||||||
<?php echo esc_html($location->name); ?>
|
class="hero-location-input"
|
||||||
</option>
|
placeholder="City or ZIP code..."
|
||||||
<?php endforeach; ?>
|
autocomplete="off"
|
||||||
</select>
|
>
|
||||||
|
<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">
|
<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">
|
<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"/>
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
|||||||
@@ -144,16 +144,26 @@
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
background-color: var(--color-bg-card);
|
background-color: var(--color-bg-card);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-location-select {
|
.hero-location-input-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
min-width: 180px;
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-location-input {
|
||||||
|
width: 100%;
|
||||||
padding: 0.875rem 1rem;
|
padding: 0.875rem 1rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
@@ -161,35 +171,154 @@
|
|||||||
background-color: var(--color-bg-card);
|
background-color: var(--color-bg-card);
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
|
||||||
appearance: none;
|
&::placeholder {
|
||||||
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");
|
color: var(--color-text-muted);
|
||||||
background-repeat: no-repeat;
|
}
|
||||||
background-position: right 0.75rem center;
|
|
||||||
padding-right: 2rem;
|
|
||||||
min-width: 180px;
|
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
box-shadow: inset 0 0 0 2px var(--color-accent);
|
box-shadow: inset 0 0 0 2px var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
option {
|
@media (max-width: 480px) {
|
||||||
background-color: var(--color-bg-card);
|
text-align: center;
|
||||||
color: var(--color-text);
|
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) {
|
@media (max-width: 480px) {
|
||||||
text-align: center;
|
flex: 1;
|
||||||
background-position: right 1.5rem center;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.hero-search-btn {
|
.hero-search-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.875rem 1.25rem;
|
padding: 0.875rem 1.25rem;
|
||||||
border-radius: 0;
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
@@ -197,7 +326,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
justify-content: center;
|
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_max_price = isset($_GET['max_price']) ? intval($_GET['max_price']) : '';
|
||||||
$current_beds = isset($_GET['beds']) ? intval($_GET['beds']) : '';
|
$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
|
// Pagination
|
||||||
$paged = get_query_var('paged') ? get_query_var('paged') : 1;
|
$paged = get_query_var('paged') ? get_query_var('paged') : 1;
|
||||||
$per_page = 12;
|
$per_page = 12;
|
||||||
@@ -46,6 +51,12 @@ if ($current_type) {
|
|||||||
if ($current_location) {
|
if ($current_location) {
|
||||||
$filter_args['city'] = $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) {
|
if ($current_min_price) {
|
||||||
$filter_args['min_price'] = $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">
|
<div class="properties-meta">
|
||||||
<p class="properties-count">
|
<p class="properties-count">
|
||||||
<?php if ($total > 0) : ?>
|
<?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 echo $total === 1 ? 'property' : 'properties'; ?>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
No properties found
|
No properties found
|
||||||
|
|||||||
Reference in New Issue
Block a user