Add US geo data tables, filter bounds API, and URL hash state management

- Add mls_geo_cities and mls_geo_zipcodes tables with 29,880 cities and 33,144 zip codes
- Add get_filter_bounds() method to reposition map when filters don't intersect current view
- Move all URL state (filters, page, scroll, map position) to hash to avoid WordPress 404s
- Add filter bounds AJAX endpoint for map repositioning on filter change

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hanson.xyz Dev
2025-12-17 15:07:33 -06:00
parent 5522d18ada
commit 564d556a8c
8 changed files with 63566 additions and 44 deletions
File diff suppressed because one or more lines are too long
@@ -125,7 +125,7 @@ function homeproz_ajax_filter_properties() {
<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 if ($has_map_bounds) : ?>
in view
@@ -207,6 +207,55 @@ function homeproz_ajax_filter_properties() {
add_action('wp_ajax_homeproz_filter_properties', 'homeproz_ajax_filter_properties');
add_action('wp_ajax_nopriv_homeproz_filter_properties', 'homeproz_ajax_filter_properties');
/**
* Get geographic bounds for filtered properties
* Used to reposition map when filters change
*/
function homeproz_ajax_get_filter_bounds() {
// Check if MLS plugin is available
if (!function_exists('mls_get_filter_bounds')) {
wp_send_json_error('MLS plugin not available');
}
// Get filter values
$property_type = isset($_GET['property_type']) ? sanitize_text_field($_GET['property_type']) : '';
$property_location = isset($_GET['city']) ? sanitize_text_field($_GET['city']) : '';
$min_price = isset($_GET['min_price']) ? intval($_GET['min_price']) : '';
$max_price = isset($_GET['max_price']) ? intval($_GET['max_price']) : '';
$min_beds = isset($_GET['min_beds']) ? intval($_GET['min_beds']) : '';
// Build filter args
$filter_args = array(
'status' => 'Active',
);
if ($property_type) {
$filter_args['property_type'] = $property_type;
}
if ($property_location) {
$filter_args['city'] = $property_location;
}
if ($min_price) {
$filter_args['min_price'] = $min_price;
}
if ($max_price) {
$filter_args['max_price'] = $max_price;
}
if ($min_beds) {
$filter_args['min_beds'] = $min_beds;
}
$bounds = mls_get_filter_bounds($filter_args);
if (!$bounds) {
wp_send_json_error('No properties found');
}
wp_send_json_success($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');
/**
* Localize script data for AJAX
*/
@@ -1079,7 +1079,7 @@
// Filter changes (auto-submit on select change)
this.$form.find('select').on('change', function() {
self.clearPinSelection();
self.filterProperties(1);
self.onFilterChange();
});
// Reset button
@@ -1103,33 +1103,34 @@
},
/**
* Initialize filters from URL (hash for page, scroll, map state)
* Initialize filters from URL (all state from hash)
*/
initFromUrl: function() {
var self = this;
var params = new URLSearchParams(window.location.search);
// Set form values from URL query params (filters only, not page)
// Get full state from hash (includes filters now)
var state = this.getStateFromHash();
if (!state) return;
// Set form values from hash state
this.$form.find('select').each(function() {
var name = $(this).attr('name');
if (params.has(name)) {
$(this).val(params.get(name));
if (state[name]) {
$(this).val(state[name]);
}
});
// Also handle text inputs (like zip)
this.$form.find('input[type="text"]').each(function() {
var name = $(this).attr('name');
if (params.has(name)) {
$(this).val(params.get(name));
if (state[name]) {
$(this).val(state[name]);
}
});
// Get full state from hash
var state = this.getStateFromHash();
// Store restoration state for map init to use
if (state && (state.lat !== null || state.page > 1)) {
if (state.lat !== null || state.page > 1) {
this.pendingRestoreState = state;
}
},
@@ -1145,6 +1146,17 @@
if (!state) return;
this.pendingRestoreState = null;
// Update map filters from form (which was set from hash in initFromUrl)
var formData = this.getFormData();
PropertyMap.currentFilters = {
status: 'Active',
property_type: formData.property_type || '',
city: formData.property_location || '',
min_price: formData.min_price || '',
max_price: formData.max_price || '',
min_beds: formData.beds || ''
};
// If we have map coordinates, set map position first (without triggering load)
if (state.lat !== null && state.lng !== null && state.zoom !== null && PropertyMap.map) {
// Temporarily disable map events
@@ -1563,22 +1575,19 @@
},
/**
* Update browser URL (filters in query, state in hash)
* Stores: page, scroll position, map position/zoom
* Update browser URL (all state in hash to avoid WordPress query var conflicts)
* Stores: filters, page, scroll position, map position/zoom
*/
updateUrl: function(formData, page) {
var url = new URL(homeprozAjax.archiveUrl);
var hashParts = [];
// Add non-empty filters to URL query params
// Add non-empty filters to hash
for (var key in formData) {
if (formData[key]) {
url.searchParams.set(key, formData[key]);
hashParts.push(key + '=' + encodeURIComponent(formData[key]));
}
}
// Build hash with all state info
var hashParts = [];
if (page > 1) {
hashParts.push('page=' + page);
}
@@ -1600,10 +1609,10 @@
hashParts.push('zoom=' + zoom);
}
url.hash = hashParts.length ? hashParts.join('&') : '';
var newUrl = homeprozAjax.archiveUrl + (hashParts.length ? '#' + hashParts.join('&') : '');
// Use replaceState to avoid adding history entries for every page
history.replaceState(null, '', url.toString());
history.replaceState(null, '', newUrl);
},
/**
@@ -1624,26 +1633,35 @@
},
/**
* Parse state from URL hash
* Parse state from URL hash (includes filters, page, scroll, map state)
*/
getStateFromHash: function() {
var hash = window.location.hash.replace('#', '');
if (!hash) return null;
var state = {};
var raw = {};
hash.split('&').forEach(function(part) {
var kv = part.split('=');
if (kv.length === 2) {
state[kv[0]] = kv[1];
raw[kv[0]] = decodeURIComponent(kv[1]);
}
});
// Return object with all values - filters will be accessed by name
return {
page: state.page ? parseInt(state.page) : 1,
scroll: state.scroll ? parseInt(state.scroll) : 0,
lat: state.lat ? parseFloat(state.lat) : null,
lng: state.lng ? parseFloat(state.lng) : null,
zoom: state.zoom ? parseInt(state.zoom) : null
// Filters
property_type: raw.property_type || '',
property_location: raw.property_location || '',
zip: raw.zip || '',
min_price: raw.min_price || '',
max_price: raw.max_price || '',
beds: raw.beds || '',
// State
page: raw.page ? parseInt(raw.page) : 1,
scroll: raw.scroll ? parseInt(raw.scroll) : 0,
lat: raw.lat ? parseFloat(raw.lat) : null,
lng: raw.lng ? parseFloat(raw.lng) : null,
zoom: raw.zoom ? parseInt(raw.zoom) : null
};
},
@@ -1666,7 +1684,88 @@
*/
resetFilters: function() {
this.$form.find('select').val('');
this.filterProperties(1);
this.onFilterChange();
},
/**
* Handle filter change - fetch bounds and reposition map if needed
*/
onFilterChange: function() {
var self = this;
var formData = this.getFormData();
// Update map filters
if (PropertyMap.map) {
PropertyMap.currentFilters = {
status: 'Active',
property_type: formData.property_type || '',
city: formData.property_location || '',
min_price: formData.min_price || '',
max_price: formData.max_price || '',
min_beds: formData.beds || ''
};
}
// If map is not visible, just filter properties
if (!PropertyMap.map) {
this.filterProperties(1);
return;
}
// Fetch bounds for the new filter set
$.ajax({
url: homeprozAjax.ajaxUrl,
type: 'GET',
data: {
action: 'homeproz_get_filter_bounds',
property_type: formData.property_type,
city: formData.property_location,
min_price: formData.min_price,
max_price: formData.max_price,
min_beds: formData.beds
},
success: function(response) {
if (response.success && response.data) {
var filterBounds = response.data;
var mapBounds = PropertyMap.map.getBounds();
// Check if map view intersects with filter bounds
var filterLatLngBounds = L.latLngBounds(
[filterBounds.sw_lat, filterBounds.sw_lng],
[filterBounds.ne_lat, filterBounds.ne_lng]
);
// Check if ANY of the filter bounds corners are visible in the map
// OR if the map view is fully contained within the filter bounds
var mapIntersects = mapBounds.intersects(filterLatLngBounds);
if (!mapIntersects) {
// Add 10% padding to bounds
var latPadding = (filterBounds.ne_lat - filterBounds.sw_lat) * 0.1;
var lngPadding = (filterBounds.ne_lng - filterBounds.sw_lng) * 0.1;
var paddedBounds = L.latLngBounds(
[filterBounds.sw_lat - latPadding, filterBounds.sw_lng - lngPadding],
[filterBounds.ne_lat + latPadding, filterBounds.ne_lng + lngPadding]
);
// Reposition map to show filtered properties
PropertyMap.map.fitBounds(paddedBounds);
// The moveend event will trigger loadClusters and updateFromMap
} else {
// Map already shows relevant area, just reload clusters and properties
PropertyMap.loadClusters();
}
} else {
// No properties found with these filters, just reload
PropertyMap.loadClusters();
}
},
error: function() {
// On error, just proceed with normal filter
PropertyMap.loadClusters();
}
});
}
};
@@ -1693,7 +1792,17 @@
// Initialize map if above breakpoint, map view selected, and we have data
if (this.isAboveBreakpoint && this.isMapView && typeof homeprozMapData !== 'undefined') {
PropertyMap.init(homeprozMapData.initialFilters || {});
// Get filters from form (which was set from hash in initFromUrl)
var formData = PropertyFilters.getFormData();
var mapFilters = {
status: 'Active',
property_type: formData.property_type || '',
city: formData.property_location || '',
min_price: formData.min_price || '',
max_price: formData.max_price || '',
min_beds: formData.beds || ''
};
PropertyMap.init(mapFilters);
this.mapInitialized = true;
// Restore state if we have pending state to restore
@@ -1733,7 +1842,17 @@
// Initialize map if not already done
if (!this.mapInitialized && typeof homeprozMapData !== 'undefined') {
PropertyMap.init(homeprozMapData.initialFilters || {});
// Get filters from form
var formData = PropertyFilters.getFormData();
var mapFilters = {
status: 'Active',
property_type: formData.property_type || '',
city: formData.property_location || '',
min_price: formData.min_price || '',
max_price: formData.max_price || '',
min_beds: formData.beds || ''
};
PropertyMap.init(mapFilters);
this.mapInitialized = true;
// Restore state if we have pending state to restore