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.
|
* Serves WebP thumbnails for MLS property images with on-demand conversion.
|
||||||
* Uses ImageMagick to convert and resize images.
|
* 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
|
* - listing_key: MLS listing key
|
||||||
* - index: Image index (0-based)
|
* - index: Image index (0-based)
|
||||||
* - size: 'thumb' (800px) or 'full' (1800px)
|
* - size: 'thumb' (800px) or 'full' (1800px)
|
||||||
|
* - sig: HMAC signature to prevent unauthorized access
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (!defined('ABSPATH')) {
|
if (!defined('ABSPATH')) {
|
||||||
@@ -33,6 +34,11 @@ class MLS_Image_Endpoint {
|
|||||||
*/
|
*/
|
||||||
const CACHE_SUBDIR = 'mls-thumbnails';
|
const CACHE_SUBDIR = 'mls-thumbnails';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signature length (truncated HMAC for shorter URLs)
|
||||||
|
*/
|
||||||
|
const SIG_LENGTH = 16;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Media handler instance
|
* Media handler instance
|
||||||
*/
|
*/
|
||||||
@@ -93,6 +99,7 @@ class MLS_Image_Endpoint {
|
|||||||
$listing_key = sanitize_text_field(get_query_var('mls_listing_key'));
|
$listing_key = sanitize_text_field(get_query_var('mls_listing_key'));
|
||||||
$index = absint(get_query_var('mls_image_index'));
|
$index = absint(get_query_var('mls_image_index'));
|
||||||
$size = sanitize_text_field(get_query_var('mls_image_size'));
|
$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)) {
|
if (empty($listing_key) || !in_array($size, array('thumb', 'full'), true)) {
|
||||||
$this->logger->error('MLS Image: Invalid params', array(
|
$this->logger->error('MLS Image: Invalid params', array(
|
||||||
@@ -104,6 +111,17 @@ class MLS_Image_Endpoint {
|
|||||||
return;
|
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;
|
$max_dimension = ($size === 'thumb') ? self::SIZE_THUMB : self::SIZE_FULL;
|
||||||
|
|
||||||
// Try to serve from cache first
|
// Try to serve from cache first
|
||||||
@@ -394,6 +412,15 @@ class MLS_Image_Endpoint {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send 403 Forbidden response
|
||||||
|
*/
|
||||||
|
private function send_403() {
|
||||||
|
status_header(403);
|
||||||
|
nocache_headers();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get URL for an MLS image
|
* Get URL for an MLS image
|
||||||
*
|
*
|
||||||
@@ -403,7 +430,50 @@ class MLS_Image_Endpoint {
|
|||||||
* @return string Image URL
|
* @return string Image URL
|
||||||
*/
|
*/
|
||||||
public static function get_url($listing_key, $index = 1, $size = 'thumb') {
|
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
|
* 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
|
* @param object $media Media record
|
||||||
* @return string|null Local URL on success, null on failure
|
* @return string|null Local URL on success, null on failure
|
||||||
*/
|
*/
|
||||||
@@ -326,81 +329,121 @@ class MLS_Media_Handler {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download the image
|
// Advisory lock key - unique per media record
|
||||||
$response = wp_remote_get($media->media_url, array(
|
$lock_name = 'mls_media_' . $media->id;
|
||||||
'timeout' => 30,
|
$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)) {
|
if ($lock_acquired !== '1') {
|
||||||
$this->logger->warning('Media fetch failed', array(
|
$this->logger->warning('Could not acquire media lock', array(
|
||||||
'listing_key' => $media->listing_key,
|
'listing_key' => $media->listing_key,
|
||||||
'media_key' => $media->media_key,
|
'media_key' => $media->media_key,
|
||||||
'error' => $response->get_error_message(),
|
'lock_result' => $lock_acquired,
|
||||||
));
|
));
|
||||||
return null;
|
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) {
|
if ($updated_media && $updated_media->local_path) {
|
||||||
$this->logger->warning('Media fetch HTTP error', array(
|
$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,
|
'listing_key' => $media->listing_key,
|
||||||
'media_key' => $media->media_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;
|
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();
|
get_header();
|
||||||
|
|
||||||
// Map view is default on desktop, grid view requires ?view=grid
|
// 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']) : '';
|
$beds = isset($_POST['beds']) ? intval($_POST['beds']) : '';
|
||||||
$sort = isset($_POST['sort']) ? sanitize_text_field($_POST['sort']) : 'newest';
|
$sort = isset($_POST['sort']) ? sanitize_text_field($_POST['sort']) : 'newest';
|
||||||
$paged = isset($_POST['paged']) ? intval($_POST['paged']) : 1;
|
$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)
|
// Map bounds and center (for map-synced list view)
|
||||||
$bounds = null;
|
$bounds = null;
|
||||||
@@ -93,6 +94,26 @@ function homeproz_ajax_filter_properties() {
|
|||||||
// Fetch only the properties we need for this page
|
// Fetch only the properties we need for this page
|
||||||
$paged_properties = mls_get_properties($mls_args);
|
$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();
|
ob_start();
|
||||||
|
|
||||||
// Results Meta
|
// Results Meta
|
||||||
|
|||||||
@@ -616,6 +616,12 @@
|
|||||||
this.mapBounds = bounds;
|
this.mapBounds = bounds;
|
||||||
this.mapCenter = center;
|
this.mapCenter = center;
|
||||||
this.isMapUpdate = true;
|
this.isMapUpdate = true;
|
||||||
|
|
||||||
|
// Reset infinite scroll state before loading new content
|
||||||
|
if (InfiniteScrollState.isEnabled) {
|
||||||
|
InfiniteScroll.reset();
|
||||||
|
}
|
||||||
|
|
||||||
// Reset to page 1 when map viewport changes
|
// Reset to page 1 when map viewport changes
|
||||||
this.filterProperties(1, false);
|
this.filterProperties(1, false);
|
||||||
},
|
},
|
||||||
@@ -692,6 +698,15 @@
|
|||||||
LayoutCalculator.calculate();
|
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)
|
// Update URL (skip when map-triggered)
|
||||||
if (updateHistory) {
|
if (updateHistory) {
|
||||||
self.updateUrl(formData, page);
|
self.updateUrl(formData, page);
|
||||||
@@ -833,6 +848,14 @@
|
|||||||
this.isAboveBreakpoint = window.innerWidth >= this.breakpoint;
|
this.isAboveBreakpoint = window.innerWidth >= this.breakpoint;
|
||||||
var $main = $('.property-archive-main');
|
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
|
// Crossing from below to above breakpoint
|
||||||
if (!wasAbove && this.isAboveBreakpoint) {
|
if (!wasAbove && this.isAboveBreakpoint) {
|
||||||
// Restore the user's view preference
|
// Restore the user's view preference
|
||||||
@@ -849,6 +872,11 @@
|
|||||||
PropertyMap.map.invalidateSize();
|
PropertyMap.map.invalidateSize();
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize infinite scroll
|
||||||
|
setTimeout(function() {
|
||||||
|
InfiniteScroll.init();
|
||||||
|
}, 200);
|
||||||
} else {
|
} else {
|
||||||
$main.removeClass('is-map-view').addClass('is-grid-view');
|
$main.removeClass('is-map-view').addClass('is-grid-view');
|
||||||
}
|
}
|
||||||
@@ -860,7 +888,6 @@
|
|||||||
}, 150);
|
}, 150);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No need to do anything when crossing below - CSS handles hiding map
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setMapView: function(isMap) {
|
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
|
* Property Card Image Loader
|
||||||
* Lazy loads background images with loading spinner
|
* Lazy loads background images with loading spinner
|
||||||
@@ -993,17 +1543,8 @@
|
|||||||
|
|
||||||
bindEvents: function() {
|
bindEvents: function() {
|
||||||
var self = this;
|
var self = this;
|
||||||
var scrollTimeout;
|
|
||||||
|
|
||||||
// Load images on scroll (throttled)
|
// Observe DOM changes for AJAX-loaded content
|
||||||
$(window).on('scroll', function() {
|
|
||||||
clearTimeout(scrollTimeout);
|
|
||||||
scrollTimeout = setTimeout(function() {
|
|
||||||
self.loadVisibleImages();
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also observe DOM changes for AJAX-loaded content
|
|
||||||
if (typeof MutationObserver !== 'undefined') {
|
if (typeof MutationObserver !== 'undefined') {
|
||||||
var observer = new MutationObserver(function(mutations) {
|
var observer = new MutationObserver(function(mutations) {
|
||||||
self.loadVisibleImages();
|
self.loadVisibleImages();
|
||||||
@@ -1021,19 +1562,10 @@
|
|||||||
|
|
||||||
loadVisibleImages: function() {
|
loadVisibleImages: function() {
|
||||||
var self = this;
|
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() {
|
$('.property-card-image.is-loading[data-bg]').each(function() {
|
||||||
var $el = $(this);
|
self.loadImage($(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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1068,6 +1600,13 @@
|
|||||||
ResponsiveView.init();
|
ResponsiveView.init();
|
||||||
LayoutCalculator.init();
|
LayoutCalculator.init();
|
||||||
CardImageLoader.init();
|
CardImageLoader.init();
|
||||||
|
|
||||||
|
// Initialize infinite scroll (desktop map view only)
|
||||||
|
if (window.innerWidth >= 1024 && $('.is-map-view').length) {
|
||||||
|
setTimeout(function() {
|
||||||
|
InfiniteScroll.init();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
})(jQuery);
|
})(jQuery);
|
||||||
|
|||||||
@@ -541,3 +541,76 @@
|
|||||||
border-top-color: var(--color-border);
|
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 {
|
body.lightbox-open {
|
||||||
overflow: hidden;
|
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