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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,20 @@ class MLS_DB {
|
|||||||
return $this->get_table_name(MLS_TABLE_MEDIA_LOG);
|
return $this->get_table_name(MLS_TABLE_MEDIA_LOG);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get geo cities table name
|
||||||
|
*/
|
||||||
|
public function geo_cities_table() {
|
||||||
|
return $this->get_table_name(MLS_TABLE_GEO_CITIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get geo zipcodes table name
|
||||||
|
*/
|
||||||
|
public function geo_zipcodes_table() {
|
||||||
|
return $this->get_table_name(MLS_TABLE_GEO_ZIPCODES);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create all database tables
|
* Create all database tables
|
||||||
*/
|
*/
|
||||||
@@ -283,6 +297,41 @@ class MLS_DB {
|
|||||||
|
|
||||||
dbDelta($sql_media_log);
|
dbDelta($sql_media_log);
|
||||||
|
|
||||||
|
// Geo cities table
|
||||||
|
$table_geo_cities = $wpdb->prefix . MLS_TABLE_GEO_CITIES;
|
||||||
|
$sql_geo_cities = "CREATE TABLE {$table_geo_cities} (
|
||||||
|
id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
state_code VARCHAR(2) NOT NULL,
|
||||||
|
state_name VARCHAR(50) NOT NULL,
|
||||||
|
city VARCHAR(100) NOT NULL,
|
||||||
|
county VARCHAR(100) DEFAULT NULL,
|
||||||
|
latitude DECIMAL(10,6) NOT NULL,
|
||||||
|
longitude DECIMAL(10,6) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY state_code (state_code),
|
||||||
|
KEY city (city),
|
||||||
|
KEY state_city (state_code, city),
|
||||||
|
KEY lat_lng (latitude, longitude)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
dbDelta($sql_geo_cities);
|
||||||
|
|
||||||
|
// Geo zipcodes table
|
||||||
|
$table_geo_zipcodes = $wpdb->prefix . MLS_TABLE_GEO_ZIPCODES;
|
||||||
|
$sql_geo_zipcodes = "CREATE TABLE {$table_geo_zipcodes} (
|
||||||
|
id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
zipcode VARCHAR(10) NOT NULL,
|
||||||
|
latitude DECIMAL(10,6) NOT NULL,
|
||||||
|
longitude DECIMAL(10,6) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY zipcode (zipcode),
|
||||||
|
KEY lat_lng (latitude, longitude)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
dbDelta($sql_geo_zipcodes);
|
||||||
|
|
||||||
// Run index migrations
|
// Run index migrations
|
||||||
self::run_index_migrations();
|
self::run_index_migrations();
|
||||||
}
|
}
|
||||||
@@ -402,6 +451,8 @@ class MLS_DB {
|
|||||||
MLS_TABLE_RATE_LIMITS,
|
MLS_TABLE_RATE_LIMITS,
|
||||||
MLS_TABLE_SYNC_LOG,
|
MLS_TABLE_SYNC_LOG,
|
||||||
MLS_TABLE_MEDIA_LOG,
|
MLS_TABLE_MEDIA_LOG,
|
||||||
|
MLS_TABLE_GEO_CITIES,
|
||||||
|
MLS_TABLE_GEO_ZIPCODES,
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach ($tables as $table) {
|
foreach ($tables as $table) {
|
||||||
@@ -472,4 +523,159 @@ class MLS_DB {
|
|||||||
|
|
||||||
return $stats;
|
return $stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import geo data from CSV files
|
||||||
|
* Only imports if tables are empty
|
||||||
|
*
|
||||||
|
* @return array Import results with counts
|
||||||
|
*/
|
||||||
|
public static function import_geo_data() {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$results = array(
|
||||||
|
'cities_imported' => 0,
|
||||||
|
'zipcodes_imported' => 0,
|
||||||
|
'cities_skipped' => false,
|
||||||
|
'zipcodes_skipped' => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$cities_table = $wpdb->prefix . MLS_TABLE_GEO_CITIES;
|
||||||
|
$zipcodes_table = $wpdb->prefix . MLS_TABLE_GEO_ZIPCODES;
|
||||||
|
|
||||||
|
// Check if tables already have data
|
||||||
|
$cities_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$cities_table}");
|
||||||
|
$zipcodes_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$zipcodes_table}");
|
||||||
|
|
||||||
|
// Import cities if table is empty
|
||||||
|
if ($cities_count === 0) {
|
||||||
|
$cities_file = MLS_PLUGIN_DIR . 'data/us_cities.csv';
|
||||||
|
if (file_exists($cities_file)) {
|
||||||
|
$results['cities_imported'] = self::import_cities_csv($cities_file, $cities_table);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$results['cities_skipped'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import zipcodes if table is empty
|
||||||
|
if ($zipcodes_count === 0) {
|
||||||
|
$zipcodes_file = MLS_PLUGIN_DIR . 'data/us_zipcodes.csv';
|
||||||
|
if (file_exists($zipcodes_file)) {
|
||||||
|
$results['zipcodes_imported'] = self::import_zipcodes_csv($zipcodes_file, $zipcodes_table);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$results['zipcodes_skipped'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import cities CSV file
|
||||||
|
* CSV format: ID,STATE_CODE,STATE_NAME,CITY,COUNTY,LATITUDE,LONGITUDE
|
||||||
|
*
|
||||||
|
* @param string $file Path to CSV file
|
||||||
|
* @param string $table Table name
|
||||||
|
* @return int Number of rows imported
|
||||||
|
*/
|
||||||
|
private static function import_cities_csv($file, $table) {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$handle = fopen($file, 'r');
|
||||||
|
if (!$handle) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip header row
|
||||||
|
fgetcsv($handle);
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$batch = array();
|
||||||
|
$batch_size = 500;
|
||||||
|
|
||||||
|
while (($row = fgetcsv($handle)) !== false) {
|
||||||
|
if (count($row) < 7) continue;
|
||||||
|
|
||||||
|
// ID,STATE_CODE,STATE_NAME,CITY,COUNTY,LATITUDE,LONGITUDE
|
||||||
|
$batch[] = $wpdb->prepare(
|
||||||
|
"(%s, %s, %s, %s, %f, %f)",
|
||||||
|
$row[1], // state_code
|
||||||
|
$row[2], // state_name
|
||||||
|
$row[3], // city
|
||||||
|
$row[4], // county
|
||||||
|
(float) $row[5], // latitude
|
||||||
|
(float) $row[6] // longitude
|
||||||
|
);
|
||||||
|
|
||||||
|
if (count($batch) >= $batch_size) {
|
||||||
|
$values = implode(',', $batch);
|
||||||
|
$wpdb->query("INSERT INTO {$table} (state_code, state_name, city, county, latitude, longitude) VALUES {$values}");
|
||||||
|
$count += count($batch);
|
||||||
|
$batch = array();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert remaining rows
|
||||||
|
if (!empty($batch)) {
|
||||||
|
$values = implode(',', $batch);
|
||||||
|
$wpdb->query("INSERT INTO {$table} (state_code, state_name, city, county, latitude, longitude) VALUES {$values}");
|
||||||
|
$count += count($batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import zipcodes CSV file
|
||||||
|
* CSV format: ZIP,LAT,LNG
|
||||||
|
*
|
||||||
|
* @param string $file Path to CSV file
|
||||||
|
* @param string $table Table name
|
||||||
|
* @return int Number of rows imported
|
||||||
|
*/
|
||||||
|
private static function import_zipcodes_csv($file, $table) {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$handle = fopen($file, 'r');
|
||||||
|
if (!$handle) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip header row
|
||||||
|
fgetcsv($handle);
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$batch = array();
|
||||||
|
$batch_size = 500;
|
||||||
|
|
||||||
|
while (($row = fgetcsv($handle)) !== false) {
|
||||||
|
if (count($row) < 3) continue;
|
||||||
|
|
||||||
|
// ZIP,LAT,LNG
|
||||||
|
$batch[] = $wpdb->prepare(
|
||||||
|
"(%s, %f, %f)",
|
||||||
|
trim($row[0]), // zipcode
|
||||||
|
(float) trim($row[1]), // latitude
|
||||||
|
(float) trim($row[2]) // longitude
|
||||||
|
);
|
||||||
|
|
||||||
|
if (count($batch) >= $batch_size) {
|
||||||
|
$values = implode(',', $batch);
|
||||||
|
$wpdb->query("INSERT INTO {$table} (zipcode, latitude, longitude) VALUES {$values}");
|
||||||
|
$count += count($batch);
|
||||||
|
$batch = array();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert remaining rows
|
||||||
|
if (!empty($batch)) {
|
||||||
|
$values = implode(',', $batch);
|
||||||
|
$wpdb->query("INSERT INTO {$table} (zipcode, latitude, longitude) VALUES {$values}");
|
||||||
|
$count += count($batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ class MLS_Query {
|
|||||||
return 'state_or_province IN (' . implode(',', $states) . ')';
|
return 'state_or_province IN (' . implode(',', $states) . ')';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the TBD address exclusion filter
|
||||||
|
* Excludes properties with "TBD" as street number
|
||||||
|
*
|
||||||
|
* @return string SQL clause
|
||||||
|
*/
|
||||||
|
private function get_tbd_exclusion_filter() {
|
||||||
|
return "(street_number IS NULL OR (street_number != 'TBD' AND street_number NOT LIKE 'TBD %'))";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get properties matching criteria
|
* Get properties matching criteria
|
||||||
*
|
*
|
||||||
@@ -102,6 +112,9 @@ class MLS_Query {
|
|||||||
$where[] = $state_filter;
|
$where[] = $state_filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exclude TBD addresses
|
||||||
|
$where[] = $this->get_tbd_exclusion_filter();
|
||||||
|
|
||||||
if ($args['status']) {
|
if ($args['status']) {
|
||||||
$where[] = 'standard_status = %s';
|
$where[] = 'standard_status = %s';
|
||||||
$values[] = $args['status'];
|
$values[] = $args['status'];
|
||||||
@@ -330,18 +343,19 @@ class MLS_Query {
|
|||||||
$table = $this->db->properties_table();
|
$table = $this->db->properties_table();
|
||||||
$state_filter = $this->get_state_filter();
|
$state_filter = $this->get_state_filter();
|
||||||
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
||||||
|
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
|
||||||
|
|
||||||
if ($status) {
|
if ($status) {
|
||||||
$cities = $wpdb->get_col($wpdb->prepare(
|
$cities = $wpdb->get_col($wpdb->prepare(
|
||||||
"SELECT DISTINCT city FROM {$table}
|
"SELECT DISTINCT city FROM {$table}
|
||||||
WHERE mlg_can_view = 1 AND standard_status = %s AND city IS NOT NULL{$state_clause}
|
WHERE mlg_can_view = 1 AND standard_status = %s AND city IS NOT NULL{$state_clause}{$tbd_clause}
|
||||||
ORDER BY city ASC",
|
ORDER BY city ASC",
|
||||||
$status
|
$status
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
$cities = $wpdb->get_col(
|
$cities = $wpdb->get_col(
|
||||||
"SELECT DISTINCT city FROM {$table}
|
"SELECT DISTINCT city FROM {$table}
|
||||||
WHERE mlg_can_view = 1 AND city IS NOT NULL{$state_clause}
|
WHERE mlg_can_view = 1 AND city IS NOT NULL{$state_clause}{$tbd_clause}
|
||||||
ORDER BY city ASC"
|
ORDER BY city ASC"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -361,18 +375,19 @@ class MLS_Query {
|
|||||||
$table = $this->db->properties_table();
|
$table = $this->db->properties_table();
|
||||||
$state_filter = $this->get_state_filter();
|
$state_filter = $this->get_state_filter();
|
||||||
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
||||||
|
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
|
||||||
|
|
||||||
if ($status) {
|
if ($status) {
|
||||||
$counties = $wpdb->get_col($wpdb->prepare(
|
$counties = $wpdb->get_col($wpdb->prepare(
|
||||||
"SELECT DISTINCT county FROM {$table}
|
"SELECT DISTINCT county FROM {$table}
|
||||||
WHERE mlg_can_view = 1 AND standard_status = %s AND county IS NOT NULL{$state_clause}
|
WHERE mlg_can_view = 1 AND standard_status = %s AND county IS NOT NULL{$state_clause}{$tbd_clause}
|
||||||
ORDER BY county ASC",
|
ORDER BY county ASC",
|
||||||
$status
|
$status
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
$counties = $wpdb->get_col(
|
$counties = $wpdb->get_col(
|
||||||
"SELECT DISTINCT county FROM {$table}
|
"SELECT DISTINCT county FROM {$table}
|
||||||
WHERE mlg_can_view = 1 AND county IS NOT NULL{$state_clause}
|
WHERE mlg_can_view = 1 AND county IS NOT NULL{$state_clause}{$tbd_clause}
|
||||||
ORDER BY county ASC"
|
ORDER BY county ASC"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -400,6 +415,9 @@ class MLS_Query {
|
|||||||
$where[] = $state_filter;
|
$where[] = $state_filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exclude TBD addresses
|
||||||
|
$where[] = $this->get_tbd_exclusion_filter();
|
||||||
|
|
||||||
if (!empty($args['status'])) {
|
if (!empty($args['status'])) {
|
||||||
$where[] = 'standard_status = %s';
|
$where[] = 'standard_status = %s';
|
||||||
$values[] = $args['status'];
|
$values[] = $args['status'];
|
||||||
@@ -472,9 +490,10 @@ class MLS_Query {
|
|||||||
|
|
||||||
$state_filter = $this->get_state_filter();
|
$state_filter = $this->get_state_filter();
|
||||||
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
||||||
|
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
|
||||||
|
|
||||||
$count = $wpdb->get_var(
|
$count = $wpdb->get_var(
|
||||||
"SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE mlg_can_view = 1{$state_clause}"
|
"SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE mlg_can_view = 1{$state_clause}{$tbd_clause}"
|
||||||
);
|
);
|
||||||
|
|
||||||
return (int) $count > 0;
|
return (int) $count > 0;
|
||||||
@@ -492,12 +511,13 @@ class MLS_Query {
|
|||||||
$table = $this->db->properties_table();
|
$table = $this->db->properties_table();
|
||||||
$state_filter = $this->get_state_filter();
|
$state_filter = $this->get_state_filter();
|
||||||
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
||||||
|
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
|
||||||
|
|
||||||
if ($status) {
|
if ($status) {
|
||||||
return $wpdb->get_results($wpdb->prepare(
|
return $wpdb->get_results($wpdb->prepare(
|
||||||
"SELECT property_type, COUNT(*) as count
|
"SELECT property_type, COUNT(*) as count
|
||||||
FROM {$table}
|
FROM {$table}
|
||||||
WHERE mlg_can_view = 1 AND standard_status = %s AND property_type IS NOT NULL{$state_clause}
|
WHERE mlg_can_view = 1 AND standard_status = %s AND property_type IS NOT NULL{$state_clause}{$tbd_clause}
|
||||||
GROUP BY property_type
|
GROUP BY property_type
|
||||||
ORDER BY count DESC",
|
ORDER BY count DESC",
|
||||||
$status
|
$status
|
||||||
@@ -507,7 +527,7 @@ class MLS_Query {
|
|||||||
return $wpdb->get_results(
|
return $wpdb->get_results(
|
||||||
"SELECT property_type, COUNT(*) as count
|
"SELECT property_type, COUNT(*) as count
|
||||||
FROM {$table}
|
FROM {$table}
|
||||||
WHERE mlg_can_view = 1 AND property_type IS NOT NULL{$state_clause}
|
WHERE mlg_can_view = 1 AND property_type IS NOT NULL{$state_clause}{$tbd_clause}
|
||||||
GROUP BY property_type
|
GROUP BY property_type
|
||||||
ORDER BY count DESC"
|
ORDER BY count DESC"
|
||||||
);
|
);
|
||||||
@@ -525,12 +545,13 @@ class MLS_Query {
|
|||||||
$table = $this->db->properties_table();
|
$table = $this->db->properties_table();
|
||||||
$state_filter = $this->get_state_filter();
|
$state_filter = $this->get_state_filter();
|
||||||
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
||||||
|
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
|
||||||
|
|
||||||
if ($status) {
|
if ($status) {
|
||||||
return $wpdb->get_row($wpdb->prepare(
|
return $wpdb->get_row($wpdb->prepare(
|
||||||
"SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
|
"SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
|
||||||
FROM {$table}
|
FROM {$table}
|
||||||
WHERE mlg_can_view = 1 AND standard_status = %s AND list_price > 0{$state_clause}",
|
WHERE mlg_can_view = 1 AND standard_status = %s AND list_price > 0{$state_clause}{$tbd_clause}",
|
||||||
$status
|
$status
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -538,7 +559,7 @@ class MLS_Query {
|
|||||||
return $wpdb->get_row(
|
return $wpdb->get_row(
|
||||||
"SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
|
"SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
|
||||||
FROM {$table}
|
FROM {$table}
|
||||||
WHERE mlg_can_view = 1 AND list_price > 0{$state_clause}"
|
WHERE mlg_can_view = 1 AND list_price > 0{$state_clause}{$tbd_clause}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,4 +609,89 @@ class MLS_Query {
|
|||||||
|
|
||||||
return $street ?: $location;
|
return $street ?: $location;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get geographic bounds for filtered properties
|
||||||
|
* Returns min/max lat/lng for all properties matching the filters
|
||||||
|
*
|
||||||
|
* @param array $args Filter arguments (same as get_properties, but bounds is ignored)
|
||||||
|
* @return array|null Bounds array with sw_lat, sw_lng, ne_lat, ne_lng or null if no results
|
||||||
|
*/
|
||||||
|
public function get_filter_bounds($args = array()) {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$table = $this->db->properties_table();
|
||||||
|
|
||||||
|
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL');
|
||||||
|
$values = array();
|
||||||
|
|
||||||
|
// Add state filter (MN and IA only)
|
||||||
|
$state_filter = $this->get_state_filter();
|
||||||
|
if ($state_filter) {
|
||||||
|
$where[] = $state_filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude TBD addresses
|
||||||
|
$where[] = $this->get_tbd_exclusion_filter();
|
||||||
|
|
||||||
|
if (!empty($args['status'])) {
|
||||||
|
$where[] = 'standard_status = %s';
|
||||||
|
$values[] = $args['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($args['property_type'])) {
|
||||||
|
$where[] = 'property_type = %s';
|
||||||
|
$values[] = $args['property_type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($args['city'])) {
|
||||||
|
$where[] = 'city = %s';
|
||||||
|
$values[] = $args['city'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($args['postal_code'])) {
|
||||||
|
$where[] = 'postal_code = %s';
|
||||||
|
$values[] = $args['postal_code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($args['min_price'])) {
|
||||||
|
$where[] = 'list_price >= %d';
|
||||||
|
$values[] = (int) $args['min_price'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($args['max_price'])) {
|
||||||
|
$where[] = 'list_price <= %d';
|
||||||
|
$values[] = (int) $args['max_price'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($args['min_beds'])) {
|
||||||
|
$where[] = 'bedrooms_total >= %d';
|
||||||
|
$values[] = (int) $args['min_beds'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT
|
||||||
|
MIN(latitude) as sw_lat,
|
||||||
|
MIN(longitude) as sw_lng,
|
||||||
|
MAX(latitude) as ne_lat,
|
||||||
|
MAX(longitude) as ne_lng
|
||||||
|
FROM {$table}
|
||||||
|
WHERE " . implode(' AND ', $where);
|
||||||
|
|
||||||
|
if (!empty($values)) {
|
||||||
|
$result = $wpdb->get_row($wpdb->prepare($sql, $values));
|
||||||
|
} else {
|
||||||
|
$result = $wpdb->get_row($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$result || $result->sw_lat === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'sw_lat' => (float) $result->sw_lat,
|
||||||
|
'sw_lng' => (float) $result->sw_lng,
|
||||||
|
'ne_lat' => (float) $result->ne_lat,
|
||||||
|
'ne_lng' => (float) $result->ne_lng,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ define('MLS_TABLE_SYNC_STATE', 'mls_sync_state');
|
|||||||
define('MLS_TABLE_RATE_LIMITS', 'mls_rate_limits');
|
define('MLS_TABLE_RATE_LIMITS', 'mls_rate_limits');
|
||||||
define('MLS_TABLE_SYNC_LOG', 'mls_sync_log');
|
define('MLS_TABLE_SYNC_LOG', 'mls_sync_log');
|
||||||
define('MLS_TABLE_MEDIA_LOG', 'mls_media_log');
|
define('MLS_TABLE_MEDIA_LOG', 'mls_media_log');
|
||||||
|
define('MLS_TABLE_GEO_CITIES', 'mls_geo_cities');
|
||||||
|
define('MLS_TABLE_GEO_ZIPCODES', 'mls_geo_zipcodes');
|
||||||
|
|
||||||
// HomeProz office MLS ID for identifying our listings
|
// HomeProz office MLS ID for identifying our listings
|
||||||
define('MLS_HOMEPROZ_OFFICE_ID', 'NST253235');
|
define('MLS_HOMEPROZ_OFFICE_ID', 'NST253235');
|
||||||
@@ -390,6 +392,20 @@ function mls_get_property_count($args = array()) {
|
|||||||
return $plugin->get_query()->get_count($args);
|
return $plugin->get_query()->get_count($args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get geographic bounds for filtered properties
|
||||||
|
*
|
||||||
|
* @param array $args Filter arguments
|
||||||
|
* @return array|null Bounds with sw_lat, sw_lng, ne_lat, ne_lng
|
||||||
|
*/
|
||||||
|
function mls_get_filter_bounds($args = array()) {
|
||||||
|
$plugin = mls_plugin();
|
||||||
|
if (!$plugin->get_query()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $plugin->get_query()->get_filter_bounds($args);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all images for a listing (on-demand fetching)
|
* Get all images for a listing (on-demand fetching)
|
||||||
*
|
*
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -125,7 +125,7 @@ function homeproz_ajax_filter_properties() {
|
|||||||
<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 if ($has_map_bounds) : ?>
|
<?php if ($has_map_bounds) : ?>
|
||||||
in view
|
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_homeproz_filter_properties', 'homeproz_ajax_filter_properties');
|
||||||
add_action('wp_ajax_nopriv_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
|
* Localize script data for AJAX
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1079,7 +1079,7 @@
|
|||||||
// Filter changes (auto-submit on select change)
|
// Filter changes (auto-submit on select change)
|
||||||
this.$form.find('select').on('change', function() {
|
this.$form.find('select').on('change', function() {
|
||||||
self.clearPinSelection();
|
self.clearPinSelection();
|
||||||
self.filterProperties(1);
|
self.onFilterChange();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset button
|
// 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() {
|
initFromUrl: function() {
|
||||||
var self = this;
|
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() {
|
this.$form.find('select').each(function() {
|
||||||
var name = $(this).attr('name');
|
var name = $(this).attr('name');
|
||||||
if (params.has(name)) {
|
if (state[name]) {
|
||||||
$(this).val(params.get(name));
|
$(this).val(state[name]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also handle text inputs (like zip)
|
// Also handle text inputs (like zip)
|
||||||
this.$form.find('input[type="text"]').each(function() {
|
this.$form.find('input[type="text"]').each(function() {
|
||||||
var name = $(this).attr('name');
|
var name = $(this).attr('name');
|
||||||
if (params.has(name)) {
|
if (state[name]) {
|
||||||
$(this).val(params.get(name));
|
$(this).val(state[name]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get full state from hash
|
|
||||||
var state = this.getStateFromHash();
|
|
||||||
|
|
||||||
// Store restoration state for map init to use
|
// 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;
|
this.pendingRestoreState = state;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1145,6 +1146,17 @@
|
|||||||
if (!state) return;
|
if (!state) return;
|
||||||
this.pendingRestoreState = null;
|
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 we have map coordinates, set map position first (without triggering load)
|
||||||
if (state.lat !== null && state.lng !== null && state.zoom !== null && PropertyMap.map) {
|
if (state.lat !== null && state.lng !== null && state.zoom !== null && PropertyMap.map) {
|
||||||
// Temporarily disable map events
|
// Temporarily disable map events
|
||||||
@@ -1563,22 +1575,19 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update browser URL (filters in query, state in hash)
|
* Update browser URL (all state in hash to avoid WordPress query var conflicts)
|
||||||
* Stores: page, scroll position, map position/zoom
|
* Stores: filters, page, scroll position, map position/zoom
|
||||||
*/
|
*/
|
||||||
updateUrl: function(formData, page) {
|
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) {
|
for (var key in formData) {
|
||||||
if (formData[key]) {
|
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) {
|
if (page > 1) {
|
||||||
hashParts.push('page=' + page);
|
hashParts.push('page=' + page);
|
||||||
}
|
}
|
||||||
@@ -1600,10 +1609,10 @@
|
|||||||
hashParts.push('zoom=' + zoom);
|
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
|
// 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() {
|
getStateFromHash: function() {
|
||||||
var hash = window.location.hash.replace('#', '');
|
var hash = window.location.hash.replace('#', '');
|
||||||
if (!hash) return null;
|
if (!hash) return null;
|
||||||
|
|
||||||
var state = {};
|
var raw = {};
|
||||||
hash.split('&').forEach(function(part) {
|
hash.split('&').forEach(function(part) {
|
||||||
var kv = part.split('=');
|
var kv = part.split('=');
|
||||||
if (kv.length === 2) {
|
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 {
|
return {
|
||||||
page: state.page ? parseInt(state.page) : 1,
|
// Filters
|
||||||
scroll: state.scroll ? parseInt(state.scroll) : 0,
|
property_type: raw.property_type || '',
|
||||||
lat: state.lat ? parseFloat(state.lat) : null,
|
property_location: raw.property_location || '',
|
||||||
lng: state.lng ? parseFloat(state.lng) : null,
|
zip: raw.zip || '',
|
||||||
zoom: state.zoom ? parseInt(state.zoom) : null
|
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() {
|
resetFilters: function() {
|
||||||
this.$form.find('select').val('');
|
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
|
// Initialize map if above breakpoint, map view selected, and we have data
|
||||||
if (this.isAboveBreakpoint && this.isMapView && typeof homeprozMapData !== 'undefined') {
|
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;
|
this.mapInitialized = true;
|
||||||
|
|
||||||
// Restore state if we have pending state to restore
|
// Restore state if we have pending state to restore
|
||||||
@@ -1733,7 +1842,17 @@
|
|||||||
|
|
||||||
// Initialize map if not already done
|
// Initialize map if not already done
|
||||||
if (!this.mapInitialized && typeof homeprozMapData !== 'undefined') {
|
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;
|
this.mapInitialized = true;
|
||||||
|
|
||||||
// Restore state if we have pending state to restore
|
// Restore state if we have pending state to restore
|
||||||
|
|||||||
Reference in New Issue
Block a user