Integrate MLS listings with property map and add smart sync

Property Map:
- Replace ACF-based property display with MLS database queries
- Use real lat/lng coordinates from MLS (100% coverage)
- Create property-card-mls.php template for MLS property cards
- Update AJAX handler to filter MLS properties

MLS Plugin Enhancements:
- Add 'wp mls run' smart sync command (auto-detects full/incremental/resume)
- Add database index migrations for lat/lng and composite search indexes
- Add comprehensive README.md documentation

Documentation:
- Update site README.md with sysadmin quick reference
- Add FEATURES_PENDING_12_15.md tracking client feature requests

🤖 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-15 22:32:41 -06:00
parent b9cddd2f64
commit fc018ca604
13 changed files with 2346 additions and 308 deletions
+41 -42
View File
@@ -106,54 +106,53 @@ $view_class = $show_map ? 'is-map-view' : 'is-grid-view';
</main>
<?php
// Always load map data for responsive switching
$map_properties = new WP_Query(array(
'post_type' => 'property',
'posts_per_page' => -1,
));
// Load MLS properties for map markers
$markers = array();
$city_coords = array(
'Albert Lea' => array(43.6480, -93.3685),
'Austin' => array(43.6666, -92.9746),
'Glenville' => array(43.5733, -93.2779),
'Emmons' => array(43.5013, -93.4896),
'Clarks Grove' => array(43.7627, -93.3196),
'Alden' => array(43.6719, -93.5768),
'Hartland' => array(43.8030, -93.4846),
'Geneva' => array(43.8255, -93.2682),
'Owatonna' => array(44.0838, -93.2260),
'Faribault' => array(44.2949, -93.2688),
'Rochester' => array(44.0234, -92.4699),
'Mankato' => array(44.1636, -93.9994),
);
if ($map_properties->have_posts()) :
while ($map_properties->have_posts()) :
$map_properties->the_post();
$city = get_field('city');
$price = get_field('property_price');
$address = get_field('street_address');
if (function_exists('mls_get_properties')) {
$mls_properties = mls_get_properties(array(
'status' => 'Active',
'limit' => 1000, // Reasonable limit for map performance
'orderby' => 'modification_timestamp',
'order' => 'DESC',
));
// Get coords for city, default to Albert Lea
$coords = isset($city_coords[$city]) ? $city_coords[$city] : $city_coords['Albert Lea'];
// Add small random offset (seeded by post ID for consistency)
srand(get_the_ID());
$lat = $coords[0] + (rand(-50, 50) / 10000);
$lng = $coords[1] + (rand(-50, 50) / 10000);
foreach ($mls_properties as $property) {
// Skip properties without coordinates
if (empty($property->latitude) || empty($property->longitude)) {
continue;
}
// Format address
$address_parts = array();
if ($property->street_number) {
$address_parts[] = $property->street_number;
}
if ($property->street_name) {
$address_parts[] = $property->street_name;
}
if ($property->street_suffix) {
$address_parts[] = $property->street_suffix;
}
$street = implode(' ', $address_parts);
$full_address = $street ? $street . ', ' . $property->city : $property->city;
$markers[] = array(
'id' => get_the_ID(),
'lat' => $lat,
'lng' => $lng,
'title' => get_the_title(),
'price' => '$' . number_format($price),
'address' => $address . ', ' . $city,
'url' => get_permalink(),
'id' => $property->listing_key,
'lat' => (float) $property->latitude,
'lng' => (float) $property->longitude,
'title' => $full_address,
'price' => '$' . number_format($property->list_price),
'address' => $full_address,
'url' => home_url('/properties/?listing=' . urlencode($property->listing_key)),
'beds' => $property->bedrooms_total,
'baths' => $property->bathrooms_total,
'sqft' => $property->living_area,
'status' => $property->standard_status,
'photo' => null, // Placeholder - photos will be added later
);
endwhile;
wp_reset_postdata();
endif;
}
}
?>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
+58 -116
View File
@@ -11,7 +11,7 @@ if (!defined('ABSPATH')) {
}
/**
* Handle AJAX property filter requests
* Handle AJAX property filter requests (MLS-based)
*/
function homeproz_ajax_filter_properties() {
// Verify nonce
@@ -19,6 +19,11 @@ function homeproz_ajax_filter_properties() {
wp_send_json_error('Invalid nonce');
}
// Check if MLS plugin is available
if (!function_exists('mls_get_properties')) {
wp_send_json_error('MLS plugin not available');
}
// Get filter values
$property_type = isset($_POST['property_type']) ? sanitize_text_field($_POST['property_type']) : '';
$property_status = isset($_POST['property_status']) ? sanitize_text_field($_POST['property_status']) : '';
@@ -29,100 +34,40 @@ function homeproz_ajax_filter_properties() {
$sort = isset($_POST['sort']) ? sanitize_text_field($_POST['sort']) : 'newest';
$paged = isset($_POST['paged']) ? intval($_POST['paged']) : 1;
// Build query args
$args = array(
'post_type' => 'property',
'posts_per_page' => 12,
'paged' => $paged,
// Build MLS query args
$per_page = 12;
$mls_args = array(
'status' => $property_status ?: 'Active',
'limit' => 1000, // Get all for counting, then paginate
'orderby' => 'modification_timestamp',
'order' => 'DESC',
);
// Taxonomy filters
$tax_query = array();
// Map filter values to MLS query args
if ($property_type) {
$tax_query[] = array(
'taxonomy' => 'property_type',
'field' => 'slug',
'terms' => $property_type,
);
$mls_args['property_type'] = $property_type;
}
if ($property_status) {
$tax_query[] = array(
'taxonomy' => 'property_status',
'field' => 'slug',
'terms' => $property_status,
);
}
if ($property_location) {
$tax_query[] = array(
'taxonomy' => 'property_location',
'field' => 'slug',
'terms' => $property_location,
);
$mls_args['city'] = $property_location;
}
if (!empty($tax_query)) {
$args['tax_query'] = $tax_query;
if (count($tax_query) > 1) {
$args['tax_query']['relation'] = 'AND';
}
}
// Meta query for price and bedrooms
$meta_query = array();
if ($min_price) {
$meta_query[] = array(
'key' => 'property_price',
'value' => $min_price,
'type' => 'NUMERIC',
'compare' => '>=',
);
$mls_args['min_price'] = $min_price;
}
if ($max_price) {
$meta_query[] = array(
'key' => 'property_price',
'value' => $max_price,
'type' => 'NUMERIC',
'compare' => '<=',
);
$mls_args['max_price'] = $max_price;
}
if ($beds) {
$meta_query[] = array(
'key' => 'bedrooms',
'value' => $beds,
'type' => 'NUMERIC',
'compare' => '>=',
);
$mls_args['min_beds'] = $beds;
}
if (!empty($meta_query)) {
$args['meta_query'] = $meta_query;
if (count($meta_query) > 1) {
$args['meta_query']['relation'] = 'AND';
}
}
// Fetch all matching properties for status-based sorting
$args['posts_per_page'] = -1;
$args['orderby'] = 'modified';
$args['order'] = 'DESC';
$all_properties = get_posts($args);
// Sort by status (Active > Pending > Sold) then by modified date
$sorted_properties = homeproz_sort_properties_by_status($all_properties);
// Fetch all matching properties
$all_properties = mls_get_properties($mls_args);
// Handle pagination manually
$per_page = 12;
$total = count($sorted_properties);
$total = count($all_properties);
$max_pages = ceil($total / $per_page);
$offset = ($paged - 1) * $per_page;
$paged_properties = array_slice($sorted_properties, $offset, $per_page);
$paged_properties = array_slice($all_properties, $offset, $per_page);
ob_start();
@@ -142,13 +87,11 @@ function homeproz_ajax_filter_properties() {
<?php if (!empty($paged_properties)) : ?>
<div class="properties-grid">
<?php
global $post;
foreach ($paged_properties as $property_post) :
$post = $property_post;
setup_postdata($post);
get_template_part('template-parts/property/property-card');
foreach ($paged_properties as $property) :
// Pass MLS property to card template
set_query_var('mls_property', $property);
get_template_part('template-parts/property/property-card-mls');
endforeach;
wp_reset_postdata();
?>
</div>
@@ -197,43 +140,42 @@ function homeproz_ajax_filter_properties() {
<?php
$html = ob_get_clean();
// Build markers data for map view
// Build markers data for map view from MLS properties
$markers = array();
$city_coords = array(
'Albert Lea' => array(43.6480, -93.3685),
'Austin' => array(43.6666, -92.9746),
'Glenville' => array(43.5733, -93.2779),
'Emmons' => array(43.5013, -93.4896),
'Clarks Grove' => array(43.7627, -93.3196),
'Alden' => array(43.6719, -93.5768),
'Hartland' => array(43.8030, -93.4846),
'Geneva' => array(43.8255, -93.2682),
'Owatonna' => array(44.0838, -93.2260),
'Faribault' => array(44.2949, -93.2688),
'Rochester' => array(44.0234, -92.4699),
'Mankato' => array(44.1636, -93.9994),
);
foreach ($sorted_properties as $prop) {
$city = get_field('city', $prop->ID);
$price = get_field('property_price', $prop->ID);
$address = get_field('street_address', $prop->ID);
foreach ($all_properties as $property) {
// Skip properties without coordinates
if (empty($property->latitude) || empty($property->longitude)) {
continue;
}
// Get coords for city, default to Albert Lea
$coords = isset($city_coords[$city]) ? $city_coords[$city] : $city_coords['Albert Lea'];
// Add small random offset to prevent overlapping markers (seeded by post ID for consistency)
srand($prop->ID);
$lat = $coords[0] + (rand(-50, 50) / 10000);
$lng = $coords[1] + (rand(-50, 50) / 10000);
// Format address
$address_parts = array();
if ($property->street_number) {
$address_parts[] = $property->street_number;
}
if ($property->street_name) {
$address_parts[] = $property->street_name;
}
if ($property->street_suffix) {
$address_parts[] = $property->street_suffix;
}
$street = implode(' ', $address_parts);
$full_address = $street ? $street . ', ' . $property->city : $property->city;
$markers[] = array(
'id' => $prop->ID,
'lat' => $lat,
'lng' => $lng,
'title' => $prop->post_title,
'price' => '$' . number_format($price),
'address' => $address . ', ' . $city,
'url' => get_permalink($prop->ID),
'id' => $property->listing_key,
'lat' => (float) $property->latitude,
'lng' => (float) $property->longitude,
'title' => $full_address,
'price' => '$' . number_format($property->list_price),
'address' => $full_address,
'url' => home_url('/properties/?listing=' . urlencode($property->listing_key)),
'beds' => $property->bedrooms_total,
'baths' => $property->bathrooms_total,
'sqft' => $property->living_area,
'status' => $property->standard_status,
'photo' => null, // Placeholder - photos will be added later
);
}
@@ -0,0 +1,136 @@
<?php
/**
* MLS Property Card Template Part
*
* Displays an MLS property in card format for archive views
*
* @package HomeProz
*/
// Prevent direct access
if (!defined('ABSPATH')) {
exit;
}
// Get MLS property data from query var
$property = get_query_var('mls_property');
if (!$property) {
return;
}
// Extract property data
$listing_key = $property->listing_key;
$price = $property->list_price;
$bedrooms = $property->bedrooms_total;
$bathrooms = $property->bathrooms_total;
$square_feet = $property->living_area;
$status = $property->standard_status;
$public_remarks = $property->public_remarks;
// Format address
$address_parts = array();
if ($property->street_number) {
$address_parts[] = $property->street_number;
}
if ($property->street_name) {
$address_parts[] = $property->street_name;
}
if ($property->street_suffix) {
$address_parts[] = $property->street_suffix;
}
$street = implode(' ', $address_parts);
$full_address = $street;
if ($property->city) {
$full_address .= ', ' . $property->city;
}
if ($property->state_or_province) {
$full_address .= ', ' . $property->state_or_province;
}
// Property URL (will be updated when single property view is implemented)
$property_url = home_url('/properties/?listing=' . urlencode($listing_key));
// Status class mapping
$status_class = 'badge-active';
if ($status === 'Pending') {
$status_class = 'badge-pending';
} elseif ($status === 'Closed' || $status === 'Sold') {
$status_class = 'badge-sold';
}
?>
<article id="property-<?php echo esc_attr($listing_key); ?>" data-property-id="<?php echo esc_attr($listing_key); ?>" class="property-card card mls-property">
<a href="<?php echo esc_url($property_url); ?>" class="property-card-link-overlay" aria-hidden="true" tabindex="-1"></a>
<div class="property-card-image">
<!-- Photo placeholder - will be implemented later -->
<div class="property-card-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</div>
<?php if ($status) : ?>
<span class="property-card-badge badge <?php echo esc_attr($status_class); ?>">
<?php echo esc_html($status); ?>
</span>
<?php endif; ?>
</div>
<div class="property-card-content">
<div class="property-card-price">
<?php echo esc_html('$' . number_format($price)); ?>
</div>
<h3 class="property-card-title">
<?php echo esc_html($full_address ?: 'Property ' . $listing_key); ?>
</h3>
<?php if ($bedrooms || $bathrooms || $square_feet) : ?>
<ul class="property-card-specs">
<?php if ($bedrooms) : ?>
<li class="spec-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M3 7v11h18V7M3 7V4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v3M3 7h18M7 11h4v4H7zM14 11h3"/>
</svg>
<span><?php echo esc_html($bedrooms); ?> <?php echo $bedrooms == 1 ? 'Bed' : 'Beds'; ?></span>
</li>
<?php endif; ?>
<?php if ($bathrooms) : ?>
<li class="spec-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M4 12h16M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7M4 12V6a2 2 0 0 1 2-2h3v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V4"/>
</svg>
<span><?php echo esc_html($bathrooms); ?> <?php echo $bathrooms == 1 ? 'Bath' : 'Baths'; ?></span>
</li>
<?php endif; ?>
<?php if ($square_feet) : ?>
<li class="spec-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18M9 3v18"/>
</svg>
<span><?php echo esc_html(number_format($square_feet)); ?> sqft</span>
</li>
<?php endif; ?>
</ul>
<?php endif; ?>
<?php if ($public_remarks) : ?>
<p class="property-card-excerpt">
<?php echo esc_html(wp_trim_words($public_remarks, 15, '...')); ?>
</p>
<?php endif; ?>
<span class="property-card-link">
View Details
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</span>
</div>
</article>
@@ -2,7 +2,7 @@
/**
* Property Results Template Part
*
* Displays property results for archive/search
* Displays MLS property results for archive/search
*
* @package HomeProz
*/
@@ -12,140 +12,70 @@ if (!defined('ABSPATH')) {
exit;
}
// Get filter values
// Check if MLS plugin is available
if (!function_exists('mls_get_properties')) {
?>
<div class="no-properties">
<h3>Properties Unavailable</h3>
<p>Property listings are temporarily unavailable. Please try again later.</p>
</div>
<?php
return;
}
// Get filter values from URL
$current_type = isset($_GET['property_type']) ? sanitize_text_field($_GET['property_type']) : '';
$current_status = isset($_GET['property_status']) ? sanitize_text_field($_GET['property_status']) : '';
$current_status = isset($_GET['property_status']) ? sanitize_text_field($_GET['property_status']) : 'Active';
$current_location = isset($_GET['property_location']) ? sanitize_text_field($_GET['property_location']) : '';
$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']) : '';
$current_sort = isset($_GET['sort']) ? sanitize_text_field($_GET['sort']) : 'newest';
// Build query args
// Pagination
$paged = get_query_var('paged') ? get_query_var('paged') : 1;
$args = array(
'post_type' => 'property',
'posts_per_page' => 12,
'paged' => $paged,
$per_page = 12;
// Build MLS query args
$mls_args = array(
'status' => $current_status ?: 'Active',
'limit' => 1000, // Get all for counting, then paginate
'orderby' => 'modification_timestamp',
'order' => 'DESC',
);
// Taxonomy filters
$tax_query = array();
// Map filter values to MLS query args
if ($current_type) {
$tax_query[] = array(
'taxonomy' => 'property_type',
'field' => 'slug',
'terms' => $current_type,
);
$mls_args['property_type'] = $current_type;
}
if ($current_status) {
$tax_query[] = array(
'taxonomy' => 'property_status',
'field' => 'slug',
'terms' => $current_status,
);
}
if ($current_location) {
$tax_query[] = array(
'taxonomy' => 'property_location',
'field' => 'slug',
'terms' => $current_location,
);
$mls_args['city'] = $current_location;
}
if (!empty($tax_query)) {
$args['tax_query'] = $tax_query;
if (count($tax_query) > 1) {
$args['tax_query']['relation'] = 'AND';
}
}
// Meta query for price and bedrooms
$meta_query = array();
if ($current_min_price) {
$meta_query[] = array(
'key' => 'property_price',
'value' => $current_min_price,
'type' => 'NUMERIC',
'compare' => '>=',
);
$mls_args['min_price'] = $current_min_price;
}
if ($current_max_price) {
$meta_query[] = array(
'key' => 'property_price',
'value' => $current_max_price,
'type' => 'NUMERIC',
'compare' => '<=',
);
$mls_args['max_price'] = $current_max_price;
}
if ($current_beds) {
$meta_query[] = array(
'key' => 'bedrooms',
'value' => $current_beds,
'type' => 'NUMERIC',
'compare' => '>=',
);
$mls_args['min_beds'] = $current_beds;
}
if (!empty($meta_query)) {
$args['meta_query'] = $meta_query;
if (count($meta_query) > 1) {
$args['meta_query']['relation'] = 'AND';
}
}
// For status-based sorting, we need to fetch all matching properties and sort in PHP
// This is efficient for real estate sites with < 1000 properties
$args['posts_per_page'] = -1;
$args['orderby'] = 'modified';
$args['order'] = 'DESC';
$all_properties = get_posts($args);
// Sort by status (Active > Pending > Sold) then by modified date
$sorted_properties = homeproz_sort_properties_by_status($all_properties);
// Fetch all matching properties
$all_properties = mls_get_properties($mls_args);
// Handle pagination manually
$per_page = 12;
$total = count($sorted_properties);
$total = count($all_properties);
$max_pages = ceil($total / $per_page);
$offset = ($paged - 1) * $per_page;
$paged_properties = array_slice($sorted_properties, $offset, $per_page);
// Create a fake WP_Query-like object for compatibility
$properties = (object) array(
'posts' => $paged_properties,
'found_posts' => $total,
'max_num_pages' => $max_pages,
);
// Helper function to check if we have posts
$properties->have_posts = function() use (&$paged_properties) {
static $index = 0;
if ($index < count($paged_properties)) {
return true;
}
$index = 0;
return false;
};
// Loop through properties manually
global $post;
$property_index = 0;
$paged_properties = array_slice($all_properties, $offset, $per_page);
?>
<!-- Results Meta -->
<div class="properties-meta">
<p class="properties-count">
<?php if ($properties->found_posts > 0) : ?>
Showing <strong><?php echo esc_html($properties->found_posts); ?></strong>
<?php echo $properties->found_posts === 1 ? 'property' : 'properties'; ?>
<?php if ($total > 0) : ?>
Showing <strong><?php echo esc_html($total); ?></strong>
<?php echo $total === 1 ? 'property' : 'properties'; ?>
<?php else : ?>
No properties found
<?php endif; ?>
@@ -155,12 +85,11 @@ $property_index = 0;
<?php if (!empty($paged_properties)) : ?>
<div class="properties-grid">
<?php
foreach ($paged_properties as $property_post) :
$post = $property_post;
setup_postdata($post);
get_template_part('template-parts/property/property-card');
foreach ($paged_properties as $property) :
// Pass MLS property to card template
set_query_var('mls_property', $property);
get_template_part('template-parts/property/property-card-mls');
endforeach;
wp_reset_postdata();
?>
</div>
@@ -195,5 +124,3 @@ $property_index = 0;
<a href="<?php echo esc_url(get_post_type_archive_link('property')); ?>" class="btn btn-primary">View All Properties</a>
</div>
<?php endif; ?>
<?php wp_reset_postdata(); ?>