Add single MLS property view and image security improvements

- Add single-property-mls.php template with full gallery support
- Route /properties/?listing=XXX to single property view
- Add HMAC-signed URLs for image endpoint (bot protection)
- Add MySQL advisory lock for image downloads (prevent stampede)
- Add infinite scroll module for property list (desktop map view)
- Load card images immediately on DOM ready (no scroll detection)
- Add cards_only AJAX parameter for infinite scroll

🤖 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-16 10:43:04 -06:00
parent acd606bb03
commit 15449b9131
10 changed files with 1459 additions and 88 deletions
@@ -5,10 +5,11 @@
* Serves WebP thumbnails for MLS property images with on-demand conversion.
* Uses ImageMagick to convert and resize images.
*
* URL format: /mls-image/{listing_key}/{index}/{size}/
* URL format: /mls-image/{listing_key}/{index}/{size}/?sig={signature}
* - listing_key: MLS listing key
* - index: Image index (0-based)
* - size: 'thumb' (800px) or 'full' (1800px)
* - sig: HMAC signature to prevent unauthorized access
*/
if (!defined('ABSPATH')) {
@@ -33,6 +34,11 @@ class MLS_Image_Endpoint {
*/
const CACHE_SUBDIR = 'mls-thumbnails';
/**
* Signature length (truncated HMAC for shorter URLs)
*/
const SIG_LENGTH = 16;
/**
* Media handler instance
*/
@@ -93,6 +99,7 @@ class MLS_Image_Endpoint {
$listing_key = sanitize_text_field(get_query_var('mls_listing_key'));
$index = absint(get_query_var('mls_image_index'));
$size = sanitize_text_field(get_query_var('mls_image_size'));
$signature = isset($_GET['sig']) ? sanitize_text_field($_GET['sig']) : '';
if (empty($listing_key) || !in_array($size, array('thumb', 'full'), true)) {
$this->logger->error('MLS Image: Invalid params', array(
@@ -104,6 +111,17 @@ class MLS_Image_Endpoint {
return;
}
// Verify signature
if (!self::verify_signature($listing_key, $signature)) {
$this->logger->warning('MLS Image: Invalid signature', array(
'listing_key' => $listing_key,
'signature' => $signature,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
));
$this->send_403();
return;
}
$max_dimension = ($size === 'thumb') ? self::SIZE_THUMB : self::SIZE_FULL;
// Try to serve from cache first
@@ -394,6 +412,15 @@ class MLS_Image_Endpoint {
exit;
}
/**
* Send 403 Forbidden response
*/
private function send_403() {
status_header(403);
nocache_headers();
exit;
}
/**
* Get URL for an MLS image
*
@@ -403,7 +430,50 @@ class MLS_Image_Endpoint {
* @return string Image URL
*/
public static function get_url($listing_key, $index = 1, $size = 'thumb') {
return home_url("/mls-image/{$listing_key}/{$index}/{$size}/");
$sig = self::generate_signature($listing_key);
return home_url("/mls-image/{$listing_key}/{$index}/{$size}/") . '?sig=' . $sig;
}
/**
* Generate HMAC signature for a listing key
*
* @param string $listing_key Listing key
* @return string Truncated HMAC signature
*/
public static function generate_signature($listing_key) {
$secret = self::get_secret_key();
$hash = hash_hmac('sha256', $listing_key, $secret);
return substr($hash, 0, self::SIG_LENGTH);
}
/**
* Verify HMAC signature for a listing key
*
* @param string $listing_key Listing key
* @param string $signature Signature to verify
* @return bool True if valid
*/
public static function verify_signature($listing_key, $signature) {
if (empty($signature)) {
return false;
}
$expected = self::generate_signature($listing_key);
return hash_equals($expected, $signature);
}
/**
* Get the secret key for signing
*
* Uses MLS_IMAGE_SECRET if defined, otherwise falls back to AUTH_KEY
*
* @return string Secret key
*/
private static function get_secret_key() {
if (defined('MLS_IMAGE_SECRET') && MLS_IMAGE_SECRET) {
return MLS_IMAGE_SECRET;
}
// Fallback to WordPress AUTH_KEY (always defined)
return AUTH_KEY;
}
/**
@@ -316,6 +316,9 @@ class MLS_Media_Handler {
/**
* Fetch image from MLS Grid and cache locally
*
* Uses MySQL advisory lock to ensure only one request downloads
* a specific image at a time (prevents stampede on cold cache).
*
* @param object $media Media record
* @return string|null Local URL on success, null on failure
*/
@@ -326,6 +329,41 @@ class MLS_Media_Handler {
return null;
}
// Advisory lock key - unique per media record
$lock_name = 'mls_media_' . $media->id;
$lock_timeout = 35; // Slightly longer than HTTP timeout
// Try to acquire lock (will wait up to $lock_timeout seconds)
$lock_acquired = $wpdb->get_var($wpdb->prepare(
"SELECT GET_LOCK(%s, %d)",
$lock_name,
$lock_timeout
));
if ($lock_acquired !== '1') {
$this->logger->warning('Could not acquire media lock', array(
'listing_key' => $media->listing_key,
'media_key' => $media->media_key,
'lock_result' => $lock_acquired,
));
return null;
}
try {
// Re-check if image was cached while we waited for lock
$updated_media = $wpdb->get_row($wpdb->prepare(
"SELECT local_path, local_url FROM {$this->db->media_table()} WHERE id = %d",
$media->id
));
if ($updated_media && $updated_media->local_path) {
$file_path = $this->get_upload_dir() . '/' . $updated_media->local_path;
if (file_exists($file_path)) {
// Another request cached it while we waited
return $updated_media->local_url;
}
}
// Download the image
$response = wp_remote_get($media->media_url, array(
'timeout' => 30,
@@ -401,6 +439,11 @@ class MLS_Media_Handler {
));
return $local_url;
} finally {
// Always release the lock
$wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lock_name));
}
}
/**
@@ -12,6 +12,14 @@ if (!defined('ABSPATH')) {
exit;
}
// Check if viewing a single MLS property
if (isset($_GET['listing']) && !empty($_GET['listing'])) {
$listing_key = sanitize_text_field($_GET['listing']);
set_query_var('mls_listing_key', $listing_key);
get_template_part('template-parts/property/single-property-mls');
exit;
}
get_header();
// Map view is default on desktop, grid view requires ?view=grid
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -33,6 +33,7 @@ function homeproz_ajax_filter_properties() {
$beds = isset($_POST['beds']) ? intval($_POST['beds']) : '';
$sort = isset($_POST['sort']) ? sanitize_text_field($_POST['sort']) : 'newest';
$paged = isset($_POST['paged']) ? intval($_POST['paged']) : 1;
$cards_only = isset($_POST['cards_only']) && $_POST['cards_only'] === 'true';
// Map bounds and center (for map-synced list view)
$bounds = null;
@@ -93,6 +94,26 @@ function homeproz_ajax_filter_properties() {
// Fetch only the properties we need for this page
$paged_properties = mls_get_properties($mls_args);
// Cards-only mode for infinite scroll - return just the cards HTML
if ($cards_only) {
ob_start();
if (!empty($paged_properties)) {
foreach ($paged_properties as $property) :
set_query_var('mls_property', $property);
get_template_part('template-parts/property/property-card-mls');
endforeach;
}
$html = ob_get_clean();
wp_send_json_success(array(
'html' => $html,
'page' => $paged,
'max_pages' => $max_pages,
'found_posts' => $total,
));
return;
}
ob_start();
// Results Meta
@@ -616,6 +616,12 @@
this.mapBounds = bounds;
this.mapCenter = center;
this.isMapUpdate = true;
// Reset infinite scroll state before loading new content
if (InfiniteScrollState.isEnabled) {
InfiniteScroll.reset();
}
// Reset to page 1 when map viewport changes
this.filterProperties(1, false);
},
@@ -692,6 +698,15 @@
LayoutCalculator.calculate();
}
// Reinitialize infinite scroll (desktop map view only)
if (window.innerWidth >= 1024 && $('.is-map-view').length) {
// Destroy existing, then reinit with new content
InfiniteScroll.destroy();
setTimeout(function() {
InfiniteScroll.init();
}, 100);
}
// Update URL (skip when map-triggered)
if (updateHistory) {
self.updateUrl(formData, page);
@@ -833,6 +848,14 @@
this.isAboveBreakpoint = window.innerWidth >= this.breakpoint;
var $main = $('.property-archive-main');
// Crossing from above to below breakpoint
if (wasAbove && !this.isAboveBreakpoint) {
// Destroy infinite scroll when going to mobile
if (InfiniteScrollState.isEnabled) {
InfiniteScroll.destroy();
}
}
// Crossing from below to above breakpoint
if (!wasAbove && this.isAboveBreakpoint) {
// Restore the user's view preference
@@ -849,6 +872,11 @@
PropertyMap.map.invalidateSize();
}, 100);
}
// Initialize infinite scroll
setTimeout(function() {
InfiniteScroll.init();
}, 200);
} else {
$main.removeClass('is-map-view').addClass('is-grid-view');
}
@@ -860,7 +888,6 @@
}, 150);
}
}
// No need to do anything when crossing below - CSS handles hiding map
},
setMapView: function(isMap) {
@@ -981,6 +1008,529 @@
}
};
/**
* Infinite Scroll State
* Tracks loaded pages, pending requests, and configuration
*/
var InfiniteScrollState = {
pages: {}, // Object: pageNum -> {html, height, loaded}
visibleRange: { first: 1, last: 1 },
totalPages: 0,
totalPosts: 0,
pendingRequests: {}, // Object: pageNum -> jqXHR
isEnabled: false,
config: {
maxPagesInDOM: 5,
bufferPages: 2,
cardsPerPage: 12,
estimatedCardHeight: 420
}
};
/**
* Infinite Scroll Module
* Bidirectional infinite scroll for property list (desktop map view only)
*/
var InfiniteScroll = {
$container: null,
$grid: null,
$topSentinel: null,
$bottomSentinel: null,
$topLoader: null,
$bottomLoader: null,
topObserver: null,
bottomObserver: null,
scrollContainer: null,
/**
* Initialize infinite scroll
*/
init: function() {
// Only enable on desktop (>= 1024px) in map view
if (window.innerWidth < 1024 || !$('.is-map-view').length) {
return;
}
this.$container = $('.property-list-container');
this.$grid = this.$container.find('.properties-grid');
if (!this.$container.length || !this.$grid.length) {
return;
}
// Get initial page info from meta or default
var $meta = this.$container.find('.properties-meta');
var countText = $meta.find('.properties-count strong').text();
InfiniteScrollState.totalPosts = parseInt(countText) || 0;
InfiniteScrollState.totalPages = Math.ceil(InfiniteScrollState.totalPosts / InfiniteScrollState.config.cardsPerPage);
// Don't enable if only 1 page
if (InfiniteScrollState.totalPages <= 1) {
return;
}
// Reset state for fresh init
this.resetState();
// Setup DOM structure
this.setupDOM();
// Wrap existing cards as page 1
this.wrapInitialCards();
// Setup IntersectionObservers
this.setupObservers();
// Mark as enabled
InfiniteScrollState.isEnabled = true;
this.$container.addClass('infinite-scroll-enabled');
},
/**
* Reset internal state
*/
resetState: function() {
InfiniteScrollState.pages = {};
InfiniteScrollState.visibleRange = { first: 1, last: 1 };
InfiniteScrollState.pendingRequests = {};
},
/**
* Setup DOM elements (sentinels and loaders)
*/
setupDOM: function() {
// Remove any existing infinite scroll elements
this.$container.find('.infinite-scroll-sentinel, .infinite-scroll-loader').remove();
// Create top sentinel and loader (before grid)
this.$topSentinel = $('<div class="infinite-scroll-sentinel" data-direction="top"></div>');
this.$topLoader = $('<div class="infinite-scroll-loader infinite-scroll-loader--top"><div class="spinner"></div></div>');
// Create bottom sentinel and loader (after grid)
this.$bottomSentinel = $('<div class="infinite-scroll-sentinel" data-direction="bottom"></div>');
this.$bottomLoader = $('<div class="infinite-scroll-loader infinite-scroll-loader--bottom"><div class="spinner"></div></div>');
// Insert elements
this.$grid.before(this.$topSentinel).before(this.$topLoader);
this.$grid.after(this.$bottomLoader).after(this.$bottomSentinel);
// Set scroll container reference
this.scrollContainer = this.$container[0];
},
/**
* Wrap initial server-rendered cards as page 1
*/
wrapInitialCards: function() {
var $cards = this.$grid.find('.property-card');
if (!$cards.length) return;
// Wrap all cards in page container
var $pageContainer = $('<div class="infinite-scroll-page" data-page="1"></div>');
$cards.wrapAll($pageContainer);
// Store page 1 info
var pageHeight = this.$grid.find('.infinite-scroll-page[data-page="1"]').outerHeight();
InfiniteScrollState.pages[1] = {
html: null, // Already in DOM
height: pageHeight,
loaded: true
};
InfiniteScrollState.visibleRange = { first: 1, last: 1 };
},
/**
* Setup IntersectionObservers for top and bottom sentinels
*/
setupObservers: function() {
var self = this;
var observerOptions = {
root: this.scrollContainer,
rootMargin: '400px 0px',
threshold: 0
};
// Bottom observer - load next page
this.bottomObserver = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
self.loadNextPage();
}
});
}, observerOptions);
// Top observer - load previous page
this.topObserver = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
self.loadPrevPage();
}
});
}, observerOptions);
// Start observing
this.bottomObserver.observe(this.$bottomSentinel[0]);
this.topObserver.observe(this.$topSentinel[0]);
},
/**
* Load the next page (append)
*/
loadNextPage: function() {
var nextPage = InfiniteScrollState.visibleRange.last + 1;
if (nextPage > InfiniteScrollState.totalPages) {
return;
}
// Check if already loaded or loading
if (InfiniteScrollState.pages[nextPage] && InfiniteScrollState.pages[nextPage].loaded) {
// Page exists, just show it
this.showPage(nextPage, 'append');
return;
}
if (InfiniteScrollState.pendingRequests[nextPage]) {
return; // Already loading
}
this.loadPage(nextPage, 'append');
},
/**
* Load the previous page (prepend)
*/
loadPrevPage: function() {
var prevPage = InfiniteScrollState.visibleRange.first - 1;
if (prevPage < 1) {
return;
}
// Check if already loaded or loading
if (InfiniteScrollState.pages[prevPage] && InfiniteScrollState.pages[prevPage].loaded) {
// Page exists, just show it
this.showPage(prevPage, 'prepend');
return;
}
if (InfiniteScrollState.pendingRequests[prevPage]) {
return; // Already loading
}
this.loadPage(prevPage, 'prepend');
},
/**
* Load a page via AJAX
*/
loadPage: function(page, position) {
var self = this;
var $loader = position === 'prepend' ? this.$topLoader : this.$bottomLoader;
// Show loader
$loader.addClass('is-loading');
// Build request data (same filters as PropertyFilters)
var formData = PropertyFilters.getFormData();
var requestData = {
action: 'homeproz_filter_properties',
nonce: homeprozAjax.nonce,
property_type: formData.property_type,
property_location: formData.property_location,
min_price: formData.min_price,
max_price: formData.max_price,
beds: formData.beds,
paged: page,
cards_only: 'true'
};
// Add map bounds if available
if (PropertyFilters.mapBounds) {
requestData.bounds = PropertyFilters.mapBounds;
}
if (PropertyFilters.mapCenter) {
requestData.center = PropertyFilters.mapCenter;
}
// Make request
var xhr = $.ajax({
url: homeprozAjax.ajaxUrl,
type: 'POST',
data: requestData,
success: function(response) {
if (response.success && response.data.html) {
self.renderPage(page, response.data.html, position);
}
},
complete: function() {
$loader.removeClass('is-loading');
delete InfiniteScrollState.pendingRequests[page];
}
});
InfiniteScrollState.pendingRequests[page] = xhr;
},
/**
* Render a page into the grid
*/
renderPage: function(page, html, position) {
var self = this;
// Save scroll anchor before DOM modification
var anchor = this.saveScrollAnchor();
// Create page container
var $pageContainer = $('<div class="infinite-scroll-page" data-page="' + page + '"></div>');
$pageContainer.html(html);
// Insert at correct position
if (position === 'prepend') {
// Find first page container and insert before it
var $firstPage = this.$grid.find('.infinite-scroll-page').first();
if ($firstPage.length) {
$firstPage.before($pageContainer);
} else {
this.$grid.prepend($pageContainer);
}
InfiniteScrollState.visibleRange.first = page;
} else {
// Find last page container and insert after it
var $lastPage = this.$grid.find('.infinite-scroll-page').last();
if ($lastPage.length) {
$lastPage.after($pageContainer);
} else {
this.$grid.append($pageContainer);
}
InfiniteScrollState.visibleRange.last = page;
}
// Store page info
InfiniteScrollState.pages[page] = {
html: html,
height: $pageContainer.outerHeight(),
loaded: true
};
// Restore scroll anchor
this.restoreScrollAnchor(anchor);
// Trigger image lazy loading
if (typeof CardImageLoader !== 'undefined') {
CardImageLoader.loadVisibleImages();
}
// Cleanup excess pages
this.cleanupExcessPages();
},
/**
* Show an already-loaded page (replace placeholder with content)
*/
showPage: function(page, position) {
var pageData = InfiniteScrollState.pages[page];
if (!pageData || !pageData.html) return;
// Check if placeholder exists
var $placeholder = this.$grid.find('.infinite-scroll-placeholder[data-page="' + page + '"]');
if ($placeholder.length) {
var anchor = this.saveScrollAnchor();
var $pageContainer = $('<div class="infinite-scroll-page" data-page="' + page + '"></div>');
$pageContainer.html(pageData.html);
$placeholder.replaceWith($pageContainer);
this.restoreScrollAnchor(anchor);
}
// Update visible range
if (position === 'prepend') {
InfiniteScrollState.visibleRange.first = page;
} else {
InfiniteScrollState.visibleRange.last = page;
}
// Trigger image lazy loading
if (typeof CardImageLoader !== 'undefined') {
CardImageLoader.loadVisibleImages();
}
this.cleanupExcessPages();
},
/**
* Cleanup pages outside buffer zone to manage memory
*/
cleanupExcessPages: function() {
var range = InfiniteScrollState.visibleRange;
var config = InfiniteScrollState.config;
var pagesInDOM = range.last - range.first + 1;
if (pagesInDOM <= config.maxPagesInDOM) {
return;
}
var self = this;
// Remove pages from beginning if scrolled far down
while (range.first < range.last - config.bufferPages) {
var pageToRemove = range.first;
var $page = this.$grid.find('.infinite-scroll-page[data-page="' + pageToRemove + '"]');
if ($page.length) {
var pageHeight = $page.outerHeight();
// Save scroll position
var anchor = this.saveScrollAnchor();
// Replace with placeholder
var $placeholder = $('<div class="infinite-scroll-placeholder" data-page="' + pageToRemove + '" style="height: ' + pageHeight + 'px;"></div>');
$page.replaceWith($placeholder);
// Update stored height
if (InfiniteScrollState.pages[pageToRemove]) {
InfiniteScrollState.pages[pageToRemove].height = pageHeight;
}
// Restore scroll position
this.restoreScrollAnchor(anchor);
}
range.first++;
}
// Remove pages from end if scrolled far up
while (range.last > range.first + config.bufferPages) {
var pageToRemove = range.last;
var $page = this.$grid.find('.infinite-scroll-page[data-page="' + pageToRemove + '"]');
if ($page.length) {
var pageHeight = $page.outerHeight();
// Save scroll position
var anchor = this.saveScrollAnchor();
// Replace with placeholder
var $placeholder = $('<div class="infinite-scroll-placeholder" data-page="' + pageToRemove + '" style="height: ' + pageHeight + 'px;"></div>');
$page.replaceWith($placeholder);
// Update stored height
if (InfiniteScrollState.pages[pageToRemove]) {
InfiniteScrollState.pages[pageToRemove].height = pageHeight;
}
// Restore scroll position
this.restoreScrollAnchor(anchor);
}
range.last--;
}
},
/**
* Save scroll anchor (first visible card)
*/
saveScrollAnchor: function() {
if (!this.scrollContainer) return null;
var containerRect = this.scrollContainer.getBoundingClientRect();
var $cards = this.$grid.find('.property-card');
var anchor = null;
$cards.each(function() {
var rect = this.getBoundingClientRect();
// Find first card that is at least partially visible
if (rect.bottom > containerRect.top && rect.top < containerRect.bottom) {
anchor = {
element: this,
offsetFromTop: rect.top - containerRect.top
};
return false; // break
}
});
return anchor;
},
/**
* Restore scroll position to maintain anchor
*/
restoreScrollAnchor: function(anchor) {
if (!anchor || !anchor.element || !this.scrollContainer) return;
var containerRect = this.scrollContainer.getBoundingClientRect();
var elementRect = anchor.element.getBoundingClientRect();
var currentOffset = elementRect.top - containerRect.top;
var diff = currentOffset - anchor.offsetFromTop;
if (Math.abs(diff) > 1) {
this.scrollContainer.scrollTop += diff;
}
},
/**
* Reset infinite scroll (called on filter/map change)
*/
reset: function() {
// Cancel all pending requests
for (var page in InfiniteScrollState.pendingRequests) {
if (InfiniteScrollState.pendingRequests[page]) {
InfiniteScrollState.pendingRequests[page].abort();
}
}
// Clear state
this.resetState();
// Scroll to top
if (this.scrollContainer) {
this.scrollContainer.scrollTop = 0;
}
},
/**
* Destroy infinite scroll (cleanup observers, restore DOM)
*/
destroy: function() {
if (!InfiniteScrollState.isEnabled) return;
// Cancel pending requests
for (var page in InfiniteScrollState.pendingRequests) {
if (InfiniteScrollState.pendingRequests[page]) {
InfiniteScrollState.pendingRequests[page].abort();
}
}
// Disconnect observers
if (this.topObserver) {
this.topObserver.disconnect();
this.topObserver = null;
}
if (this.bottomObserver) {
this.bottomObserver.disconnect();
this.bottomObserver = null;
}
// Remove DOM elements
if (this.$container) {
this.$container.find('.infinite-scroll-sentinel, .infinite-scroll-loader').remove();
this.$container.removeClass('infinite-scroll-enabled');
}
// Unwrap page containers (keep cards in grid)
if (this.$grid) {
this.$grid.find('.infinite-scroll-page').children().unwrap();
this.$grid.find('.infinite-scroll-placeholder').remove();
}
// Reset state
this.resetState();
InfiniteScrollState.isEnabled = false;
}
};
/**
* Property Card Image Loader
* Lazy loads background images with loading spinner
@@ -993,17 +1543,8 @@
bindEvents: function() {
var self = this;
var scrollTimeout;
// Load images on scroll (throttled)
$(window).on('scroll', function() {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(function() {
self.loadVisibleImages();
}, 100);
});
// Also observe DOM changes for AJAX-loaded content
// Observe DOM changes for AJAX-loaded content
if (typeof MutationObserver !== 'undefined') {
var observer = new MutationObserver(function(mutations) {
self.loadVisibleImages();
@@ -1021,19 +1562,10 @@
loadVisibleImages: function() {
var self = this;
var viewportHeight = $(window).height();
var scrollTop = $(window).scrollTop();
var buffer = 200; // Load images 200px before they enter viewport
// Load all images in DOM immediately - no viewport check
$('.property-card-image.is-loading[data-bg]').each(function() {
var $el = $(this);
var elTop = $el.offset().top;
var elBottom = elTop + $el.outerHeight();
// Check if element is in or near viewport
if (elBottom >= scrollTop - buffer && elTop <= scrollTop + viewportHeight + buffer) {
self.loadImage($el);
}
self.loadImage($(this));
});
},
@@ -1068,6 +1600,13 @@
ResponsiveView.init();
LayoutCalculator.init();
CardImageLoader.init();
// Initialize infinite scroll (desktop map view only)
if (window.innerWidth >= 1024 && $('.is-map-view').length) {
setTimeout(function() {
InfiniteScroll.init();
}, 300);
}
});
})(jQuery);
@@ -541,3 +541,76 @@
border-top-color: var(--color-border);
}
}
// Infinite Scroll (desktop map view only)
@media (min-width: 1024px) {
.property-list-container.infinite-scroll-enabled {
max-height: calc(100vh - 180px);
overflow-y: auto;
scroll-behavior: smooth;
// Custom scrollbar
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--color-bg-dark);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
&:hover {
background: var(--color-text-muted);
}
}
}
}
// Page containers flow cards into parent grid
.infinite-scroll-page {
display: contents;
}
// Placeholders maintain scroll height when pages unloaded
.infinite-scroll-placeholder {
grid-column: 1 / -1;
background: transparent;
}
// Sentinels for IntersectionObserver
.infinite-scroll-sentinel {
height: 1px;
visibility: hidden;
pointer-events: none;
}
// Loading indicators
.infinite-scroll-loader {
display: none;
justify-content: center;
align-items: center;
padding: 1.5rem;
grid-column: 1 / -1;
&.is-loading {
display: flex;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
}
// Hide pagination when infinite scroll is active
.infinite-scroll-enabled .pagination {
display: none;
}
@@ -0,0 +1,445 @@
<?php
/**
* Single MLS Property Template Part
*
* Displays a single property from MLS database
*
* @package HomeProz
*/
// Prevent direct access
if (!defined('ABSPATH')) {
exit;
}
// Get listing key from query var (set by archive-property.php)
$listing_key = get_query_var('mls_listing_key');
if (!$listing_key || !function_exists('mls_get_property')) {
// Redirect to archive if no listing specified
wp_redirect(get_post_type_archive_link('property'));
exit;
}
// Fetch property from MLS database
$property = mls_get_property($listing_key);
if (!$property) {
// Property not found - show 404
global $wp_query;
$wp_query->set_404();
status_header(404);
get_template_part('404');
exit;
}
// Extract property data
$price = $property->list_price;
$bedrooms = $property->bedrooms_total;
$bathrooms = $property->bathrooms_total;
$square_feet = $property->living_area;
$lot_size = $property->lot_size_area;
$lot_units = $property->lot_size_units;
$year_built = $property->year_built;
$garage = $property->garage_spaces;
$status = $property->standard_status;
$property_type = $property->property_type;
$property_subtype = $property->property_sub_type;
$public_remarks = $property->public_remarks;
$directions = $property->directions;
$days_on_market = $property->days_on_market;
$listing_id = $property->listing_id;
// 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;
}
if ($property->unit_number) {
$address_parts[] = '#' . $property->unit_number;
}
$street_address = implode(' ', $address_parts);
$full_address = $street_address;
if ($property->city) {
$full_address .= ', ' . $property->city;
}
if ($property->state_or_province) {
$full_address .= ', ' . $property->state_or_province;
}
if ($property->postal_code) {
$full_address .= ' ' . $property->postal_code;
}
// Status class
$status_class = 'badge-active';
if ($status === 'Pending') {
$status_class = 'badge-pending';
} elseif ($status === 'Closed' || $status === 'Sold') {
$status_class = 'badge-sold';
}
// Get all images for this property
$media = function_exists('mls_get_property_media') ? mls_get_property_media($listing_key) : array();
// Format lot size
$lot_display = '';
if ($lot_size) {
$lot_display = number_format($lot_size, 2);
if ($lot_units) {
$lot_display .= ' ' . $lot_units;
}
}
// Agent info
$agent_name = $property->list_agent_name;
$office_name = $property->list_office_name;
// Set page title to property address
$mls_page_title = $full_address;
if ($price) {
$mls_page_title .= ' - $' . number_format($price);
}
// Override title (works with Yoast SEO)
add_filter('wpseo_title', function() use ($mls_page_title) {
return $mls_page_title . ' - HomeProz';
}, 99);
add_filter('pre_get_document_title', function() use ($mls_page_title) {
return $mls_page_title . ' - HomeProz';
}, 99);
get_header();
?>
<main id="primary" class="site-main single-property-main Single_Property_MLS">
<div class="container">
<!-- Property Address Header -->
<header class="property-address-header">
<h1 class="property-address-title"><?php echo esc_html($full_address); ?></h1>
</header>
<div class="single-property-layout">
<!-- Main Content -->
<div class="single-property-content">
<!-- Gallery -->
<?php
$image_count = count($media);
if ($image_count > 0) :
// Build images array for JS
$gallery_images = array();
foreach ($media as $item) {
$full_url = function_exists('mls_get_image_url')
? mls_get_image_url($listing_key, $item->media_order, 'full')
: '';
if ($full_url) {
$gallery_images[] = array(
'url' => $full_url,
'alt' => $street_address . ' - Photo ' . $item->media_order,
);
}
}
$image_count = count($gallery_images);
$primary_image = !empty($gallery_images) ? $gallery_images[0]['url'] : '';
?>
<div class="property-gallery" data-gallery-count="<?php echo esc_attr($image_count); ?>">
<!-- Main Image -->
<div class="gallery-main">
<button class="gallery-main-image" type="button" data-lightbox-trigger aria-label="Open gallery">
<img src="<?php echo esc_url($primary_image); ?>" alt="<?php echo esc_attr($street_address); ?>" />
<?php if ($image_count > 1) : ?>
<!-- Play/Pause Button -->
<button class="gallery-playback-btn is-playing" type="button" aria-label="Pause slideshow">
<svg class="icon-pause" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="4" width="4" height="16"/>
<rect x="14" y="4" width="4" height="16"/>
</svg>
<svg class="icon-play" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<polygon points="5,3 19,12 5,21"/>
</svg>
</button>
<span class="gallery-count">
<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" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<?php echo esc_html($image_count); ?> Photos
</span>
<?php endif; ?>
</button>
</div>
<?php if ($image_count > 1) : ?>
<!-- Thumbnails -->
<div class="gallery-thumbnails-container">
<button class="gallery-thumbnails-nav gallery-thumbnails-prev" type="button" aria-label="Previous thumbnails" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<div class="gallery-thumbnails-viewport">
<div class="gallery-thumbnails">
<?php foreach ($media as $index => $item) :
$thumb_url = function_exists('mls_get_image_url')
? mls_get_image_url($listing_key, $item->media_order, 'thumb')
: '';
if (!$thumb_url) continue;
?>
<button
class="gallery-thumbnail <?php echo $index === 0 ? 'is-active' : ''; ?>"
type="button"
data-index="<?php echo esc_attr($index); ?>"
aria-label="View image <?php echo esc_attr($index + 1); ?>"
>
<img src="<?php echo esc_url($thumb_url); ?>" alt="" loading="lazy" />
</button>
<?php endforeach; ?>
</div>
</div>
<button class="gallery-thumbnails-nav gallery-thumbnails-next" type="button" aria-label="Next thumbnails">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
</div>
<?php endif; ?>
<!-- Lightbox -->
<div class="gallery-lightbox" id="property-lightbox" aria-hidden="true">
<div class="lightbox-overlay"></div>
<div class="lightbox-container">
<button class="lightbox-close" type="button" aria-label="Close gallery">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<button class="lightbox-nav lightbox-prev" type="button" aria-label="Previous image">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<div class="lightbox-image-container">
<img class="lightbox-image" src="" alt="" />
</div>
<button class="lightbox-nav lightbox-next" type="button" aria-label="Next image">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
<div class="lightbox-counter">
<span class="lightbox-current">1</span> / <span class="lightbox-total"><?php echo esc_html($image_count); ?></span>
</div>
</div>
<!-- Store image data for JS -->
<script type="application/json" id="gallery-images-data">
<?php echo wp_json_encode($gallery_images); ?>
</script>
</div>
</div>
<?php else : ?>
<!-- No Images Placeholder -->
<div class="property-gallery">
<div class="gallery-placeholder">
<svg width="64" height="64" 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>
<p>No photos available</p>
</div>
</div>
<?php endif; ?>
<!-- Property Specs -->
<?php if ($bedrooms || $bathrooms || $square_feet || $lot_display || $year_built || $garage) : ?>
<section class="property-specs-section">
<h2 class="section-title">Property Details</h2>
<ul class="property-specs-grid">
<?php if ($bedrooms) : ?>
<li class="spec-item">
<span class="spec-label">Bedrooms</span>
<span class="spec-value"><?php echo esc_html($bedrooms); ?></span>
</li>
<?php endif; ?>
<?php if ($bathrooms) : ?>
<li class="spec-item">
<span class="spec-label">Bathrooms</span>
<span class="spec-value"><?php echo esc_html($bathrooms); ?></span>
</li>
<?php endif; ?>
<?php if ($square_feet) : ?>
<li class="spec-item">
<span class="spec-label">Square Feet</span>
<span class="spec-value"><?php echo esc_html(number_format($square_feet)); ?></span>
</li>
<?php endif; ?>
<?php if ($lot_display) : ?>
<li class="spec-item">
<span class="spec-label">Lot Size</span>
<span class="spec-value"><?php echo esc_html($lot_display); ?></span>
</li>
<?php endif; ?>
<?php if ($year_built) : ?>
<li class="spec-item">
<span class="spec-label">Year Built</span>
<span class="spec-value"><?php echo esc_html($year_built); ?></span>
</li>
<?php endif; ?>
<?php if ($garage) : ?>
<li class="spec-item">
<span class="spec-label">Garage</span>
<span class="spec-value"><?php echo esc_html($garage); ?> Car</span>
</li>
<?php endif; ?>
<?php if ($days_on_market !== null) : ?>
<li class="spec-item">
<span class="spec-label">Days on Market</span>
<span class="spec-value"><?php echo esc_html($days_on_market); ?></span>
</li>
<?php endif; ?>
</ul>
</section>
<?php endif; ?>
<!-- Description -->
<?php if ($public_remarks) : ?>
<section class="property-description">
<h2 class="section-title">Description</h2>
<div class="property-full-desc">
<?php echo wpautop(esc_html($public_remarks)); ?>
</div>
</section>
<?php endif; ?>
<!-- Directions -->
<?php if ($directions) : ?>
<section class="property-directions">
<h2 class="section-title">Directions</h2>
<p><?php echo esc_html($directions); ?></p>
</section>
<?php endif; ?>
<!-- Map -->
<?php if ($property->latitude && $property->longitude) : ?>
<section class="property-map-section">
<h2 class="section-title">Location</h2>
<div id="property-location-map" class="property-location-map"
data-lat="<?php echo esc_attr($property->latitude); ?>"
data-lng="<?php echo esc_attr($property->longitude); ?>">
</div>
</section>
<?php endif; ?>
</div>
<!-- Sidebar -->
<aside class="single-property-sidebar">
<!-- Property Header -->
<div class="sidebar-widget property-header-widget">
<div class="property-header-top">
<?php if ($property_type) : ?>
<span class="badge badge-type"><?php echo esc_html($property_type); ?></span>
<?php endif; ?>
<?php if ($status) : ?>
<span class="badge <?php echo esc_attr($status_class); ?>"><?php echo esc_html($status); ?></span>
<?php endif; ?>
</div>
<div class="property-title">$<?php echo esc_html(number_format($price)); ?></div>
<?php if ($listing_id) : ?>
<p class="property-mls">MLS# <?php echo esc_html($listing_id); ?></p>
<?php endif; ?>
</div>
<!-- Contact Agent -->
<div class="sidebar-widget property-agent-widget">
<h3 class="widget-title">Listed By</h3>
<?php if ($agent_name) : ?>
<p class="agent-name"><?php echo esc_html($agent_name); ?></p>
<?php endif; ?>
<?php if ($office_name) : ?>
<p class="office-name"><?php echo esc_html($office_name); ?></p>
<?php endif; ?>
<a href="<?php echo esc_url(home_url('/contact/')); ?>" class="btn btn-primary btn-block">
Contact About This Property
</a>
</div>
<!-- Share -->
<div class="sidebar-widget property-share-widget">
<h3 class="widget-title">Share This Property</h3>
<div class="share-buttons">
<a href="mailto:?subject=<?php echo rawurlencode($full_address); ?>&body=<?php echo rawurlencode(get_permalink()); ?>"
class="share-btn share-email" aria-label="Share via Email">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</a>
<button class="share-btn share-copy" data-url="<?php echo esc_url(home_url('/properties/?listing=' . $listing_key)); ?>" aria-label="Copy Link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
</div>
</aside>
</div>
</div>
</main>
<?php if ($property->latitude && $property->longitude) : ?>
<!-- Leaflet for property map -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
(function() {
var mapEl = document.getElementById('property-location-map');
if (!mapEl) return;
var lat = parseFloat(mapEl.dataset.lat);
var lng = parseFloat(mapEl.dataset.lng);
var map = L.map('property-location-map').setView([lat, lng], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap'
}).addTo(map);
L.marker([lat, lng]).addTo(map);
})();
</script>
<?php endif; ?>
<script>
// Copy link button
(function() {
var copyBtn = document.querySelector('.share-copy');
if (copyBtn) {
copyBtn.addEventListener('click', function() {
var url = this.dataset.url;
navigator.clipboard.writeText(url).then(function() {
copyBtn.classList.add('copied');
setTimeout(function() { copyBtn.classList.remove('copied'); }, 2000);
});
});
}
})();
</script>
<?php
get_footer();
@@ -516,3 +516,175 @@
body.lightbox-open {
overflow: hidden;
}
// MLS Single Property Styles
.Single_Property_MLS {
// Breadcrumb
.property-breadcrumb {
padding: 1rem 0;
font-size: 0.875rem;
color: var(--color-text-muted);
a {
color: var(--color-text-muted);
text-decoration: none;
&:hover {
color: var(--color-accent-light);
}
}
.separator {
margin: 0 0.5rem;
}
.current {
color: var(--color-text);
}
}
// Gallery Section
.property-gallery-section {
margin-bottom: 2rem;
}
.property-gallery-main {
border-radius: 0.5rem;
overflow: hidden;
background-color: var(--color-bg-card);
margin-bottom: 1rem;
.gallery-main-image {
width: 100%;
height: auto;
max-height: 500px;
object-fit: cover;
display: block;
}
.gallery-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 300px;
color: var(--color-text-muted);
}
}
.property-gallery-thumbs {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.gallery-thumb {
flex-shrink: 0;
width: 80px;
height: 60px;
border-radius: 0.25rem;
overflow: hidden;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
background: none;
&.is-active {
border-color: var(--color-accent);
}
&:hover {
border-color: var(--color-accent-light);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&.gallery-more {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-bg-card);
color: var(--color-text);
font-weight: 600;
font-size: 0.875rem;
}
}
// Location Map
.property-map-section {
margin-bottom: 2rem;
}
.property-location-map {
height: 300px;
border-radius: 0.5rem;
overflow: hidden;
}
// Directions
.property-directions {
margin-bottom: 2rem;
p {
color: var(--color-text-muted);
line-height: 1.6;
}
}
// Agent Widget
.property-agent-widget {
.agent-name {
font-weight: 600;
margin-bottom: 0.25rem;
}
.office-name {
font-size: 0.875rem;
color: var(--color-text-muted);
margin-bottom: 1rem;
}
}
// Share Widget
.property-share-widget {
.share-buttons {
display: flex;
gap: 0.75rem;
}
.share-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 0.375rem;
background-color: var(--color-bg-dark);
color: var(--color-text-muted);
border: none;
cursor: pointer;
text-decoration: none;
transition: background-color 0.2s, color 0.2s;
&:hover {
background-color: var(--color-accent);
color: white;
}
&.copied {
background-color: var(--color-success);
color: white;
}
}
}
// Badge type generic
.badge-type {
background-color: var(--color-bg-dark);
color: var(--color-text);
}
}