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:
@@ -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,81 +329,121 @@ class MLS_Media_Handler {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Download the image
|
||||
$response = wp_remote_get($media->media_url, array(
|
||||
'timeout' => 30,
|
||||
// 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 (is_wp_error($response)) {
|
||||
$this->logger->warning('Media fetch failed', array(
|
||||
if ($lock_acquired !== '1') {
|
||||
$this->logger->warning('Could not acquire media lock', array(
|
||||
'listing_key' => $media->listing_key,
|
||||
'media_key' => $media->media_key,
|
||||
'error' => $response->get_error_message(),
|
||||
'lock_result' => $lock_acquired,
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code($response);
|
||||
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 ($status_code !== 200) {
|
||||
$this->logger->warning('Media fetch HTTP error', array(
|
||||
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,
|
||||
));
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$this->logger->warning('Media fetch failed', array(
|
||||
'listing_key' => $media->listing_key,
|
||||
'media_key' => $media->media_key,
|
||||
'error' => $response->get_error_message(),
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code($response);
|
||||
|
||||
if ($status_code !== 200) {
|
||||
$this->logger->warning('Media fetch HTTP error', array(
|
||||
'listing_key' => $media->listing_key,
|
||||
'media_key' => $media->media_key,
|
||||
'status' => $status_code,
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
if (empty($body)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine extension
|
||||
$content_type = wp_remote_retrieve_header($response, 'content-type');
|
||||
$extension = $this->get_extension_from_content_type($content_type, $media->media_url);
|
||||
|
||||
// Create directory
|
||||
$listing_dir = $this->get_listing_dir($media->listing_key);
|
||||
if (!file_exists($listing_dir)) {
|
||||
wp_mkdir_p($listing_dir);
|
||||
}
|
||||
|
||||
// Save file
|
||||
$filename = $media->media_order . '.' . $extension;
|
||||
$file_path = $listing_dir . '/' . $filename;
|
||||
|
||||
if (file_put_contents($file_path, $body) === false) {
|
||||
$this->logger->error('Failed to write media file', array(
|
||||
'path' => $file_path,
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update database
|
||||
$prefix = substr($media->listing_key, 0, 2);
|
||||
$relative_path = $prefix . '/' . $media->listing_key . '/' . $filename;
|
||||
$local_url = $this->get_upload_url() . '/' . $relative_path;
|
||||
|
||||
$wpdb->update(
|
||||
$this->db->media_table(),
|
||||
array(
|
||||
'local_path' => $relative_path,
|
||||
'local_url' => $local_url,
|
||||
'file_size' => strlen($body),
|
||||
'mime_type' => $content_type,
|
||||
'downloaded_at' => current_time('mysql'),
|
||||
),
|
||||
array('id' => $media->id)
|
||||
);
|
||||
|
||||
$this->logger->debug('Media fetched and cached', array(
|
||||
'listing_key' => $media->listing_key,
|
||||
'media_key' => $media->media_key,
|
||||
'status' => $status_code,
|
||||
'size' => strlen($body),
|
||||
));
|
||||
return null;
|
||||
|
||||
return $local_url;
|
||||
|
||||
} finally {
|
||||
// Always release the lock
|
||||
$wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lock_name));
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
if (empty($body)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine extension
|
||||
$content_type = wp_remote_retrieve_header($response, 'content-type');
|
||||
$extension = $this->get_extension_from_content_type($content_type, $media->media_url);
|
||||
|
||||
// Create directory
|
||||
$listing_dir = $this->get_listing_dir($media->listing_key);
|
||||
if (!file_exists($listing_dir)) {
|
||||
wp_mkdir_p($listing_dir);
|
||||
}
|
||||
|
||||
// Save file
|
||||
$filename = $media->media_order . '.' . $extension;
|
||||
$file_path = $listing_dir . '/' . $filename;
|
||||
|
||||
if (file_put_contents($file_path, $body) === false) {
|
||||
$this->logger->error('Failed to write media file', array(
|
||||
'path' => $file_path,
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update database
|
||||
$prefix = substr($media->listing_key, 0, 2);
|
||||
$relative_path = $prefix . '/' . $media->listing_key . '/' . $filename;
|
||||
$local_url = $this->get_upload_url() . '/' . $relative_path;
|
||||
|
||||
$wpdb->update(
|
||||
$this->db->media_table(),
|
||||
array(
|
||||
'local_path' => $relative_path,
|
||||
'local_url' => $local_url,
|
||||
'file_size' => strlen($body),
|
||||
'mime_type' => $content_type,
|
||||
'downloaded_at' => current_time('mysql'),
|
||||
),
|
||||
array('id' => $media->id)
|
||||
);
|
||||
|
||||
$this->logger->debug('Media fetched and cached', array(
|
||||
'listing_key' => $media->listing_key,
|
||||
'media_key' => $media->media_key,
|
||||
'size' => strlen($body),
|
||||
));
|
||||
|
||||
return $local_url;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
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: '© 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user