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:
@@ -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
|
||||
|
||||
+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;
|
||||
$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);
|
||||
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';
|
||||
$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"/>
|
||||
|
||||
Regular → Executable
+75
-11
@@ -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 {
|
||||
background-color: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
@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
|
||||
|
||||
Reference in New Issue
Block a user