acd606bb03
Major changes: - Property list now updates when map pans/zooms - Properties sorted by distance from map center (closest first) - Shows "X properties in view" when viewport filtering active - Min 30 properties required before grouping kicks in - Added rule to CLAUDE.md: no commits unless asked Backend: - MLS_Query: Added bounds filtering and distance-based sorting - AJAX handler: Accepts bounds/center, sorts by distance when provided Frontend: - Map move triggers property list refresh with same viewport - Loop prevention flag to avoid map->filter->map recursion - Resets to page 1 when viewport changes Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
552 lines
16 KiB
PHP
552 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* MLS Query Class
|
|
*
|
|
* Public API for querying cached MLS data
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
class MLS_Query {
|
|
|
|
/**
|
|
* Database instance
|
|
*/
|
|
private $db;
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
public function __construct(MLS_DB $db) {
|
|
$this->db = $db;
|
|
}
|
|
|
|
/**
|
|
* Get properties matching criteria
|
|
*
|
|
* @param array $args Query arguments
|
|
* @return array Property objects
|
|
*/
|
|
public function get_properties($args = array()) {
|
|
global $wpdb;
|
|
|
|
$defaults = array(
|
|
'status' => null, // Active, Pending, Closed
|
|
'property_type' => null, // Residential, Land, Commercial
|
|
'city' => null,
|
|
'county' => null,
|
|
'postal_code' => null,
|
|
'min_price' => null,
|
|
'max_price' => null,
|
|
'min_beds' => null,
|
|
'max_beds' => null,
|
|
'min_baths' => null,
|
|
'min_sqft' => null,
|
|
'max_sqft' => null,
|
|
'year_built_min' => null,
|
|
'year_built_max' => null,
|
|
'listing_key' => null,
|
|
'listing_id' => null,
|
|
'search' => null, // Search in address/remarks
|
|
'bounds' => null, // Map bounds: array(sw_lat, sw_lng, ne_lat, ne_lng)
|
|
'center' => null, // Map center for distance sort: array(lat, lng)
|
|
'limit' => 20,
|
|
'offset' => 0,
|
|
'orderby' => 'modification_timestamp',
|
|
'order' => 'DESC',
|
|
'include_media' => false,
|
|
'fields' => '*', // Specific fields or *
|
|
);
|
|
|
|
$args = wp_parse_args($args, $defaults);
|
|
|
|
// Build query
|
|
$table = $this->db->properties_table();
|
|
|
|
// Fields
|
|
if ($args['fields'] === '*') {
|
|
$select = '*';
|
|
} else {
|
|
$fields = array_map('sanitize_key', (array) $args['fields']);
|
|
$select = implode(', ', $fields);
|
|
}
|
|
|
|
$sql = "SELECT {$select} FROM {$table}";
|
|
|
|
// WHERE conditions
|
|
$where = array('mlg_can_view = 1');
|
|
$values = array();
|
|
|
|
if ($args['status']) {
|
|
$where[] = 'standard_status = %s';
|
|
$values[] = $args['status'];
|
|
}
|
|
|
|
if ($args['property_type']) {
|
|
$where[] = 'property_type = %s';
|
|
$values[] = $args['property_type'];
|
|
}
|
|
|
|
if ($args['city']) {
|
|
$where[] = 'city = %s';
|
|
$values[] = $args['city'];
|
|
}
|
|
|
|
if ($args['county']) {
|
|
$where[] = 'county = %s';
|
|
$values[] = $args['county'];
|
|
}
|
|
|
|
if ($args['postal_code']) {
|
|
$where[] = 'postal_code = %s';
|
|
$values[] = $args['postal_code'];
|
|
}
|
|
|
|
if ($args['min_price']) {
|
|
$where[] = 'list_price >= %d';
|
|
$values[] = (int) $args['min_price'];
|
|
}
|
|
|
|
if ($args['max_price']) {
|
|
$where[] = 'list_price <= %d';
|
|
$values[] = (int) $args['max_price'];
|
|
}
|
|
|
|
if ($args['min_beds']) {
|
|
$where[] = 'bedrooms_total >= %d';
|
|
$values[] = (int) $args['min_beds'];
|
|
}
|
|
|
|
if ($args['max_beds']) {
|
|
$where[] = 'bedrooms_total <= %d';
|
|
$values[] = (int) $args['max_beds'];
|
|
}
|
|
|
|
if ($args['min_baths']) {
|
|
$where[] = 'bathrooms_total >= %d';
|
|
$values[] = (int) $args['min_baths'];
|
|
}
|
|
|
|
if ($args['min_sqft']) {
|
|
$where[] = 'living_area >= %d';
|
|
$values[] = (int) $args['min_sqft'];
|
|
}
|
|
|
|
if ($args['max_sqft']) {
|
|
$where[] = 'living_area <= %d';
|
|
$values[] = (int) $args['max_sqft'];
|
|
}
|
|
|
|
if ($args['year_built_min']) {
|
|
$where[] = 'year_built >= %d';
|
|
$values[] = (int) $args['year_built_min'];
|
|
}
|
|
|
|
if ($args['year_built_max']) {
|
|
$where[] = 'year_built <= %d';
|
|
$values[] = (int) $args['year_built_max'];
|
|
}
|
|
|
|
if ($args['listing_key']) {
|
|
$where[] = 'listing_key = %s';
|
|
$values[] = $args['listing_key'];
|
|
}
|
|
|
|
if ($args['listing_id']) {
|
|
$where[] = 'listing_id = %s';
|
|
$values[] = $args['listing_id'];
|
|
}
|
|
|
|
if ($args['search']) {
|
|
$search_term = '%' . $wpdb->esc_like($args['search']) . '%';
|
|
$where[] = '(street_name LIKE %s OR city LIKE %s OR public_remarks LIKE %s OR listing_id LIKE %s)';
|
|
$values[] = $search_term;
|
|
$values[] = $search_term;
|
|
$values[] = $search_term;
|
|
$values[] = $search_term;
|
|
}
|
|
|
|
// Map bounds filtering
|
|
if ($args['bounds'] && is_array($args['bounds']) && count($args['bounds']) === 4) {
|
|
list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds'];
|
|
$where[] = 'latitude BETWEEN %f AND %f';
|
|
$where[] = 'longitude BETWEEN %f AND %f';
|
|
$where[] = 'latitude IS NOT NULL';
|
|
$where[] = 'longitude IS NOT NULL';
|
|
$values[] = (float) $sw_lat;
|
|
$values[] = (float) $ne_lat;
|
|
$values[] = (float) $sw_lng;
|
|
$values[] = (float) $ne_lng;
|
|
}
|
|
|
|
$sql .= ' WHERE ' . implode(' AND ', $where);
|
|
|
|
// ORDER BY
|
|
// If center provided, sort by distance from center
|
|
if ($args['center'] && is_array($args['center']) && count($args['center']) === 2) {
|
|
list($center_lat, $center_lng) = $args['center'];
|
|
// Haversine formula approximation for distance (good enough for sorting)
|
|
// Using squared Euclidean distance with latitude adjustment for speed
|
|
$lat_factor = cos(deg2rad((float) $center_lat));
|
|
$sql .= $wpdb->prepare(
|
|
" ORDER BY (POW(latitude - %f, 2) + POW((longitude - %f) * %f, 2)) ASC",
|
|
(float) $center_lat,
|
|
(float) $center_lng,
|
|
$lat_factor
|
|
);
|
|
} else {
|
|
$allowed_orderby = array(
|
|
'modification_timestamp',
|
|
'list_price',
|
|
'bedrooms_total',
|
|
'bathrooms_total',
|
|
'living_area',
|
|
'year_built',
|
|
'days_on_market',
|
|
'city',
|
|
'created_at',
|
|
);
|
|
|
|
$orderby = in_array($args['orderby'], $allowed_orderby) ? $args['orderby'] : 'modification_timestamp';
|
|
$order = strtoupper($args['order']) === 'ASC' ? 'ASC' : 'DESC';
|
|
$sql .= " ORDER BY {$orderby} {$order}";
|
|
}
|
|
|
|
// LIMIT/OFFSET
|
|
$sql .= ' LIMIT %d OFFSET %d';
|
|
$values[] = (int) $args['limit'];
|
|
$values[] = (int) $args['offset'];
|
|
|
|
// Execute
|
|
$results = $wpdb->get_results($wpdb->prepare($sql, $values));
|
|
|
|
// Include media if requested
|
|
if ($args['include_media'] && $results) {
|
|
foreach ($results as &$property) {
|
|
$property->media = $this->get_property_media($property->listing_key);
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* Get a single property
|
|
*
|
|
* @param string $identifier Listing key or listing ID
|
|
* @return object|null Property object
|
|
*/
|
|
public function get_property($identifier) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
|
|
// Try listing_key first
|
|
$property = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT * FROM {$table} WHERE listing_key = %s AND mlg_can_view = 1",
|
|
$identifier
|
|
));
|
|
|
|
// Try listing_id if not found
|
|
if (!$property) {
|
|
$property = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT * FROM {$table} WHERE listing_id = %s AND mlg_can_view = 1",
|
|
$identifier
|
|
));
|
|
}
|
|
|
|
return $property;
|
|
}
|
|
|
|
/**
|
|
* Get media for a property
|
|
*
|
|
* @param string $listing_key Listing key
|
|
* @return array Media objects
|
|
*/
|
|
public function get_property_media($listing_key) {
|
|
global $wpdb;
|
|
|
|
return $wpdb->get_results($wpdb->prepare(
|
|
"SELECT * FROM {$this->db->media_table()}
|
|
WHERE listing_key = %s
|
|
ORDER BY media_order ASC",
|
|
$listing_key
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Get primary image URL
|
|
*
|
|
* @param string $listing_key Listing key
|
|
* @return string|null Image URL
|
|
*/
|
|
public function get_primary_image($listing_key) {
|
|
global $wpdb;
|
|
|
|
return $wpdb->get_var($wpdb->prepare(
|
|
"SELECT local_url FROM {$this->db->media_table()}
|
|
WHERE listing_key = %s AND local_url IS NOT NULL
|
|
ORDER BY media_order ASC
|
|
LIMIT 1",
|
|
$listing_key
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Get distinct cities
|
|
*
|
|
* @param string|null $status Optional status filter
|
|
* @return array City names
|
|
*/
|
|
public function get_distinct_cities($status = null) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
|
|
if ($status) {
|
|
$cities = $wpdb->get_col($wpdb->prepare(
|
|
"SELECT DISTINCT city FROM {$table}
|
|
WHERE mlg_can_view = 1 AND standard_status = %s AND city IS NOT NULL
|
|
ORDER BY city ASC",
|
|
$status
|
|
));
|
|
} else {
|
|
$cities = $wpdb->get_col(
|
|
"SELECT DISTINCT city FROM {$table}
|
|
WHERE mlg_can_view = 1 AND city IS NOT NULL
|
|
ORDER BY city ASC"
|
|
);
|
|
}
|
|
|
|
return $cities;
|
|
}
|
|
|
|
/**
|
|
* Get distinct counties
|
|
*
|
|
* @param string|null $status Optional status filter
|
|
* @return array County names
|
|
*/
|
|
public function get_distinct_counties($status = null) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
|
|
if ($status) {
|
|
$counties = $wpdb->get_col($wpdb->prepare(
|
|
"SELECT DISTINCT county FROM {$table}
|
|
WHERE mlg_can_view = 1 AND standard_status = %s AND county IS NOT NULL
|
|
ORDER BY county ASC",
|
|
$status
|
|
));
|
|
} else {
|
|
$counties = $wpdb->get_col(
|
|
"SELECT DISTINCT county FROM {$table}
|
|
WHERE mlg_can_view = 1 AND county IS NOT NULL
|
|
ORDER BY county ASC"
|
|
);
|
|
}
|
|
|
|
return $counties;
|
|
}
|
|
|
|
/**
|
|
* Get property count
|
|
*
|
|
* @param array $args Filter arguments (same as get_properties)
|
|
* @return int Count
|
|
*/
|
|
public function get_count($args = array()) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
|
|
$where = array('mlg_can_view = 1');
|
|
$values = array();
|
|
|
|
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['county'])) {
|
|
$where[] = 'county = %s';
|
|
$values[] = $args['county'];
|
|
}
|
|
|
|
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'];
|
|
}
|
|
|
|
if (!empty($args['min_baths'])) {
|
|
$where[] = 'bathrooms_total >= %d';
|
|
$values[] = (int) $args['min_baths'];
|
|
}
|
|
|
|
// Map bounds filtering
|
|
if (!empty($args['bounds']) && is_array($args['bounds']) && count($args['bounds']) === 4) {
|
|
list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds'];
|
|
$where[] = 'latitude BETWEEN %f AND %f';
|
|
$where[] = 'longitude BETWEEN %f AND %f';
|
|
$where[] = 'latitude IS NOT NULL';
|
|
$where[] = 'longitude IS NOT NULL';
|
|
$values[] = (float) $sw_lat;
|
|
$values[] = (float) $ne_lat;
|
|
$values[] = (float) $sw_lng;
|
|
$values[] = (float) $ne_lng;
|
|
}
|
|
|
|
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
|
|
|
|
if (!empty($values)) {
|
|
return (int) $wpdb->get_var($wpdb->prepare($sql, $values));
|
|
}
|
|
|
|
return (int) $wpdb->get_var($sql);
|
|
}
|
|
|
|
/**
|
|
* Check if data exists
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function has_data() {
|
|
global $wpdb;
|
|
|
|
$count = $wpdb->get_var(
|
|
"SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE mlg_can_view = 1"
|
|
);
|
|
|
|
return (int) $count > 0;
|
|
}
|
|
|
|
/**
|
|
* Get property types with counts
|
|
*
|
|
* @param string|null $status Optional status filter
|
|
* @return array Property types with counts
|
|
*/
|
|
public function get_property_types($status = null) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
|
|
if ($status) {
|
|
return $wpdb->get_results($wpdb->prepare(
|
|
"SELECT property_type, COUNT(*) as count
|
|
FROM {$table}
|
|
WHERE mlg_can_view = 1 AND standard_status = %s AND property_type IS NOT NULL
|
|
GROUP BY property_type
|
|
ORDER BY count DESC",
|
|
$status
|
|
));
|
|
}
|
|
|
|
return $wpdb->get_results(
|
|
"SELECT property_type, COUNT(*) as count
|
|
FROM {$table}
|
|
WHERE mlg_can_view = 1 AND property_type IS NOT NULL
|
|
GROUP BY property_type
|
|
ORDER BY count DESC"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get price range
|
|
*
|
|
* @param string|null $status Optional status filter
|
|
* @return object Min and max prices
|
|
*/
|
|
public function get_price_range($status = null) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
|
|
if ($status) {
|
|
return $wpdb->get_row($wpdb->prepare(
|
|
"SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
|
|
FROM {$table}
|
|
WHERE mlg_can_view = 1 AND standard_status = %s AND list_price > 0",
|
|
$status
|
|
));
|
|
}
|
|
|
|
return $wpdb->get_row(
|
|
"SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
|
|
FROM {$table}
|
|
WHERE mlg_can_view = 1 AND list_price > 0"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get formatted address for a property
|
|
*
|
|
* @param object $property Property object
|
|
* @return string Formatted address
|
|
*/
|
|
public function format_address($property) {
|
|
$parts = array();
|
|
|
|
if ($property->street_number) {
|
|
$parts[] = $property->street_number;
|
|
}
|
|
|
|
if ($property->street_name) {
|
|
$parts[] = $property->street_name;
|
|
}
|
|
|
|
if ($property->street_suffix) {
|
|
$parts[] = $property->street_suffix;
|
|
}
|
|
|
|
if ($property->unit_number) {
|
|
$parts[] = '#' . $property->unit_number;
|
|
}
|
|
|
|
$street = implode(' ', $parts);
|
|
|
|
$location_parts = array();
|
|
if ($property->city) {
|
|
$location_parts[] = $property->city;
|
|
}
|
|
if ($property->state_or_province) {
|
|
$location_parts[] = $property->state_or_province;
|
|
}
|
|
if ($property->postal_code) {
|
|
$location_parts[] = $property->postal_code;
|
|
}
|
|
|
|
$location = implode(', ', $location_parts);
|
|
|
|
if ($street && $location) {
|
|
return $street . ', ' . $location;
|
|
}
|
|
|
|
return $street ?: $location;
|
|
}
|
|
}
|