Manual property enhancements: MLS status sync, agent clone, description formatting
- Manual properties linked to MLS now inherit status (Active/Pending/Closed) and days_on_market from the MLS listing dynamically - Properties not in MLS default to Closed status - Clone feature now auto-populates listing agent by matching MLS ID to Agent CPT - Description formatter detects embedded headers (unpunctuated text after sentences) and splits them into separate paragraphs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -191,24 +191,62 @@ class MLS_CLI {
|
||||
private function show_rate_limits() {
|
||||
$rate_limiter = $this->plugin->get_rate_limiter();
|
||||
$status = $rate_limiter->get_status();
|
||||
$summary = $rate_limiter->get_usage_summary();
|
||||
|
||||
WP_CLI::line('=== Rate Limits ===');
|
||||
WP_CLI::line('=== MLS Grid Rate Limits ===');
|
||||
WP_CLI::line('');
|
||||
|
||||
// Requests
|
||||
WP_CLI::line('Requests:');
|
||||
WP_CLI::line(sprintf(
|
||||
'Hourly: %d / %d requests (%d remaining)',
|
||||
$status['hourly']['used'],
|
||||
$status['hourly']['limit'],
|
||||
$status['hourly']['remaining']
|
||||
' Hourly: %s / %s (%s%%)',
|
||||
number_format($status['hourly']['used']),
|
||||
number_format($status['hourly']['limit']),
|
||||
$summary['requests_hourly_pct']
|
||||
));
|
||||
WP_CLI::line(sprintf(
|
||||
'Daily: %d / %d requests (%d remaining)',
|
||||
$status['daily']['used'],
|
||||
$status['daily']['limit'],
|
||||
$status['daily']['remaining']
|
||||
' Daily: %s / %s (%s%%)',
|
||||
number_format($status['daily']['used']),
|
||||
number_format($status['daily']['limit']),
|
||||
$summary['requests_daily_pct']
|
||||
));
|
||||
WP_CLI::line('');
|
||||
|
||||
// Data transfer
|
||||
WP_CLI::line('Data Transfer:');
|
||||
WP_CLI::line(sprintf(
|
||||
' Hourly: %s / %s (%s%%)',
|
||||
size_format($status['data_hourly']['used']),
|
||||
size_format($status['data_hourly']['limit']),
|
||||
$summary['data_hourly_pct']
|
||||
));
|
||||
WP_CLI::line(sprintf(
|
||||
'Data: %s / 4GB this hour',
|
||||
size_format($status['bytes_this_hour'])
|
||||
' Daily: %s / %s (%s%%)',
|
||||
size_format($status['data_daily']['used']),
|
||||
size_format($status['data_daily']['limit']),
|
||||
$summary['data_daily_pct']
|
||||
));
|
||||
WP_CLI::line(sprintf(
|
||||
' Remaining today: %s GB',
|
||||
$summary['data_daily_remaining_gb']
|
||||
));
|
||||
WP_CLI::line('');
|
||||
|
||||
// Warnings
|
||||
if ($rate_limiter->is_approaching_limit(0.7)) {
|
||||
WP_CLI::warning('Approaching rate limits (>70% used)');
|
||||
}
|
||||
if ($rate_limiter->is_approaching_limit(0.9)) {
|
||||
WP_CLI::error('Critical: Near rate limit threshold (>90% used)', false);
|
||||
}
|
||||
|
||||
// Sync pacing info
|
||||
WP_CLI::line('Sync Pacing:');
|
||||
WP_CLI::line(sprintf(
|
||||
' Min interval: %s seconds between API requests',
|
||||
number_format(MLS_Rate_Limiter::SYNC_MIN_INTERVAL_MS / 1000, 2)
|
||||
));
|
||||
WP_CLI::line(' (Ensures max 50%% of daily quota used even if sync runs 24h)');
|
||||
WP_CLI::line('');
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,8 @@ class MLS_Cluster {
|
||||
|
||||
// Build WHERE clause
|
||||
// Exclude properties with invalid coordinates from map display
|
||||
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL', 'coordinates_invalid = 0');
|
||||
// Also exclude properties with no price or price < 100 (invalid data)
|
||||
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL', 'coordinates_invalid = 0', 'list_price >= 100');
|
||||
$values = array();
|
||||
|
||||
// Add state filter (MN, IA only)
|
||||
@@ -484,6 +485,11 @@ class MLS_Cluster {
|
||||
$street = implode(' ', $address_parts);
|
||||
$full_address = $street ? $street . ', ' . $property->city : $property->city;
|
||||
|
||||
// Get image URL with signature
|
||||
$image_url = function_exists('mls_get_image_url')
|
||||
? mls_get_image_url($property->listing_key, 1, 'thumb')
|
||||
: '';
|
||||
|
||||
$markers[] = array(
|
||||
'id' => $property->listing_key,
|
||||
'lat' => (float) $property->latitude,
|
||||
@@ -495,6 +501,7 @@ class MLS_Cluster {
|
||||
'baths' => $property->bathrooms_total,
|
||||
'sqft' => $property->living_area,
|
||||
'status' => $property->standard_status,
|
||||
'image' => $image_url,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -572,8 +579,8 @@ class MLS_Cluster {
|
||||
global $wpdb;
|
||||
|
||||
$table = $this->db->properties_table();
|
||||
// Exclude properties with invalid coordinates
|
||||
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL', 'coordinates_invalid = 0');
|
||||
// Exclude properties with invalid coordinates or invalid price
|
||||
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL', 'coordinates_invalid = 0', 'list_price >= 100');
|
||||
$values = array();
|
||||
|
||||
// Add state filter (MN, IA only)
|
||||
|
||||
+1256
File diff suppressed because it is too large
Load Diff
@@ -464,6 +464,9 @@ class MLS_Media_Handler {
|
||||
* Uses MySQL advisory lock to ensure only one request downloads
|
||||
* a specific image at a time (prevents stampede on cold cache).
|
||||
*
|
||||
* Respects daily data budget - if approaching limit, will skip fetch
|
||||
* and return null (graceful degradation).
|
||||
*
|
||||
* @param object $media Media record
|
||||
* @return string|null Local URL on success, null on failure
|
||||
*/
|
||||
@@ -474,6 +477,17 @@ class MLS_Media_Handler {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check daily data budget before fetching
|
||||
$rate_limiter = mls_plugin()->get_rate_limiter();
|
||||
if (!$rate_limiter->can_fetch_image()) {
|
||||
$this->logger->warning('Daily data budget exhausted, skipping image fetch', array(
|
||||
'listing_key' => $media->listing_key,
|
||||
'media_key' => $media->media_key,
|
||||
'remaining_bytes' => $rate_limiter->get_daily_data_remaining(),
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Advisory lock key - unique per media record
|
||||
$lock_name = 'mls_media_' . $media->id;
|
||||
$lock_timeout = 35; // Slightly longer than HTTP timeout
|
||||
@@ -516,9 +530,16 @@ class MLS_Media_Handler {
|
||||
}
|
||||
|
||||
// Download the image
|
||||
$response = wp_remote_get($media->media_url, array(
|
||||
$request_args = array(
|
||||
'timeout' => 30,
|
||||
));
|
||||
);
|
||||
|
||||
// Allow skipping SSL verification if configured (for expired certs)
|
||||
if (defined('MLS_SKIP_SSL_VERIFY') && MLS_SKIP_SSL_VERIFY) {
|
||||
$request_args['sslverify'] = false;
|
||||
}
|
||||
|
||||
$response = wp_remote_get($media->media_url, $request_args);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$this->logger->warning('Media fetch failed', array(
|
||||
@@ -545,6 +566,10 @@ class MLS_Media_Handler {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Record bytes downloaded against daily data cap
|
||||
$bytes_downloaded = strlen($body);
|
||||
$rate_limiter->record_data_transfer($bytes_downloaded);
|
||||
|
||||
// Determine extension
|
||||
$content_type = wp_remote_retrieve_header($response, 'content-type');
|
||||
$extension = $this->get_extension_from_content_type($content_type, $media->media_url);
|
||||
@@ -598,7 +623,7 @@ class MLS_Media_Handler {
|
||||
$this->logger->debug('Media fetched and cached', array(
|
||||
'listing_key' => $media->listing_key,
|
||||
'media_key' => $media->media_key,
|
||||
'original_size' => strlen($body),
|
||||
'bytes_downloaded' => $bytes_downloaded,
|
||||
'final_size' => $final_size,
|
||||
'converted' => $conversion['converted'],
|
||||
));
|
||||
|
||||
@@ -164,6 +164,9 @@ class MLS_Query {
|
||||
/**
|
||||
* Get properties matching criteria
|
||||
*
|
||||
* Queries both MLS and manual properties, excluding MLS entries
|
||||
* that have manual overrides.
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Property objects
|
||||
*/
|
||||
@@ -190,6 +193,7 @@ class MLS_Query {
|
||||
'year_built_max' => null,
|
||||
'listing_key' => null,
|
||||
'listing_id' => null,
|
||||
'agent_mls_id' => null, // Filter by list_agent_mls_id
|
||||
'search' => null, // Search in address/remarks
|
||||
'bounds' => null, // Map bounds: array(sw_lat, sw_lng, ne_lat, ne_lng)
|
||||
'center' => null, // Map center for distance sort: array(lat, lng)
|
||||
@@ -199,11 +203,15 @@ class MLS_Query {
|
||||
'orderby' => 'modification_timestamp',
|
||||
'order' => 'DESC',
|
||||
'include_media' => false,
|
||||
'include_manual' => true, // Include manual properties
|
||||
'fields' => '*', // Specific fields or *
|
||||
);
|
||||
|
||||
$args = wp_parse_args($args, $defaults);
|
||||
|
||||
// Get manual override IDs to exclude from MLS query
|
||||
$override_ids = $args['include_manual'] ? $this->get_manual_override_listing_ids() : array();
|
||||
|
||||
// Build query
|
||||
$table = $this->db->properties_table();
|
||||
|
||||
@@ -218,7 +226,8 @@ class MLS_Query {
|
||||
$sql = "SELECT {$select} FROM {$table}";
|
||||
|
||||
// WHERE conditions
|
||||
$where = array('mlg_can_view = 1');
|
||||
// Exclude properties with no price or price < 100 (invalid data)
|
||||
$where = array('mlg_can_view = 1', 'list_price >= 100');
|
||||
$values = array();
|
||||
|
||||
// Add state filter (MN and IA only)
|
||||
@@ -230,6 +239,12 @@ class MLS_Query {
|
||||
// Exclude TBD addresses
|
||||
$where[] = $this->get_tbd_exclusion_filter();
|
||||
|
||||
// Exclude MLS properties that have manual overrides
|
||||
if (!empty($override_ids)) {
|
||||
$placeholders = implode(',', array_fill(0, count($override_ids), '%s'));
|
||||
$where[] = $wpdb->prepare("listing_id NOT IN ({$placeholders})", $override_ids);
|
||||
}
|
||||
|
||||
if ($args['status']) {
|
||||
$where[] = 'standard_status = %s';
|
||||
$values[] = $args['status'];
|
||||
@@ -270,6 +285,11 @@ class MLS_Query {
|
||||
$values[] = $args['county'];
|
||||
}
|
||||
|
||||
if ($args['agent_mls_id']) {
|
||||
$where[] = 'list_agent_mls_id = %s';
|
||||
$values[] = $args['agent_mls_id'];
|
||||
}
|
||||
|
||||
if ($args['min_price']) {
|
||||
$where[] = 'list_price >= %d';
|
||||
$values[] = (int) $args['min_price'];
|
||||
@@ -397,9 +417,59 @@ class MLS_Query {
|
||||
$values[] = (int) $args['limit'];
|
||||
$values[] = (int) $args['offset'];
|
||||
|
||||
// Execute
|
||||
// Execute MLS query
|
||||
$results = $wpdb->get_results($wpdb->prepare($sql, $values));
|
||||
|
||||
// Query manual properties with same filters
|
||||
if ($args['include_manual']) {
|
||||
$manual_results = $this->get_manual_properties($args);
|
||||
if (!empty($manual_results)) {
|
||||
// Merge manual properties with MLS results
|
||||
$results = array_merge($manual_results, $results);
|
||||
|
||||
// Re-sort: HomeProz first, then featured, then by orderby
|
||||
$featured_ids = !empty($args['featured_ids']) ? (array) $args['featured_ids'] : array();
|
||||
$order_desc = strtoupper($args['order']) !== 'ASC';
|
||||
$orderby = $args['orderby'];
|
||||
|
||||
usort($results, function($a, $b) use ($featured_ids, $order_desc, $orderby) {
|
||||
// 1. HomeProz listings first
|
||||
$a_homeproz = !empty($a->is_homeproz) ? 1 : 0;
|
||||
$b_homeproz = !empty($b->is_homeproz) ? 1 : 0;
|
||||
if ($a_homeproz !== $b_homeproz) {
|
||||
return $b_homeproz - $a_homeproz;
|
||||
}
|
||||
|
||||
// 2. Featured listings second
|
||||
$a_featured = in_array($a->listing_id, $featured_ids) || !empty($a->is_featured) ? 1 : 0;
|
||||
$b_featured = in_array($b->listing_id, $featured_ids) || !empty($b->is_featured) ? 1 : 0;
|
||||
if ($a_featured !== $b_featured) {
|
||||
return $b_featured - $a_featured;
|
||||
}
|
||||
|
||||
// 3. Order by specified field
|
||||
$a_val = isset($a->$orderby) ? $a->$orderby : '';
|
||||
$b_val = isset($b->$orderby) ? $b->$orderby : '';
|
||||
|
||||
if ($a_val === $b_val) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Compare as numbers if numeric
|
||||
if (is_numeric($a_val) && is_numeric($b_val)) {
|
||||
$cmp = ($a_val < $b_val) ? -1 : 1;
|
||||
} else {
|
||||
$cmp = strcmp($a_val, $b_val);
|
||||
}
|
||||
|
||||
return $order_desc ? -$cmp : $cmp;
|
||||
});
|
||||
|
||||
// Apply offset and limit after merge
|
||||
$results = array_slice($results, (int) $args['offset'], (int) $args['limit']);
|
||||
}
|
||||
}
|
||||
|
||||
// Include media if requested
|
||||
if ($args['include_media'] && $results) {
|
||||
foreach ($results as &$property) {
|
||||
@@ -410,15 +480,169 @@ class MLS_Query {
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get manual properties matching criteria
|
||||
*
|
||||
* @param array $args Query arguments (same as get_properties)
|
||||
* @return array Manual property objects
|
||||
*/
|
||||
private function get_manual_properties($args) {
|
||||
global $wpdb;
|
||||
|
||||
$table = $this->db->manual_properties_table();
|
||||
|
||||
// Check if table exists
|
||||
$table_exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
|
||||
DB_NAME,
|
||||
$table
|
||||
));
|
||||
|
||||
if (!$table_exists) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$sql = "SELECT * FROM {$table}";
|
||||
$where = array("standard_status != 'Withdrawn'");
|
||||
$values = array();
|
||||
|
||||
if ($args['status']) {
|
||||
$where[] = 'standard_status = %s';
|
||||
$values[] = $args['status'];
|
||||
}
|
||||
|
||||
if ($args['property_type']) {
|
||||
$where[] = 'property_type = %s';
|
||||
$values[] = $args['property_type'];
|
||||
}
|
||||
|
||||
if ($args['city']) {
|
||||
$where[] = 'city = %s';
|
||||
$values[] = $args['city'];
|
||||
}
|
||||
|
||||
if ($args['county']) {
|
||||
$where[] = 'county = %s';
|
||||
$values[] = $args['county'];
|
||||
}
|
||||
|
||||
if ($args['postal_code']) {
|
||||
$where[] = 'postal_code = %s';
|
||||
$values[] = $args['postal_code'];
|
||||
}
|
||||
|
||||
if ($args['min_price']) {
|
||||
$where[] = 'list_price >= %d';
|
||||
$values[] = (int) $args['min_price'];
|
||||
}
|
||||
|
||||
if ($args['max_price']) {
|
||||
$where[] = 'list_price <= %d';
|
||||
$values[] = (int) $args['max_price'];
|
||||
}
|
||||
|
||||
if ($args['min_beds']) {
|
||||
$where[] = 'bedrooms_total >= %d';
|
||||
$values[] = (int) $args['min_beds'];
|
||||
}
|
||||
|
||||
if ($args['max_beds']) {
|
||||
$where[] = 'bedrooms_total <= %d';
|
||||
$values[] = (int) $args['max_beds'];
|
||||
}
|
||||
|
||||
if ($args['min_baths']) {
|
||||
$where[] = 'bathrooms_total >= %d';
|
||||
$values[] = (int) $args['min_baths'];
|
||||
}
|
||||
|
||||
if ($args['listing_key']) {
|
||||
$where[] = 'listing_key = %s';
|
||||
$values[] = $args['listing_key'];
|
||||
}
|
||||
|
||||
if ($args['listing_id']) {
|
||||
$where[] = 'listing_id = %s';
|
||||
$values[] = $args['listing_id'];
|
||||
}
|
||||
|
||||
if ($args['search']) {
|
||||
$search_term = '%' . $wpdb->esc_like($args['search']) . '%';
|
||||
$where[] = '(full_address LIKE %s OR city LIKE %s OR public_remarks LIKE %s OR listing_id LIKE %s)';
|
||||
$values[] = $search_term;
|
||||
$values[] = $search_term;
|
||||
$values[] = $search_term;
|
||||
$values[] = $search_term;
|
||||
}
|
||||
|
||||
// Map bounds filtering
|
||||
if ($args['bounds'] && is_array($args['bounds']) && count($args['bounds']) === 4) {
|
||||
list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds'];
|
||||
$where[] = 'latitude BETWEEN %f AND %f';
|
||||
$where[] = 'longitude BETWEEN %f AND %f';
|
||||
$where[] = 'latitude IS NOT NULL';
|
||||
$where[] = 'longitude IS NOT NULL';
|
||||
$values[] = (float) $sw_lat;
|
||||
$values[] = (float) $ne_lat;
|
||||
$values[] = (float) $sw_lng;
|
||||
$values[] = (float) $ne_lng;
|
||||
}
|
||||
|
||||
// Radius search for manual properties
|
||||
if ($args['center_lat'] && $args['center_lng']) {
|
||||
$lat = (float) $args['center_lat'];
|
||||
$lng = (float) $args['center_lng'];
|
||||
$radius = (int) $args['radius'];
|
||||
|
||||
// Use simple bounding box for manual properties (no spatial index)
|
||||
$lat_delta = $radius / 69.0;
|
||||
$lng_delta = $radius / (69.0 * cos(deg2rad($lat)));
|
||||
|
||||
$where[] = 'latitude BETWEEN %f AND %f';
|
||||
$where[] = 'longitude BETWEEN %f AND %f';
|
||||
$values[] = $lat - $lat_delta;
|
||||
$values[] = $lat + $lat_delta;
|
||||
$values[] = $lng - $lng_delta;
|
||||
$values[] = $lng + $lng_delta;
|
||||
}
|
||||
|
||||
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||
|
||||
// Execute
|
||||
if (!empty($values)) {
|
||||
$results = $wpdb->get_results($wpdb->prepare($sql, $values));
|
||||
} else {
|
||||
$results = $wpdb->get_results($sql);
|
||||
}
|
||||
|
||||
// Normalize results to match MLS schema
|
||||
foreach ($results as $key => $property) {
|
||||
$results[$key] = $this->normalize_manual_property($property);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single property
|
||||
*
|
||||
* Checks manual properties first (for overrides), then MLS.
|
||||
*
|
||||
* @param string $identifier Listing key or listing ID
|
||||
* @param bool $skip_manual_override Skip manual property override (for A/B testing)
|
||||
* @return object|null Property object
|
||||
*/
|
||||
public function get_property($identifier) {
|
||||
public function get_property($identifier, $skip_manual_override = false) {
|
||||
global $wpdb;
|
||||
|
||||
// Check manual properties first (they can override MLS)
|
||||
if (!$skip_manual_override) {
|
||||
$manual = $this->get_manual_property($identifier);
|
||||
if ($manual) {
|
||||
return $manual;
|
||||
}
|
||||
}
|
||||
|
||||
$table = $this->db->properties_table();
|
||||
|
||||
// Try listing_key first
|
||||
@@ -435,18 +659,193 @@ class MLS_Query {
|
||||
));
|
||||
}
|
||||
|
||||
// If found by listing_id, check if there's a manual override
|
||||
if (!$skip_manual_override && $property && $property->listing_id) {
|
||||
$override_ids = $this->get_manual_override_listing_ids();
|
||||
if (in_array($property->listing_id, $override_ids)) {
|
||||
// Manual override exists, return the manual version
|
||||
return $this->get_manual_property($property->listing_id);
|
||||
}
|
||||
}
|
||||
|
||||
return $property;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a manual property by listing key or listing ID
|
||||
*
|
||||
* @param string $identifier Listing key (MANUAL-xxx) or listing_id
|
||||
* @return object|null Property object normalized to match MLS schema
|
||||
*/
|
||||
private function get_manual_property($identifier) {
|
||||
global $wpdb;
|
||||
|
||||
$table = $this->db->manual_properties_table();
|
||||
|
||||
// Check if table exists
|
||||
$table_exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
|
||||
DB_NAME,
|
||||
$table
|
||||
));
|
||||
|
||||
if (!$table_exists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try by listing_key first (MANUAL-xxx format)
|
||||
$property = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE listing_key = %s AND standard_status != 'Withdrawn'",
|
||||
$identifier
|
||||
));
|
||||
|
||||
if (!$property) {
|
||||
// Try by listing_id (MLS ID for overrides)
|
||||
$property = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE listing_id = %s AND standard_status != 'Withdrawn'",
|
||||
$identifier
|
||||
));
|
||||
}
|
||||
|
||||
if ($property) {
|
||||
// Normalize to match MLS schema
|
||||
$property = $this->normalize_manual_property($property);
|
||||
}
|
||||
|
||||
return $property;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a manual property to match MLS schema
|
||||
*
|
||||
* @param object $property Manual property row
|
||||
* @return object Normalized property
|
||||
*/
|
||||
private function normalize_manual_property($property) {
|
||||
global $wpdb;
|
||||
|
||||
// Set defaults for MLS-specific fields
|
||||
$property->originating_system = 'manual';
|
||||
$property->mls_status = null;
|
||||
$property->mlg_can_view = 1;
|
||||
$property->original_list_price = $property->original_list_price ?? $property->list_price;
|
||||
|
||||
// For manual properties linked to MLS, use the MLS status and days_on_market
|
||||
// If not in MLS (no listing_id or MLS listing not found), default to Closed
|
||||
$property->days_on_market = null; // Default to null (won't display)
|
||||
|
||||
if (!empty($property->listing_id)) {
|
||||
$mls_table = $this->db->properties_table();
|
||||
$mls_data = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT standard_status, days_on_market FROM {$mls_table} WHERE listing_id = %s AND mlg_can_view = 1",
|
||||
$property->listing_id
|
||||
));
|
||||
|
||||
if ($mls_data) {
|
||||
$property->standard_status = $mls_data->standard_status;
|
||||
$property->mls_status = $mls_data->standard_status;
|
||||
$property->days_on_market = $mls_data->days_on_market;
|
||||
} else {
|
||||
// MLS listing no longer exists - assume Closed
|
||||
$property->standard_status = 'Closed';
|
||||
}
|
||||
} else {
|
||||
// No MLS link - pure manual property, assume Closed
|
||||
$property->standard_status = 'Closed';
|
||||
}
|
||||
|
||||
// Get agent info from linked Agent CPT
|
||||
if (!empty($property->list_agent_post_id)) {
|
||||
$agent_name = get_the_title($property->list_agent_post_id);
|
||||
$agent_mls_id = get_field('mls_id', $property->list_agent_post_id);
|
||||
$property->list_agent_name = $agent_name;
|
||||
$property->list_agent_mls_id = $agent_mls_id;
|
||||
$property->list_agent_key = null;
|
||||
} else {
|
||||
$property->list_agent_name = null;
|
||||
$property->list_agent_mls_id = null;
|
||||
$property->list_agent_key = null;
|
||||
}
|
||||
|
||||
// Office info (manual listings are HomeProz)
|
||||
$property->list_office_key = null;
|
||||
$property->list_office_mls_id = $property->is_homeproz ? (defined('MLS_HOMEPROZ_OFFICE_ID') ? MLS_HOMEPROZ_OFFICE_ID : null) : null;
|
||||
$property->list_office_name = $property->is_homeproz ? 'HomeProz Real Estate' : null;
|
||||
|
||||
// Date fields
|
||||
$property->modification_timestamp = $property->updated_at;
|
||||
$property->photos_change_timestamp = $property->updated_at;
|
||||
$property->listing_contract_date = $property->list_date;
|
||||
// days_on_market is set above from MLS data (or null if not in MLS)
|
||||
$property->media_expires_at = null;
|
||||
$property->coordinates_invalid = 0;
|
||||
|
||||
// Location column for spatial queries (null for manual)
|
||||
$property->location = null;
|
||||
|
||||
// Raw data
|
||||
$property->raw_data = null;
|
||||
|
||||
// Mark as manual for frontend display logic
|
||||
$property->is_manual = 1;
|
||||
|
||||
return $property;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get listing IDs that have manual overrides
|
||||
*
|
||||
* @return array Array of MLS listing_id values
|
||||
*/
|
||||
private function get_manual_override_listing_ids() {
|
||||
global $wpdb;
|
||||
|
||||
// Cache result for this request
|
||||
static $override_ids = null;
|
||||
if ($override_ids !== null) {
|
||||
return $override_ids;
|
||||
}
|
||||
|
||||
$table = $this->db->manual_properties_table();
|
||||
|
||||
// Check if table exists
|
||||
$table_exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
|
||||
DB_NAME,
|
||||
$table
|
||||
));
|
||||
|
||||
if (!$table_exists) {
|
||||
$override_ids = array();
|
||||
return $override_ids;
|
||||
}
|
||||
|
||||
$override_ids = $wpdb->get_col(
|
||||
"SELECT listing_id FROM {$table}
|
||||
WHERE listing_id IS NOT NULL
|
||||
AND listing_id != ''
|
||||
AND standard_status != 'Withdrawn'"
|
||||
);
|
||||
|
||||
return array_filter($override_ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media for a property
|
||||
*
|
||||
* Handles both MLS and manual properties.
|
||||
*
|
||||
* @param string $listing_key Listing key
|
||||
* @return array Media objects
|
||||
*/
|
||||
public function get_property_media($listing_key) {
|
||||
global $wpdb;
|
||||
|
||||
// Check if this is a manual property
|
||||
if (strpos($listing_key, 'MANUAL-') === 0) {
|
||||
return MLS_Manual_Property::get_manual_property_images($listing_key);
|
||||
}
|
||||
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$this->db->media_table()}
|
||||
WHERE listing_key = %s
|
||||
@@ -540,15 +939,23 @@ class MLS_Query {
|
||||
/**
|
||||
* Get property count
|
||||
*
|
||||
* Counts both MLS and manual properties.
|
||||
*
|
||||
* @param array $args Filter arguments (same as get_properties)
|
||||
* @return int Count
|
||||
*/
|
||||
public function get_count($args = array()) {
|
||||
global $wpdb;
|
||||
|
||||
$include_manual = !isset($args['include_manual']) || $args['include_manual'];
|
||||
|
||||
// Get override IDs to exclude from MLS count
|
||||
$override_ids = $include_manual ? $this->get_manual_override_listing_ids() : array();
|
||||
|
||||
$table = $this->db->properties_table();
|
||||
|
||||
$where = array('mlg_can_view = 1');
|
||||
// Exclude properties with no price or price < 100 (invalid data)
|
||||
$where = array('mlg_can_view = 1', 'list_price >= 100');
|
||||
$values = array();
|
||||
|
||||
// Add state filter (MN and IA only)
|
||||
@@ -560,6 +967,12 @@ class MLS_Query {
|
||||
// Exclude TBD addresses
|
||||
$where[] = $this->get_tbd_exclusion_filter();
|
||||
|
||||
// Exclude MLS properties that have manual overrides
|
||||
if (!empty($override_ids)) {
|
||||
$placeholders = implode(',', array_fill(0, count($override_ids), '%s'));
|
||||
$where[] = $wpdb->prepare("listing_id NOT IN ({$placeholders})", $override_ids);
|
||||
}
|
||||
|
||||
if (!empty($args['status'])) {
|
||||
$where[] = 'standard_status = %s';
|
||||
$values[] = $args['status'];
|
||||
@@ -636,6 +1049,116 @@ class MLS_Query {
|
||||
|
||||
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
|
||||
|
||||
if (!empty($values)) {
|
||||
$mls_count = (int) $wpdb->get_var($wpdb->prepare($sql, $values));
|
||||
} else {
|
||||
$mls_count = (int) $wpdb->get_var($sql);
|
||||
}
|
||||
|
||||
// Add manual property count
|
||||
if ($include_manual) {
|
||||
$manual_count = $this->get_manual_count($args);
|
||||
return $mls_count + $manual_count;
|
||||
}
|
||||
|
||||
return $mls_count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get manual property count
|
||||
*
|
||||
* @param array $args Filter arguments
|
||||
* @return int Count
|
||||
*/
|
||||
private function get_manual_count($args) {
|
||||
global $wpdb;
|
||||
|
||||
$table = $this->db->manual_properties_table();
|
||||
|
||||
// Check if table exists
|
||||
$table_exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
|
||||
DB_NAME,
|
||||
$table
|
||||
));
|
||||
|
||||
if (!$table_exists) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$where = array("standard_status != 'Withdrawn'");
|
||||
$values = array();
|
||||
|
||||
if (!empty($args['status'])) {
|
||||
$where[] = 'standard_status = %s';
|
||||
$values[] = $args['status'];
|
||||
}
|
||||
|
||||
if (!empty($args['property_type'])) {
|
||||
$where[] = 'property_type = %s';
|
||||
$values[] = $args['property_type'];
|
||||
}
|
||||
|
||||
if (!empty($args['city'])) {
|
||||
$where[] = 'city = %s';
|
||||
$values[] = $args['city'];
|
||||
} elseif (!empty($args['postal_code'])) {
|
||||
$where[] = 'postal_code = %s';
|
||||
$values[] = $args['postal_code'];
|
||||
} elseif (!empty($args['center_lat']) && !empty($args['center_lng'])) {
|
||||
$lat = (float) $args['center_lat'];
|
||||
$lng = (float) $args['center_lng'];
|
||||
$radius = !empty($args['radius']) ? (int) $args['radius'] : 30;
|
||||
$lat_delta = $radius / 69.0;
|
||||
$lng_delta = $radius / (69.0 * cos(deg2rad($lat)));
|
||||
|
||||
$where[] = 'latitude BETWEEN %f AND %f';
|
||||
$where[] = 'longitude BETWEEN %f AND %f';
|
||||
$values[] = $lat - $lat_delta;
|
||||
$values[] = $lat + $lat_delta;
|
||||
$values[] = $lng - $lng_delta;
|
||||
$values[] = $lng + $lng_delta;
|
||||
}
|
||||
|
||||
if (!empty($args['county'])) {
|
||||
$where[] = 'county = %s';
|
||||
$values[] = $args['county'];
|
||||
}
|
||||
|
||||
if (!empty($args['min_price'])) {
|
||||
$where[] = 'list_price >= %d';
|
||||
$values[] = (int) $args['min_price'];
|
||||
}
|
||||
|
||||
if (!empty($args['max_price'])) {
|
||||
$where[] = 'list_price <= %d';
|
||||
$values[] = (int) $args['max_price'];
|
||||
}
|
||||
|
||||
if (!empty($args['min_beds'])) {
|
||||
$where[] = 'bedrooms_total >= %d';
|
||||
$values[] = (int) $args['min_beds'];
|
||||
}
|
||||
|
||||
if (!empty($args['min_baths'])) {
|
||||
$where[] = 'bathrooms_total >= %d';
|
||||
$values[] = (int) $args['min_baths'];
|
||||
}
|
||||
|
||||
if (!empty($args['bounds']) && is_array($args['bounds']) && count($args['bounds']) === 4) {
|
||||
list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds'];
|
||||
$where[] = 'latitude BETWEEN %f AND %f';
|
||||
$where[] = 'longitude BETWEEN %f AND %f';
|
||||
$where[] = 'latitude IS NOT NULL';
|
||||
$where[] = 'longitude IS NOT NULL';
|
||||
$values[] = (float) $sw_lat;
|
||||
$values[] = (float) $ne_lat;
|
||||
$values[] = (float) $sw_lng;
|
||||
$values[] = (float) $ne_lng;
|
||||
}
|
||||
|
||||
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
|
||||
|
||||
if (!empty($values)) {
|
||||
return (int) $wpdb->get_var($wpdb->prepare($sql, $values));
|
||||
}
|
||||
@@ -785,8 +1308,8 @@ class MLS_Query {
|
||||
|
||||
$table = $this->db->properties_table();
|
||||
|
||||
// Exclude properties with invalid coordinates from map bounds
|
||||
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL', 'coordinates_invalid = 0');
|
||||
// Exclude properties with invalid coordinates or invalid price from map bounds
|
||||
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL', 'coordinates_invalid = 0', 'list_price >= 100');
|
||||
$values = array();
|
||||
|
||||
// Add state filter (MN and IA only)
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
/**
|
||||
* Rate limiter class for MLS Grid API compliance
|
||||
*
|
||||
* MLS Grid Rate Limits:
|
||||
* - 2 requests per second
|
||||
* - 7,200 requests per hour
|
||||
* - 40,000 requests per day
|
||||
* - 4GB data per hour
|
||||
* MLS Grid Rate Limits (warning thresholds):
|
||||
* - 4 requests per second (suspension at 6)
|
||||
* - 7,200 requests per hour (suspension at 18,000)
|
||||
* - 40,000 requests per 24 hours (suspension at 60,000)
|
||||
* - 3GB data per hour / 40GB per 24 hours (suspension at 4GB/60GB)
|
||||
*
|
||||
* Our strategy: Throttle sync operations to use max 50% of daily quota
|
||||
* even if running continuously for 24 hours. This leaves 50% budget
|
||||
* for on-demand image fetches and other operations.
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
@@ -16,17 +20,36 @@ if (!defined('ABSPATH')) {
|
||||
class MLS_Rate_Limiter {
|
||||
|
||||
/**
|
||||
* Rate limit constants
|
||||
* MLS Grid absolute limits (for reference)
|
||||
*/
|
||||
const MLSGRID_LIMIT_PER_SECOND = 4;
|
||||
const MLSGRID_LIMIT_PER_HOUR = 7200;
|
||||
const MLSGRID_LIMIT_PER_DAY = 40000;
|
||||
const MLSGRID_BYTES_PER_HOUR = 3221225472; // 3GB
|
||||
const MLSGRID_BYTES_PER_DAY = 42949672960; // 40GB
|
||||
|
||||
/**
|
||||
* Sync operation limits (50% of daily quota paced over 24 hours)
|
||||
*
|
||||
* Goal: If sync ran continuously for 24h, use max 50% of daily quota
|
||||
* - 20,000 requests / 86,400 seconds = 0.23 RPS (~4.3s between requests)
|
||||
* - 20GB data / 86,400 seconds = ~243KB/s average
|
||||
*/
|
||||
const SYNC_REQUESTS_PER_DAY = 20000; // 50% of 40,000
|
||||
const SYNC_BYTES_PER_DAY = 21474836480; // 20GB (50% of 40GB)
|
||||
const SYNC_MIN_INTERVAL_MS = 4320; // 86400000ms / 20000 = 4.32s between requests
|
||||
|
||||
/**
|
||||
* Rate limit constants (used for tracking against MLS Grid limits)
|
||||
*/
|
||||
const LIMIT_PER_SECOND = 2;
|
||||
const LIMIT_PER_HOUR = 7200;
|
||||
const LIMIT_PER_DAY = 40000;
|
||||
const LIMIT_BYTES_PER_HOUR = 4294967296; // 4GB
|
||||
const LIMIT_BYTES_PER_HOUR = 3221225472; // 3GB
|
||||
const LIMIT_BYTES_PER_DAY = 42949672960; // 40GB
|
||||
|
||||
/**
|
||||
* Window types
|
||||
*/
|
||||
const WINDOW_SECOND = 'second';
|
||||
const WINDOW_HOUR = 'hour';
|
||||
const WINDOW_DAY = 'day';
|
||||
|
||||
@@ -52,14 +75,18 @@ class MLS_Rate_Limiter {
|
||||
/**
|
||||
* Check if we can make a request (and wait if needed)
|
||||
*
|
||||
* For sync operations, this enforces the 50% daily quota pacing.
|
||||
* The minimum interval between requests ensures that even continuous
|
||||
* syncing won't exceed 50% of the daily quota.
|
||||
*
|
||||
* @param bool $wait Whether to wait if rate limited
|
||||
* @return bool True if request can proceed
|
||||
*/
|
||||
public function check_and_wait($wait = true) {
|
||||
// Check per-second limit (most restrictive)
|
||||
$this->enforce_per_second_limit();
|
||||
// Enforce sync pacing (4.32s between requests for 50% daily quota)
|
||||
$this->enforce_sync_pacing();
|
||||
|
||||
// Check hourly limit
|
||||
// Check hourly limit (hard stop if approaching MLS Grid limits)
|
||||
if (!$this->check_limit(self::WINDOW_HOUR, self::LIMIT_PER_HOUR)) {
|
||||
if ($wait) {
|
||||
$this->wait_for_window(self::WINDOW_HOUR);
|
||||
@@ -68,7 +95,7 @@ class MLS_Rate_Limiter {
|
||||
}
|
||||
}
|
||||
|
||||
// Check daily limit
|
||||
// Check daily limit (hard stop if approaching MLS Grid limits)
|
||||
if (!$this->check_limit(self::WINDOW_DAY, self::LIMIT_PER_DAY)) {
|
||||
if ($wait) {
|
||||
$this->wait_for_window(self::WINDOW_DAY);
|
||||
@@ -81,11 +108,14 @@ class MLS_Rate_Limiter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce per-second rate limit
|
||||
* Enforce sync operation pacing
|
||||
*
|
||||
* Ensures minimum interval between sync requests so that
|
||||
* 24 hours of continuous syncing uses max 50% of daily quota.
|
||||
*/
|
||||
private function enforce_per_second_limit() {
|
||||
private function enforce_sync_pacing() {
|
||||
$now = microtime(true);
|
||||
$min_interval = 1.0 / self::LIMIT_PER_SECOND; // 0.5 seconds
|
||||
$min_interval = self::SYNC_MIN_INTERVAL_MS / 1000.0; // Convert ms to seconds (4.32s)
|
||||
|
||||
if ($this->last_request_time > 0) {
|
||||
$elapsed = $now - $this->last_request_time;
|
||||
@@ -141,9 +171,6 @@ class MLS_Rate_Limiter {
|
||||
$now = current_time('timestamp');
|
||||
|
||||
switch ($window_type) {
|
||||
case self::WINDOW_SECOND:
|
||||
return gmdate('Y-m-d H:i:s', $now);
|
||||
|
||||
case self::WINDOW_HOUR:
|
||||
return gmdate('Y-m-d H:00:00', $now);
|
||||
|
||||
@@ -261,6 +288,9 @@ class MLS_Rate_Limiter {
|
||||
* @return array Rate limit status
|
||||
*/
|
||||
public function get_status() {
|
||||
$bytes_hour = $this->get_bytes_this_hour();
|
||||
$bytes_day = $this->get_bytes_today();
|
||||
|
||||
return array(
|
||||
'hourly' => array(
|
||||
'used' => $this->get_window_count(self::WINDOW_HOUR),
|
||||
@@ -272,7 +302,18 @@ class MLS_Rate_Limiter {
|
||||
'limit' => self::LIMIT_PER_DAY,
|
||||
'remaining' => max(0, self::LIMIT_PER_DAY - $this->get_window_count(self::WINDOW_DAY)),
|
||||
),
|
||||
'bytes_this_hour' => $this->get_bytes_this_hour(),
|
||||
'data_hourly' => array(
|
||||
'used' => $bytes_hour,
|
||||
'limit' => self::LIMIT_BYTES_PER_HOUR,
|
||||
'remaining' => max(0, self::LIMIT_BYTES_PER_HOUR - $bytes_hour),
|
||||
),
|
||||
'data_daily' => array(
|
||||
'used' => $bytes_day,
|
||||
'limit' => self::LIMIT_BYTES_PER_DAY,
|
||||
'remaining' => max(0, self::LIMIT_BYTES_PER_DAY - $bytes_day),
|
||||
),
|
||||
// Legacy fields for backward compatibility
|
||||
'bytes_this_hour' => $bytes_hour,
|
||||
'bytes_limit' => self::LIMIT_BYTES_PER_HOUR,
|
||||
);
|
||||
}
|
||||
@@ -282,7 +323,7 @@ class MLS_Rate_Limiter {
|
||||
*
|
||||
* @return int Bytes
|
||||
*/
|
||||
private function get_bytes_this_hour() {
|
||||
public function get_bytes_this_hour() {
|
||||
global $wpdb;
|
||||
|
||||
$window_start = $this->get_window_start(self::WINDOW_HOUR);
|
||||
@@ -297,6 +338,103 @@ class MLS_Rate_Limiter {
|
||||
return $bytes ? (int) $bytes : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bytes transferred today
|
||||
*
|
||||
* @return int Bytes
|
||||
*/
|
||||
public function get_bytes_today() {
|
||||
global $wpdb;
|
||||
|
||||
$window_start = $this->get_window_start(self::WINDOW_DAY);
|
||||
|
||||
$bytes = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT bytes_transferred FROM {$this->db->rate_limits_table()}
|
||||
WHERE window_type = %s AND window_start = %s",
|
||||
self::WINDOW_DAY,
|
||||
$window_start
|
||||
));
|
||||
|
||||
return $bytes ? (int) $bytes : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining daily data budget
|
||||
*
|
||||
* @return int Remaining bytes
|
||||
*/
|
||||
public function get_daily_data_remaining() {
|
||||
return max(0, self::LIMIT_BYTES_PER_DAY - $this->get_bytes_today());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we can fetch an image based on remaining daily data budget
|
||||
*
|
||||
* @param int $estimated_bytes Estimated size of image (default 400KB)
|
||||
* @return bool True if we have budget for this image
|
||||
*/
|
||||
public function can_fetch_image($estimated_bytes = 409600) {
|
||||
return $this->get_daily_data_remaining() > $estimated_bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record data transfer (for image downloads, separate from API requests)
|
||||
*
|
||||
* This tracks bytes against the daily data cap without incrementing
|
||||
* the request count (since image fetches aren't API requests).
|
||||
*
|
||||
* @param int $bytes Bytes transferred
|
||||
*/
|
||||
public function record_data_transfer($bytes) {
|
||||
global $wpdb;
|
||||
|
||||
if ($bytes <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Record for hourly window (data only, no request count)
|
||||
$this->increment_data_only(self::WINDOW_HOUR, $bytes);
|
||||
|
||||
// Record for daily window (data only, no request count)
|
||||
$this->increment_data_only(self::WINDOW_DAY, $bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment data transfer for a window without incrementing request count
|
||||
*
|
||||
* @param string $window_type Window type
|
||||
* @param int $bytes Bytes transferred
|
||||
*/
|
||||
private function increment_data_only($window_type, $bytes) {
|
||||
global $wpdb;
|
||||
|
||||
$window_start = $this->get_window_start($window_type);
|
||||
|
||||
// Try to update existing record
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$this->db->rate_limits_table()}
|
||||
SET bytes_transferred = bytes_transferred + %d
|
||||
WHERE window_type = %s AND window_start = %s",
|
||||
$bytes,
|
||||
$window_type,
|
||||
$window_start
|
||||
));
|
||||
|
||||
// If no record existed, insert new one (request_count = 0 since this is data-only)
|
||||
if (0 === $updated) {
|
||||
$wpdb->insert(
|
||||
$this->db->rate_limits_table(),
|
||||
array(
|
||||
'window_type' => $window_type,
|
||||
'window_start' => $window_start,
|
||||
'request_count' => 0,
|
||||
'bytes_transferred' => $bytes,
|
||||
),
|
||||
array('%s', '%s', '%d', '%d')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're approaching rate limits
|
||||
*
|
||||
@@ -308,8 +446,26 @@ class MLS_Rate_Limiter {
|
||||
|
||||
$hourly_pct = $status['hourly']['used'] / $status['hourly']['limit'];
|
||||
$daily_pct = $status['daily']['used'] / $status['daily']['limit'];
|
||||
$data_daily_pct = $status['data_daily']['used'] / $status['data_daily']['limit'];
|
||||
|
||||
return $hourly_pct >= $threshold || $daily_pct >= $threshold;
|
||||
return $hourly_pct >= $threshold || $daily_pct >= $threshold || $data_daily_pct >= $threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of current usage for logging/display
|
||||
*
|
||||
* @return array Summary with percentages
|
||||
*/
|
||||
public function get_usage_summary() {
|
||||
$status = $this->get_status();
|
||||
|
||||
return array(
|
||||
'requests_hourly_pct' => round(($status['hourly']['used'] / $status['hourly']['limit']) * 100, 1),
|
||||
'requests_daily_pct' => round(($status['daily']['used'] / $status['daily']['limit']) * 100, 1),
|
||||
'data_hourly_pct' => round(($status['data_hourly']['used'] / $status['data_hourly']['limit']) * 100, 1),
|
||||
'data_daily_pct' => round(($status['data_daily']['used'] / $status['data_daily']['limit']) * 100, 1),
|
||||
'data_daily_remaining_gb' => round($status['data_daily']['remaining'] / 1073741824, 2),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,9 +41,140 @@ if ($near_me_mode) {
|
||||
$hero_bg = get_field('properties_hero_background', 'option');
|
||||
$has_bg_class = $hero_bg ? 'has-background' : '';
|
||||
$bg_style = $hero_bg ? 'style="background-image: url(' . esc_url($hero_bg) . ');"' : '';
|
||||
|
||||
// Get MLS property types and cities for mobile filters
|
||||
$property_types = homeproz_get_mls_property_types();
|
||||
$mls_cities = homeproz_get_mls_cities(50);
|
||||
$current_type = isset($_GET['property_type']) ? sanitize_text_field($_GET['property_type']) : '';
|
||||
$current_location = isset($_GET['city']) ? sanitize_text_field($_GET['city']) : '';
|
||||
$current_zip = isset($_GET['zip']) ? sanitize_text_field($_GET['zip']) : '';
|
||||
$current_min_price = isset($_GET['min_price']) ? intval($_GET['min_price']) : '';
|
||||
$current_max_price = isset($_GET['max_price']) ? intval($_GET['max_price']) : '';
|
||||
$current_beds = isset($_GET['beds']) ? intval($_GET['beds']) : '';
|
||||
?>
|
||||
|
||||
<!-- Mobile Map View (visible < 1024px) -->
|
||||
<div class="mobile-map-view" id="mobile-map-view">
|
||||
<!-- Full-screen map container -->
|
||||
<div id="mobile-property-map" class="mobile-map-container">
|
||||
<!-- Leaflet map initialized here by JS -->
|
||||
</div>
|
||||
|
||||
<!-- Bottom Sheet -->
|
||||
<div class="mobile-bottom-sheet" id="mobile-bottom-sheet" data-state="collapsed">
|
||||
<!-- Drag Handle -->
|
||||
<div class="sheet-drag-handle" id="sheet-drag-handle">
|
||||
<div class="sheet-handle-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- Sheet Header (always visible) -->
|
||||
<div class="sheet-header">
|
||||
<div class="sheet-property-count">
|
||||
<span id="mobile-property-count">0</span> properties
|
||||
</div>
|
||||
<button type="button" class="sheet-filter-toggle" id="sheet-filter-toggle">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="4" y1="6" x2="20" y2="6"/>
|
||||
<line x1="4" y1="12" x2="20" y2="12"/>
|
||||
<line x1="4" y1="18" x2="20" y2="18"/>
|
||||
<circle cx="8" cy="6" r="2" fill="currentColor"/>
|
||||
<circle cx="16" cy="12" r="2" fill="currentColor"/>
|
||||
<circle cx="10" cy="18" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sheet Content (scrollable) -->
|
||||
<div class="sheet-content" id="sheet-content">
|
||||
<!-- Filters Section (collapsible) -->
|
||||
<div class="sheet-filters" id="sheet-filters">
|
||||
<div class="sheet-filters-grid">
|
||||
<div class="sheet-filter-item">
|
||||
<label for="mobile-filter-type">Type</label>
|
||||
<select name="property_type" id="mobile-filter-type" class="sheet-filter-select">
|
||||
<option value="">All Types</option>
|
||||
<?php foreach ($property_types as $type) : ?>
|
||||
<option value="<?php echo esc_attr($type->property_type); ?>" <?php selected($current_type, $type->property_type); ?>>
|
||||
<?php echo esc_html($type->property_type); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sheet-filter-item">
|
||||
<label for="mobile-filter-city">City</label>
|
||||
<select name="city" id="mobile-filter-city" class="sheet-filter-select">
|
||||
<option value="">All Cities</option>
|
||||
<?php foreach ($mls_cities as $city_obj) :
|
||||
$city_label = $city_obj->city . ', ' . $city_obj->state_code;
|
||||
?>
|
||||
<option value="<?php echo esc_attr($city_label); ?>" <?php selected($current_location, $city_label); ?>>
|
||||
<?php echo esc_html($city_label); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sheet-filter-item">
|
||||
<label for="mobile-filter-beds">Beds</label>
|
||||
<select name="beds" id="mobile-filter-beds" class="sheet-filter-select">
|
||||
<option value="">Any</option>
|
||||
<option value="1" <?php selected($current_beds, 1); ?>>1+</option>
|
||||
<option value="2" <?php selected($current_beds, 2); ?>>2+</option>
|
||||
<option value="3" <?php selected($current_beds, 3); ?>>3+</option>
|
||||
<option value="4" <?php selected($current_beds, 4); ?>>4+</option>
|
||||
<option value="5" <?php selected($current_beds, 5); ?>>5+</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sheet-filter-item">
|
||||
<label for="mobile-filter-min-price">Min Price</label>
|
||||
<select name="min_price" id="mobile-filter-min-price" class="sheet-filter-select">
|
||||
<option value="">No Min</option>
|
||||
<option value="50000" <?php selected($current_min_price, 50000); ?>>$50k</option>
|
||||
<option value="100000" <?php selected($current_min_price, 100000); ?>>$100k</option>
|
||||
<option value="150000" <?php selected($current_min_price, 150000); ?>>$150k</option>
|
||||
<option value="200000" <?php selected($current_min_price, 200000); ?>>$200k</option>
|
||||
<option value="250000" <?php selected($current_min_price, 250000); ?>>$250k</option>
|
||||
<option value="300000" <?php selected($current_min_price, 300000); ?>>$300k</option>
|
||||
<option value="400000" <?php selected($current_min_price, 400000); ?>>$400k</option>
|
||||
<option value="500000" <?php selected($current_min_price, 500000); ?>>$500k</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sheet-filter-item">
|
||||
<label for="mobile-filter-max-price">Max Price</label>
|
||||
<select name="max_price" id="mobile-filter-max-price" class="sheet-filter-select">
|
||||
<option value="">No Max</option>
|
||||
<option value="100000" <?php selected($current_max_price, 100000); ?>>$100k</option>
|
||||
<option value="150000" <?php selected($current_max_price, 150000); ?>>$150k</option>
|
||||
<option value="200000" <?php selected($current_max_price, 200000); ?>>$200k</option>
|
||||
<option value="250000" <?php selected($current_max_price, 250000); ?>>$250k</option>
|
||||
<option value="300000" <?php selected($current_max_price, 300000); ?>>$300k</option>
|
||||
<option value="400000" <?php selected($current_max_price, 400000); ?>>$400k</option>
|
||||
<option value="500000" <?php selected($current_max_price, 500000); ?>>$500k</option>
|
||||
<option value="750000" <?php selected($current_max_price, 750000); ?>>$750k</option>
|
||||
<option value="1000000" <?php selected($current_max_price, 1000000); ?>>$1M+</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sheet-filter-item sheet-filter-reset">
|
||||
<a href="<?php echo esc_url(get_post_type_archive_link('property')); ?>" class="btn btn-secondary btn-sm">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Property List -->
|
||||
<div class="sheet-property-list" id="sheet-property-list">
|
||||
<!-- Properties loaded via AJAX -->
|
||||
<div class="sheet-property-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading properties...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Layout (visible >= 1024px) -->
|
||||
<!-- Archive Hero -->
|
||||
<section class="archive-hero <?php echo esc_attr($has_bg_class); ?>" <?php echo $bg_style; ?>>
|
||||
<section class="archive-hero desktop-only <?php echo esc_attr($has_bg_class); ?>" <?php echo $bg_style; ?>>
|
||||
<?php if ($hero_bg) : ?>
|
||||
<div class="archive-hero-overlay"></div>
|
||||
<?php endif; ?>
|
||||
@@ -71,7 +202,7 @@ if ($near_me_mode) {
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="container">
|
||||
<div class="container desktop-only">
|
||||
<!-- Filters -->
|
||||
<?php get_template_part('template-parts/property/property-filters'); ?>
|
||||
|
||||
|
||||
+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
@@ -47,6 +47,12 @@ require_once HOMEPROZ_DIR . '/inc/schema-markup.php';
|
||||
// Contact Form 7 hooks (agent email routing)
|
||||
require_once HOMEPROZ_DIR . '/inc/wpcf7-hooks.php';
|
||||
|
||||
// Yoast SEO customizations (sitemap, meta)
|
||||
require_once HOMEPROZ_DIR . '/inc/yoast-seo.php';
|
||||
|
||||
// Favicon management
|
||||
require_once HOMEPROZ_DIR . '/inc/favicon.php';
|
||||
|
||||
/**
|
||||
* Send no-cache headers for HTML pages
|
||||
* Prevents browser/proxy caching of dynamic content
|
||||
|
||||
@@ -625,6 +625,62 @@ function homeproz_register_acf_fields() {
|
||||
'instructions' => 'Copyright text shown in footer. Year is added automatically.',
|
||||
'default_value' => 'HomeProz Real Estate LLC. All rights reserved.',
|
||||
),
|
||||
|
||||
// Branding Tab
|
||||
array(
|
||||
'key' => 'field_theme_tab_branding',
|
||||
'label' => 'Branding',
|
||||
'name' => '',
|
||||
'type' => 'tab',
|
||||
'placement' => 'left',
|
||||
),
|
||||
array(
|
||||
'key' => 'field_theme_favicon_source',
|
||||
'label' => 'Favicon Source Image',
|
||||
'name' => 'theme_favicon_source',
|
||||
'type' => 'image',
|
||||
'instructions' => 'Upload a square image (PNG or WebP) at least 512x512 pixels. This will be automatically converted to all required favicon sizes. After saving, favicons are generated and stored in /wp-content/uploads/favicon/.',
|
||||
'required' => 0,
|
||||
'return_format' => 'id',
|
||||
'preview_size' => 'thumbnail',
|
||||
'library' => 'all',
|
||||
'mime_types' => 'png, webp',
|
||||
'min_width' => 256,
|
||||
'min_height' => 256,
|
||||
),
|
||||
array(
|
||||
'key' => 'field_theme_favicon_status',
|
||||
'label' => 'Favicon Status',
|
||||
'name' => 'theme_favicon_status',
|
||||
'type' => 'message',
|
||||
'message' => 'Save the page after uploading a new favicon source to generate all favicon sizes.',
|
||||
'new_lines' => 'wpautop',
|
||||
),
|
||||
|
||||
// Advanced Tab
|
||||
array(
|
||||
'key' => 'field_theme_tab_advanced',
|
||||
'label' => 'Advanced',
|
||||
'name' => '',
|
||||
'type' => 'tab',
|
||||
'placement' => 'left',
|
||||
),
|
||||
array(
|
||||
'key' => 'field_theme_header_scripts',
|
||||
'label' => 'Header Scripts',
|
||||
'name' => 'theme_header_scripts',
|
||||
'type' => 'textarea',
|
||||
'instructions' => 'Add tracking scripts (Google Analytics, Meta Pixel, etc.) to be included in the page header. You must include the complete code including <script></script> tags. This content is output exactly as entered.',
|
||||
'rows' => 10,
|
||||
'placeholder' => '<!-- Example: Google Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag(\'js\', new Date());
|
||||
gtag(\'config\', \'G-XXXXXXXXXX\');
|
||||
</script>',
|
||||
),
|
||||
),
|
||||
'location' => array(
|
||||
array(
|
||||
@@ -1380,6 +1436,52 @@ function homeproz_register_acf_fields() {
|
||||
),
|
||||
),
|
||||
|
||||
// Testimonials Tab
|
||||
array(
|
||||
'key' => 'field_agent_tab_testimonials',
|
||||
'label' => 'Testimonials',
|
||||
'name' => '',
|
||||
'type' => 'tab',
|
||||
'placement' => 'top',
|
||||
),
|
||||
array(
|
||||
'key' => 'field_agent_testimonials',
|
||||
'label' => 'Client Testimonials',
|
||||
'name' => 'agent_testimonials',
|
||||
'type' => 'repeater',
|
||||
'instructions' => 'Add testimonials from clients who have worked with this agent.',
|
||||
'min' => 0,
|
||||
'max' => 20,
|
||||
'layout' => 'block',
|
||||
'button_label' => 'Add Testimonial',
|
||||
'sub_fields' => array(
|
||||
array(
|
||||
'key' => 'field_testimonial_quote',
|
||||
'label' => 'Quote',
|
||||
'name' => 'quote',
|
||||
'type' => 'textarea',
|
||||
'required' => 1,
|
||||
'instructions' => 'The testimonial text from the client.',
|
||||
'rows' => 4,
|
||||
),
|
||||
array(
|
||||
'key' => 'field_testimonial_client_name',
|
||||
'label' => 'Client Name',
|
||||
'name' => 'client_name',
|
||||
'type' => 'text',
|
||||
'required' => 1,
|
||||
'instructions' => 'Client\'s name (e.g., "John D." or "John Doe")',
|
||||
),
|
||||
array(
|
||||
'key' => 'field_testimonial_context',
|
||||
'label' => 'Context',
|
||||
'name' => 'context',
|
||||
'type' => 'text',
|
||||
'instructions' => 'Optional context (e.g., "Albert Lea Buyer", "First-time Homeowner")',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Settings Tab
|
||||
array(
|
||||
'key' => 'field_agent_tab_settings',
|
||||
@@ -1925,6 +2027,69 @@ function homeproz_register_acf_fields() {
|
||||
'position' => 'normal',
|
||||
'active' => true,
|
||||
));
|
||||
|
||||
// City Landing Page Field Group
|
||||
acf_add_local_field_group(array(
|
||||
'key' => 'group_city_landing',
|
||||
'title' => 'City Landing Page Settings',
|
||||
'fields' => array(
|
||||
array(
|
||||
'key' => 'field_city_name',
|
||||
'label' => 'City Name',
|
||||
'name' => 'city_name',
|
||||
'type' => 'text',
|
||||
'instructions' => 'Enter the city name exactly as it appears in MLS data. Leave blank to use the page title.',
|
||||
),
|
||||
array(
|
||||
'key' => 'field_city_intro_content',
|
||||
'label' => 'Introduction Content',
|
||||
'name' => 'city_intro_content',
|
||||
'type' => 'wysiwyg',
|
||||
'instructions' => 'Custom content about this city/area. If left empty, the page content will be used.',
|
||||
'tabs' => 'all',
|
||||
'toolbar' => 'full',
|
||||
'media_upload' => 1,
|
||||
),
|
||||
array(
|
||||
'key' => 'field_city_listings_heading',
|
||||
'label' => 'Listings Section Heading',
|
||||
'name' => 'listings_section_heading',
|
||||
'type' => 'text',
|
||||
'instructions' => 'Leave blank for default: "Homes for Sale in [City]"',
|
||||
),
|
||||
array(
|
||||
'key' => 'field_city_max_listings',
|
||||
'label' => 'Maximum Listings to Show',
|
||||
'name' => 'max_listings_to_show',
|
||||
'type' => 'number',
|
||||
'instructions' => 'How many listings to display on this page',
|
||||
'default_value' => 8,
|
||||
'min' => 1,
|
||||
'max' => 20,
|
||||
),
|
||||
array(
|
||||
'key' => 'field_city_show_all_text',
|
||||
'label' => 'Show All Button Text',
|
||||
'name' => 'show_all_button_text',
|
||||
'type' => 'text',
|
||||
'instructions' => 'Leave blank for default: "View All [City] Listings"',
|
||||
),
|
||||
),
|
||||
'location' => array(
|
||||
array(
|
||||
array(
|
||||
'param' => 'page_template',
|
||||
'operator' => '==',
|
||||
'value' => 'page-city-landing.php',
|
||||
),
|
||||
),
|
||||
),
|
||||
'menu_order' => 0,
|
||||
'position' => 'normal',
|
||||
'style' => 'default',
|
||||
'label_placement' => 'top',
|
||||
'active' => true,
|
||||
));
|
||||
}
|
||||
add_action('acf/init', 'homeproz_register_acf_fields');
|
||||
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
/**
|
||||
* Favicon Management
|
||||
*
|
||||
* Handles favicon generation from uploaded source image using ImageMagick.
|
||||
* Generates all required sizes and outputs appropriate HTML.
|
||||
*
|
||||
* @package HomeProz
|
||||
*/
|
||||
|
||||
// Prevent direct access
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the favicon directory path and URL
|
||||
*/
|
||||
function homeproz_get_favicon_paths() {
|
||||
$upload_dir = wp_upload_dir();
|
||||
return array(
|
||||
'dir' => $upload_dir['basedir'] . '/favicon',
|
||||
'url' => $upload_dir['baseurl'] . '/favicon',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process favicon when Theme Options are saved
|
||||
*/
|
||||
add_action('acf/save_post', 'homeproz_process_favicon', 20);
|
||||
function homeproz_process_favicon($post_id) {
|
||||
// Only process on options page
|
||||
if ($post_id !== 'options') {
|
||||
return;
|
||||
}
|
||||
|
||||
$favicon_source_id = get_field('theme_favicon_source', 'option');
|
||||
|
||||
if (!$favicon_source_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the source image path
|
||||
$source_path = get_attached_file($favicon_source_id);
|
||||
|
||||
if (!$source_path || !file_exists($source_path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we need to regenerate (compare source modification time)
|
||||
$paths = homeproz_get_favicon_paths();
|
||||
$version_file = $paths['dir'] . '/.version';
|
||||
$source_mtime = filemtime($source_path);
|
||||
|
||||
if (file_exists($version_file)) {
|
||||
$stored_version = file_get_contents($version_file);
|
||||
if ($stored_version === $favicon_source_id . ':' . $source_mtime) {
|
||||
// Already processed this version
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate favicons
|
||||
$result = homeproz_generate_favicons($source_path);
|
||||
|
||||
if ($result) {
|
||||
// Store version to avoid re-processing
|
||||
file_put_contents($version_file, $favicon_source_id . ':' . $source_mtime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all favicon sizes using ImageMagick
|
||||
*/
|
||||
function homeproz_generate_favicons($source_path) {
|
||||
$paths = homeproz_get_favicon_paths();
|
||||
$favicon_dir = $paths['dir'];
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!file_exists($favicon_dir)) {
|
||||
wp_mkdir_p($favicon_dir);
|
||||
}
|
||||
|
||||
// Check if ImageMagick is available
|
||||
$convert_path = trim(shell_exec('which convert 2>/dev/null'));
|
||||
if (empty($convert_path)) {
|
||||
error_log('HomeProz Favicon: ImageMagick convert command not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify source image dimensions
|
||||
$image_info = getimagesize($source_path);
|
||||
if (!$image_info || $image_info[0] < 256 || $image_info[1] < 256) {
|
||||
error_log('HomeProz Favicon: Source image must be at least 256x256 pixels');
|
||||
return false;
|
||||
}
|
||||
|
||||
$source_escaped = escapeshellarg($source_path);
|
||||
|
||||
// Define all the sizes we need to generate
|
||||
$png_sizes = array(
|
||||
'favicon-16x16.png' => 16,
|
||||
'favicon-32x32.png' => 32,
|
||||
'favicon-48x48.png' => 48,
|
||||
'apple-touch-icon.png' => 180,
|
||||
'android-chrome-192x192.png' => 192,
|
||||
'android-chrome-512x512.png' => 512,
|
||||
'mstile-150x150.png' => 150,
|
||||
);
|
||||
|
||||
$success = true;
|
||||
|
||||
// Generate PNG files
|
||||
foreach ($png_sizes as $filename => $size) {
|
||||
$output_path = $favicon_dir . '/' . $filename;
|
||||
$output_escaped = escapeshellarg($output_path);
|
||||
|
||||
// Use ImageMagick to resize with high quality
|
||||
$cmd = sprintf(
|
||||
'%s %s -resize %dx%d -gravity center -background transparent -extent %dx%d %s 2>&1',
|
||||
escapeshellarg($convert_path),
|
||||
$source_escaped,
|
||||
$size,
|
||||
$size,
|
||||
$size,
|
||||
$size,
|
||||
$output_escaped
|
||||
);
|
||||
|
||||
exec($cmd, $output, $return_var);
|
||||
|
||||
if ($return_var !== 0) {
|
||||
error_log('HomeProz Favicon: Failed to generate ' . $filename . ': ' . implode("\n", $output));
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate favicon.ico (multi-resolution ICO file)
|
||||
$ico_path = $favicon_dir . '/favicon.ico';
|
||||
$ico_escaped = escapeshellarg($ico_path);
|
||||
|
||||
// Create temporary files for ICO sizes
|
||||
$temp_16 = $favicon_dir . '/temp-16.png';
|
||||
$temp_32 = $favicon_dir . '/temp-32.png';
|
||||
$temp_48 = $favicon_dir . '/temp-48.png';
|
||||
|
||||
// Generate temp files
|
||||
foreach (array(16 => $temp_16, 32 => $temp_32, 48 => $temp_48) as $size => $temp_path) {
|
||||
$cmd = sprintf(
|
||||
'%s %s -resize %dx%d -gravity center -background transparent -extent %dx%d %s 2>&1',
|
||||
escapeshellarg($convert_path),
|
||||
$source_escaped,
|
||||
$size,
|
||||
$size,
|
||||
$size,
|
||||
$size,
|
||||
escapeshellarg($temp_path)
|
||||
);
|
||||
exec($cmd, $output, $return_var);
|
||||
}
|
||||
|
||||
// Combine into ICO
|
||||
$cmd = sprintf(
|
||||
'%s %s %s %s %s 2>&1',
|
||||
escapeshellarg($convert_path),
|
||||
escapeshellarg($temp_16),
|
||||
escapeshellarg($temp_32),
|
||||
escapeshellarg($temp_48),
|
||||
$ico_escaped
|
||||
);
|
||||
exec($cmd, $output, $return_var);
|
||||
|
||||
// Clean up temp files
|
||||
@unlink($temp_16);
|
||||
@unlink($temp_32);
|
||||
@unlink($temp_48);
|
||||
|
||||
if ($return_var !== 0) {
|
||||
error_log('HomeProz Favicon: Failed to generate favicon.ico: ' . implode("\n", $output));
|
||||
$success = false;
|
||||
}
|
||||
|
||||
// Generate web manifest
|
||||
homeproz_generate_web_manifest($favicon_dir);
|
||||
|
||||
// Generate browserconfig.xml for Windows
|
||||
homeproz_generate_browserconfig($favicon_dir);
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate site.webmanifest file
|
||||
*/
|
||||
function homeproz_generate_web_manifest($favicon_dir) {
|
||||
$paths = homeproz_get_favicon_paths();
|
||||
|
||||
$manifest = array(
|
||||
'name' => get_bloginfo('name'),
|
||||
'short_name' => 'HomeProz',
|
||||
'icons' => array(
|
||||
array(
|
||||
'src' => $paths['url'] . '/android-chrome-192x192.png',
|
||||
'sizes' => '192x192',
|
||||
'type' => 'image/png',
|
||||
),
|
||||
array(
|
||||
'src' => $paths['url'] . '/android-chrome-512x512.png',
|
||||
'sizes' => '512x512',
|
||||
'type' => 'image/png',
|
||||
),
|
||||
),
|
||||
'theme_color' => '#0A0A0A',
|
||||
'background_color' => '#0A0A0A',
|
||||
'display' => 'standalone',
|
||||
);
|
||||
|
||||
$manifest_path = $favicon_dir . '/site.webmanifest';
|
||||
file_put_contents($manifest_path, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate browserconfig.xml for Windows tiles
|
||||
*/
|
||||
function homeproz_generate_browserconfig($favicon_dir) {
|
||||
$paths = homeproz_get_favicon_paths();
|
||||
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="' . esc_url($paths['url'] . '/mstile-150x150.png') . '"/>
|
||||
<TileColor>#0A0A0A</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>';
|
||||
|
||||
$config_path = $favicon_dir . '/browserconfig.xml';
|
||||
file_put_contents($config_path, $xml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output favicon HTML in head
|
||||
*/
|
||||
add_action('wp_head', 'homeproz_output_favicon_html', 2);
|
||||
function homeproz_output_favicon_html() {
|
||||
$favicon_source_id = get_field('theme_favicon_source', 'option');
|
||||
|
||||
if (!$favicon_source_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$paths = homeproz_get_favicon_paths();
|
||||
$favicon_dir = $paths['dir'];
|
||||
$favicon_url = $paths['url'];
|
||||
|
||||
// Check if favicons exist
|
||||
if (!file_exists($favicon_dir . '/favicon.ico')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get version for cache busting (use directory modification time)
|
||||
$version = filemtime($favicon_dir . '/favicon.ico');
|
||||
|
||||
echo "\n<!-- Favicons -->\n";
|
||||
|
||||
// Standard favicons
|
||||
echo '<link rel="icon" type="image/x-icon" href="' . esc_url($favicon_url . '/favicon.ico') . '?v=' . $version . '">' . "\n";
|
||||
echo '<link rel="icon" type="image/png" sizes="16x16" href="' . esc_url($favicon_url . '/favicon-16x16.png') . '?v=' . $version . '">' . "\n";
|
||||
echo '<link rel="icon" type="image/png" sizes="32x32" href="' . esc_url($favicon_url . '/favicon-32x32.png') . '?v=' . $version . '">' . "\n";
|
||||
echo '<link rel="icon" type="image/png" sizes="48x48" href="' . esc_url($favicon_url . '/favicon-48x48.png') . '?v=' . $version . '">' . "\n";
|
||||
|
||||
// Apple Touch Icon
|
||||
echo '<link rel="apple-touch-icon" sizes="180x180" href="' . esc_url($favicon_url . '/apple-touch-icon.png') . '?v=' . $version . '">' . "\n";
|
||||
|
||||
// Android/Chrome
|
||||
echo '<link rel="manifest" href="' . esc_url($favicon_url . '/site.webmanifest') . '?v=' . $version . '">' . "\n";
|
||||
|
||||
// Microsoft
|
||||
echo '<meta name="msapplication-TileImage" content="' . esc_url($favicon_url . '/mstile-150x150.png') . '?v=' . $version . '">' . "\n";
|
||||
echo '<meta name="msapplication-config" content="' . esc_url($favicon_url . '/browserconfig.xml') . '?v=' . $version . '">' . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable WordPress Site Icon from Customizer to avoid conflicts
|
||||
*/
|
||||
add_action('customize_register', 'homeproz_disable_site_icon', 20);
|
||||
function homeproz_disable_site_icon($wp_customize) {
|
||||
// Remove the site icon control
|
||||
$wp_customize->remove_control('site_icon');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove default WordPress site icon output
|
||||
*/
|
||||
add_action('init', 'homeproz_remove_default_site_icon');
|
||||
function homeproz_remove_default_site_icon() {
|
||||
// Remove site icon from wp_head
|
||||
remove_action('wp_head', 'wp_site_icon', 99);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter to disable site icon in REST API responses
|
||||
*/
|
||||
add_filter('get_site_icon_url', 'homeproz_filter_site_icon_url', 10, 3);
|
||||
function homeproz_filter_site_icon_url($url, $size, $blog_id) {
|
||||
$favicon_source_id = get_field('theme_favicon_source', 'option');
|
||||
|
||||
if ($favicon_source_id) {
|
||||
$paths = homeproz_get_favicon_paths();
|
||||
|
||||
// Return appropriate size
|
||||
if ($size <= 16) {
|
||||
return $paths['url'] . '/favicon-16x16.png';
|
||||
} elseif ($size <= 32) {
|
||||
return $paths['url'] . '/favicon-32x32.png';
|
||||
} elseif ($size <= 48) {
|
||||
return $paths['url'] . '/favicon-48x48.png';
|
||||
} elseif ($size <= 150) {
|
||||
return $paths['url'] . '/mstile-150x150.png';
|
||||
} elseif ($size <= 180) {
|
||||
return $paths['url'] . '/apple-touch-icon.png';
|
||||
} elseif ($size <= 192) {
|
||||
return $paths['url'] . '/android-chrome-192x192.png';
|
||||
} else {
|
||||
return $paths['url'] . '/android-chrome-512x512.png';
|
||||
}
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin notice if ImageMagick is not available
|
||||
*/
|
||||
add_action('admin_notices', 'homeproz_favicon_admin_notice');
|
||||
function homeproz_favicon_admin_notice() {
|
||||
// Only show on theme options page
|
||||
$screen = get_current_screen();
|
||||
if (!$screen || $screen->id !== 'toplevel_page_theme-options') {
|
||||
return;
|
||||
}
|
||||
|
||||
$convert_path = trim(shell_exec('which convert 2>/dev/null'));
|
||||
if (empty($convert_path)) {
|
||||
echo '<div class="notice notice-error"><p><strong>Favicon Generation:</strong> ImageMagick is not installed on this server. Favicon generation will not work until ImageMagick is available.</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force regenerate favicons (can be called manually or via WP-CLI)
|
||||
*/
|
||||
function homeproz_regenerate_favicons() {
|
||||
$favicon_source_id = get_field('theme_favicon_source', 'option');
|
||||
|
||||
if (!$favicon_source_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$source_path = get_attached_file($favicon_source_id);
|
||||
|
||||
if (!$source_path || !file_exists($source_path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete version file to force regeneration
|
||||
$paths = homeproz_get_favicon_paths();
|
||||
@unlink($paths['dir'] . '/.version');
|
||||
|
||||
return homeproz_generate_favicons($source_path);
|
||||
}
|
||||
@@ -43,6 +43,14 @@ function homeproz_get_page_class() {
|
||||
return 'About_Page';
|
||||
}
|
||||
|
||||
if (is_page_template('page-team.php') || is_page('team')) {
|
||||
return 'Team_Page';
|
||||
}
|
||||
|
||||
if (is_page_template('page-results.php') || is_page('results')) {
|
||||
return 'Results_Page';
|
||||
}
|
||||
|
||||
if (is_page_template('page-contact.php') || is_page('contact')) {
|
||||
return 'Contact_Page';
|
||||
}
|
||||
@@ -106,27 +114,6 @@ function homeproz_excerpt_more($more) {
|
||||
}
|
||||
add_filter('excerpt_more', 'homeproz_excerpt_more');
|
||||
|
||||
/**
|
||||
* Get property status badge class
|
||||
*
|
||||
* @param string $status The property status
|
||||
* @return string CSS class for the badge
|
||||
*/
|
||||
function homeproz_get_status_class($status) {
|
||||
$status = strtolower($status);
|
||||
|
||||
switch ($status) {
|
||||
case 'active':
|
||||
return 'badge-success';
|
||||
case 'pending':
|
||||
return 'badge-warning';
|
||||
case 'sold':
|
||||
return 'badge-muted';
|
||||
default:
|
||||
return 'badge-default';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price for display
|
||||
*
|
||||
@@ -250,38 +237,30 @@ function homeproz_get_featured_mls_listings($count = 3) {
|
||||
$added_keys = array();
|
||||
$listings = array();
|
||||
|
||||
// 1. Get featured MLS IDs from the override system (FIRST PRIORITY)
|
||||
$featured_mls_ids = function_exists('homeproz_get_featured_mls_ids')
|
||||
? homeproz_get_featured_mls_ids()
|
||||
: array();
|
||||
// 1. Get featured properties (is_featured = 1 in MLS database)
|
||||
$type_placeholders = implode(',', array_fill(0, count($residential_types), '%s'));
|
||||
$featured_query = $wpdb->prepare(
|
||||
"SELECT listing_key, listing_id, list_price, street_number, street_name, street_suffix,
|
||||
city, state_or_province, postal_code, bedrooms_total, bathrooms_total,
|
||||
living_area, standard_status, property_type, photos_count
|
||||
FROM {$table}
|
||||
WHERE is_featured = 1
|
||||
AND property_type IN ({$type_placeholders})
|
||||
AND standard_status = 'Active'
|
||||
AND mlg_can_view = 1
|
||||
AND photos_count > 0
|
||||
ORDER BY modification_timestamp DESC",
|
||||
...$residential_types
|
||||
);
|
||||
$featured_listings = $wpdb->get_results($featured_query);
|
||||
|
||||
// Add featured residential listings first
|
||||
if (!empty($featured_mls_ids)) {
|
||||
$id_placeholders = implode(',', array_fill(0, count($featured_mls_ids), '%s'));
|
||||
$type_placeholders = implode(',', array_fill(0, count($residential_types), '%s'));
|
||||
$featured_query = $wpdb->prepare(
|
||||
"SELECT listing_key, listing_id, list_price, street_number, street_name, street_suffix,
|
||||
city, state_or_province, postal_code, bedrooms_total, bathrooms_total,
|
||||
living_area, standard_status, property_type, photos_count
|
||||
FROM {$table}
|
||||
WHERE listing_id IN ({$id_placeholders})
|
||||
AND property_type IN ({$type_placeholders})
|
||||
AND standard_status = 'Active'
|
||||
AND mlg_can_view = 1
|
||||
AND photos_count > 0
|
||||
ORDER BY modification_timestamp DESC",
|
||||
...array_merge($featured_mls_ids, $residential_types)
|
||||
);
|
||||
$featured_listings = $wpdb->get_results($featured_query);
|
||||
|
||||
foreach ($featured_listings as $listing) {
|
||||
if (count($listings) >= $count) break;
|
||||
$listings[] = homeproz_format_mls_listing_for_json($listing, false);
|
||||
$added_keys[] = $listing->listing_key;
|
||||
}
|
||||
foreach ($featured_listings as $listing) {
|
||||
if (count($listings) >= $count) break;
|
||||
$listings[] = homeproz_format_mls_listing_for_json($listing, false);
|
||||
$added_keys[] = $listing->listing_key;
|
||||
}
|
||||
|
||||
// 2. Add HomeProz residential listings (if we need more)
|
||||
// 2. Add HomeProz Active residential listings (if we need more)
|
||||
if (count($listings) < $count) {
|
||||
$type_placeholders = implode(',', array_fill(0, count($residential_types), '%s'));
|
||||
$exclude_clause = '';
|
||||
@@ -315,7 +294,41 @@ function homeproz_get_featured_mls_listings($count = 3) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fill remaining slots with random residential listings
|
||||
// 3. Add HomeProz Pending residential listings (if we need more)
|
||||
if (count($listings) < $count) {
|
||||
$type_placeholders = implode(',', array_fill(0, count($residential_types), '%s'));
|
||||
$exclude_clause = '';
|
||||
if (!empty($added_keys)) {
|
||||
$key_placeholders = implode(',', array_fill(0, count($added_keys), '%s'));
|
||||
$exclude_clause = $wpdb->prepare(" AND listing_key NOT IN ({$key_placeholders})", ...$added_keys);
|
||||
}
|
||||
|
||||
$homeproz_pending_query = $wpdb->prepare(
|
||||
"SELECT listing_key, listing_id, list_price, street_number, street_name, street_suffix,
|
||||
city, state_or_province, postal_code, bedrooms_total, bathrooms_total,
|
||||
living_area, standard_status, property_type, photos_count
|
||||
FROM {$table}
|
||||
WHERE is_homeproz = 1
|
||||
AND property_type IN ({$type_placeholders})
|
||||
AND standard_status = 'Pending'
|
||||
AND mlg_can_view = 1
|
||||
AND COALESCE(street_name, '') NOT REGEXP '\\\\bTBD\\\\b'
|
||||
AND COALESCE(street_number, '') NOT REGEXP '\\\\bTBD\\\\b'
|
||||
AND photos_count > 0
|
||||
{$exclude_clause}
|
||||
ORDER BY modification_timestamp DESC",
|
||||
...$residential_types
|
||||
);
|
||||
$homeproz_pending_listings = $wpdb->get_results($homeproz_pending_query);
|
||||
|
||||
foreach ($homeproz_pending_listings as $listing) {
|
||||
if (count($listings) >= $count) break;
|
||||
$listings[] = homeproz_format_mls_listing_for_json($listing, true);
|
||||
$added_keys[] = $listing->listing_key;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fill remaining slots with random residential listings
|
||||
if (count($listings) < $count) {
|
||||
$needed = $count - count($listings);
|
||||
$type_placeholders = implode(',', array_fill(0, count($residential_types), '%s'));
|
||||
@@ -376,34 +389,26 @@ function homeproz_get_featured_commercial_listings($count = 3) {
|
||||
$added_keys = array();
|
||||
$listings = array();
|
||||
|
||||
// 1. Get featured MLS IDs from the override system
|
||||
$featured_mls_ids = function_exists('homeproz_get_featured_mls_ids')
|
||||
? homeproz_get_featured_mls_ids()
|
||||
: array();
|
||||
// 1. Get featured properties (is_featured = 1 in MLS database)
|
||||
$featured_query = $wpdb->prepare(
|
||||
"SELECT listing_key, listing_id, list_price, street_number, street_name, street_suffix,
|
||||
city, state_or_province, postal_code, bedrooms_total, bathrooms_total,
|
||||
living_area, standard_status, property_type, photos_count
|
||||
FROM {$table}
|
||||
WHERE is_featured = 1
|
||||
AND property_type IN ({$type_placeholders})
|
||||
AND standard_status = 'Active'
|
||||
AND mlg_can_view = 1
|
||||
AND photos_count > 0
|
||||
ORDER BY modification_timestamp DESC",
|
||||
...$commercial_types
|
||||
);
|
||||
$featured_listings = $wpdb->get_results($featured_query);
|
||||
|
||||
// Add featured commercial listings first
|
||||
if (!empty($featured_mls_ids)) {
|
||||
$id_placeholders = implode(',', array_fill(0, count($featured_mls_ids), '%s'));
|
||||
$featured_query = $wpdb->prepare(
|
||||
"SELECT listing_key, listing_id, list_price, street_number, street_name, street_suffix,
|
||||
city, state_or_province, postal_code, bedrooms_total, bathrooms_total,
|
||||
living_area, standard_status, property_type, photos_count
|
||||
FROM {$table}
|
||||
WHERE listing_id IN ({$id_placeholders})
|
||||
AND property_type IN ({$type_placeholders})
|
||||
AND standard_status = 'Active'
|
||||
AND mlg_can_view = 1
|
||||
AND photos_count > 0
|
||||
ORDER BY modification_timestamp DESC",
|
||||
...array_merge($featured_mls_ids, $commercial_types)
|
||||
);
|
||||
$featured_listings = $wpdb->get_results($featured_query);
|
||||
|
||||
foreach ($featured_listings as $listing) {
|
||||
if (count($listings) >= $count) break;
|
||||
$listings[] = homeproz_format_mls_listing_for_json($listing, false);
|
||||
$added_keys[] = $listing->listing_key;
|
||||
}
|
||||
foreach ($featured_listings as $listing) {
|
||||
if (count($listings) >= $count) break;
|
||||
$listings[] = homeproz_format_mls_listing_for_json($listing, false);
|
||||
$added_keys[] = $listing->listing_key;
|
||||
}
|
||||
|
||||
// 2. Add HomeProz commercial listings (if we need more)
|
||||
@@ -495,18 +500,8 @@ function homeproz_format_mls_listing_for_json($listing, $is_homeproz = false) {
|
||||
$full_address .= ', ' . $listing->state_or_province;
|
||||
}
|
||||
|
||||
// Check for MLS override (custom featured photo)
|
||||
$listing_id = isset($listing->listing_id) ? $listing->listing_id : null;
|
||||
$override = function_exists('homeproz_get_mls_override') ? homeproz_get_mls_override($listing_id) : null;
|
||||
|
||||
// Get image URL - use override if available, otherwise MLS image
|
||||
if ($override && !empty($override['featured_photo'])) {
|
||||
$image_url = isset($override['featured_photo']['sizes']['medium_large'])
|
||||
? $override['featured_photo']['sizes']['medium_large']
|
||||
: $override['featured_photo']['url'];
|
||||
} else {
|
||||
$image_url = mls_get_image_url($listing->listing_key, 1, 'thumb');
|
||||
}
|
||||
// Get image URL from MLS
|
||||
$image_url = mls_get_image_url($listing->listing_key, 1, 'thumb');
|
||||
|
||||
return array(
|
||||
'listing_key' => $listing->listing_key,
|
||||
@@ -698,3 +693,115 @@ function homeproz_admin_bar_edit_link($wp_admin_bar) {
|
||||
}
|
||||
add_action('admin_bar_menu', 'homeproz_admin_bar_edit_link', 80);
|
||||
|
||||
/**
|
||||
* Format property description with smart paragraph breaks and auto-linked URLs
|
||||
*
|
||||
* - Detects embedded headers: if a line has punctuation but ends without punctuation,
|
||||
* the trailing unpunctuated text is split out as a header
|
||||
* - Adds paragraph breaks between lines, except when both lines start with
|
||||
* non-alphanumeric characters (preserves bullet lists)
|
||||
* - Auto-links URLs with target="_blank"
|
||||
*
|
||||
* @param string $text The description text
|
||||
* @return string Formatted HTML
|
||||
*/
|
||||
function homeproz_format_property_description($text) {
|
||||
if (empty($text)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Escape HTML first
|
||||
$text = esc_html($text);
|
||||
|
||||
// Normalize line endings
|
||||
$text = str_replace("\r\n", "\n", $text);
|
||||
$text = str_replace("\r", "\n", $text);
|
||||
|
||||
// Split into lines
|
||||
$lines = explode("\n", $text);
|
||||
$processed_lines = array();
|
||||
|
||||
// First pass: detect embedded headers
|
||||
// Rule: if a line contains punctuation but the text after the last punctuation
|
||||
// doesn't end with punctuation, that trailing text is likely a header
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
// Skip empty lines
|
||||
if ($trimmed === '') {
|
||||
$processed_lines[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for pattern: sentence ending with punctuation, followed by unpunctuated text
|
||||
// e.g., "...modern conveniences. On the Scenic Shell Rock River"
|
||||
if (preg_match('/^(.+[.!?])(\s+)([A-Z].*)$/u', $trimmed, $matches)) {
|
||||
$before = $matches[1]; // text up to and including last punctuation
|
||||
$after = $matches[3]; // text after the punctuation (starts with capital)
|
||||
|
||||
// Only split if the trailing text doesn't end with punctuation
|
||||
$after_trimmed = trim($after);
|
||||
if (!empty($after_trimmed) && !preg_match('/[.!?]$/', $after_trimmed)) {
|
||||
// This is an embedded header - split it out with blank line
|
||||
$processed_lines[] = $before;
|
||||
$processed_lines[] = '';
|
||||
$processed_lines[] = $after;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$processed_lines[] = $line;
|
||||
}
|
||||
|
||||
// Second pass: add paragraph breaks between non-list lines
|
||||
$result = array();
|
||||
$count = count($processed_lines);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$current_line = $processed_lines[$i];
|
||||
$result[] = $current_line;
|
||||
|
||||
// Check if we need to add an extra newline after this line
|
||||
if ($i < $count - 1) {
|
||||
$next_line = $processed_lines[$i + 1];
|
||||
|
||||
// Skip empty lines
|
||||
if (trim($current_line) === '' || trim($next_line) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check first character of each line (after trimming)
|
||||
$current_first = substr(ltrim($current_line), 0, 1);
|
||||
$next_first = substr(ltrim($next_line), 0, 1);
|
||||
|
||||
// If both lines start with non-alphanumeric (like bullets), keep them together
|
||||
$current_is_list = !ctype_alnum($current_first);
|
||||
$next_is_list = !ctype_alnum($next_first);
|
||||
|
||||
if (!($current_is_list && $next_is_list)) {
|
||||
// Add extra newline to create paragraph break
|
||||
$result[] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Join lines back together
|
||||
$text = implode("\n", $result);
|
||||
|
||||
// Auto-link URLs (before wpautop to avoid breaking tags)
|
||||
$url_pattern = '/(https?:\/\/[^\s<>\[\]]+)/i';
|
||||
$text = preg_replace_callback($url_pattern, function($matches) {
|
||||
$url = $matches[1];
|
||||
// Remove trailing punctuation that's likely not part of URL
|
||||
$trailing = '';
|
||||
if (preg_match('/([.,;:!?\)]+)$/', $url, $punct)) {
|
||||
$trailing = $punct[1];
|
||||
$url = substr($url, 0, -strlen($trailing));
|
||||
}
|
||||
return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>' . $trailing;
|
||||
}, $text);
|
||||
|
||||
// Convert to paragraphs
|
||||
return wpautop($text);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,26 @@ function homeproz_theme_color_meta() {
|
||||
}
|
||||
add_action('wp_head', 'homeproz_theme_color_meta', 1);
|
||||
|
||||
/**
|
||||
* Output custom header scripts from Theme Options
|
||||
*
|
||||
* Allows site admin to add tracking scripts (Google Analytics, etc.)
|
||||
* Scripts are output exactly as entered - must include own script tags.
|
||||
*/
|
||||
function homeproz_header_scripts() {
|
||||
if (!function_exists('get_field')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$header_scripts = get_field('theme_header_scripts', 'option');
|
||||
|
||||
if (!empty($header_scripts)) {
|
||||
// Output exactly as entered - no escaping, no wrapping
|
||||
echo "\n" . $header_scripts . "\n";
|
||||
}
|
||||
}
|
||||
add_action('wp_head', 'homeproz_header_scripts', 5);
|
||||
|
||||
/**
|
||||
* Register custom block pattern category
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
/**
|
||||
* Yoast SEO Customizations
|
||||
*
|
||||
* Customizes sitemap to include HomeProz MLS listings and exclude utility pages.
|
||||
*
|
||||
* @package HomeProz
|
||||
*/
|
||||
|
||||
// Prevent direct access
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add HomeProz MLS listings to Yoast sitemap
|
||||
*
|
||||
* Creates a custom sitemap provider for properties listed by HomeProz.
|
||||
*/
|
||||
add_filter('wpseo_sitemap_index', 'homeproz_add_mls_sitemap_to_index');
|
||||
function homeproz_add_mls_sitemap_to_index($sitemap_custom_items) {
|
||||
// Add our custom MLS sitemap to the index using query string (more reliable)
|
||||
$sitemap_custom_items .= '<sitemap>
|
||||
<loc>' . home_url('/?homeproz_mls_sitemap=1') . '</loc>
|
||||
<lastmod>' . date('c') . '</lastmod>
|
||||
</sitemap>';
|
||||
|
||||
return $sitemap_custom_items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register custom sitemap for MLS listings
|
||||
*/
|
||||
add_action('init', 'homeproz_register_mls_sitemap');
|
||||
function homeproz_register_mls_sitemap() {
|
||||
global $wpseo_sitemaps;
|
||||
|
||||
if (isset($wpseo_sitemaps) && is_object($wpseo_sitemaps)) {
|
||||
$wpseo_sitemaps->register_sitemap('mls-listings', 'homeproz_generate_mls_sitemap');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative: Handle custom sitemap via rewrite rule
|
||||
*/
|
||||
add_action('init', 'homeproz_mls_sitemap_rewrite');
|
||||
function homeproz_mls_sitemap_rewrite() {
|
||||
add_rewrite_rule(
|
||||
'^mls-listings-sitemap\.xml$',
|
||||
'index.php?homeproz_mls_sitemap=1',
|
||||
'top'
|
||||
);
|
||||
}
|
||||
|
||||
add_filter('query_vars', 'homeproz_mls_sitemap_query_vars');
|
||||
function homeproz_mls_sitemap_query_vars($vars) {
|
||||
$vars[] = 'homeproz_mls_sitemap';
|
||||
return $vars;
|
||||
}
|
||||
|
||||
add_action('template_redirect', 'homeproz_render_mls_sitemap', 1);
|
||||
function homeproz_render_mls_sitemap() {
|
||||
if (!get_query_var('homeproz_mls_sitemap')) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Clean any output buffers
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
// Send headers
|
||||
status_header(200);
|
||||
header('Content-Type: application/xml; charset=UTF-8');
|
||||
header('X-Robots-Tag: noindex, follow');
|
||||
|
||||
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
||||
|
||||
// Get properties where HomeProz is the listing office
|
||||
$table_name = $wpdb->prefix . 'mls_properties';
|
||||
$table_exists = $wpdb->get_var("SHOW TABLES LIKE '{$table_name}'");
|
||||
|
||||
if ($table_exists) {
|
||||
$properties = $wpdb->get_results("
|
||||
SELECT listing_key, modification_timestamp
|
||||
FROM {$table_name}
|
||||
WHERE (list_office_name LIKE '%HomeProz%' OR list_office_name LIKE '%Home Proz%')
|
||||
AND standard_status IN ('Active', 'Pending')
|
||||
ORDER BY modification_timestamp DESC
|
||||
");
|
||||
|
||||
if ($properties) {
|
||||
foreach ($properties as $property) {
|
||||
$url = home_url('/properties/?listing=' . urlencode($property->listing_key));
|
||||
$lastmod = $property->modification_timestamp ? date('c', strtotime($property->modification_timestamp)) : date('c');
|
||||
|
||||
echo " <url>\n";
|
||||
echo " <loc>" . esc_url($url) . "</loc>\n";
|
||||
echo " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
|
||||
echo " <changefreq>daily</changefreq>\n";
|
||||
echo " <priority>0.7</priority>\n";
|
||||
echo " </url>\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo '</urlset>';
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude specific pages from sitemap
|
||||
*
|
||||
* Excludes thank you pages, template examples, and utility pages.
|
||||
*/
|
||||
add_filter('wpseo_exclude_from_sitemap_by_post_ids', 'homeproz_exclude_pages_from_sitemap');
|
||||
function homeproz_exclude_pages_from_sitemap($excluded_posts) {
|
||||
// Get pages to exclude by slug
|
||||
$exclude_slugs = array(
|
||||
'inquiry-thank-you',
|
||||
'contact-thank-you',
|
||||
'page-template-examples',
|
||||
'content-sidebar',
|
||||
'alternating-blocks',
|
||||
'service-detail',
|
||||
'card-grid',
|
||||
'long-form-article',
|
||||
'landing-page',
|
||||
'sample-page',
|
||||
'home-page-alt',
|
||||
);
|
||||
|
||||
foreach ($exclude_slugs as $slug) {
|
||||
$page = get_page_by_path($slug);
|
||||
if ($page) {
|
||||
$excluded_posts[] = $page->ID;
|
||||
}
|
||||
}
|
||||
|
||||
return $excluded_posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set noindex for thank you and utility pages via Yoast
|
||||
*/
|
||||
add_action('wp_head', 'homeproz_noindex_utility_pages', 1);
|
||||
function homeproz_noindex_utility_pages() {
|
||||
if (!is_page()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$noindex_slugs = array(
|
||||
'inquiry-thank-you',
|
||||
'contact-thank-you',
|
||||
'page-template-examples',
|
||||
'content-sidebar',
|
||||
'alternating-blocks',
|
||||
'service-detail',
|
||||
'card-grid',
|
||||
'long-form-article',
|
||||
'landing-page',
|
||||
'sample-page',
|
||||
'home-page-alt',
|
||||
);
|
||||
|
||||
global $post;
|
||||
if ($post && in_array($post->post_name, $noindex_slugs)) {
|
||||
echo '<meta name="robots" content="noindex, nofollow">' . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush rewrite rules on theme activation to register sitemap URL
|
||||
*/
|
||||
add_action('after_switch_theme', 'homeproz_flush_rewrite_rules_for_sitemap');
|
||||
function homeproz_flush_rewrite_rules_for_sitemap() {
|
||||
homeproz_mls_sitemap_rewrite();
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
+53
-13
@@ -17,12 +17,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -148,12 +148,12 @@
|
||||
"@parcel/watcher-win32-x64": "2.5.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
|
||||
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
|
||||
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -169,12 +169,47 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
|
||||
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
|
||||
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -312,6 +347,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
@@ -658,6 +694,7 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -865,6 +902,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -1157,6 +1195,7 @@
|
||||
"integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^5.0.2",
|
||||
@@ -1371,6 +1410,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# esbuild
|
||||
|
||||
This is the Linux ARM 64-bit binary for esbuild, a JavaScript bundler and minifier. See https://github.com/evanw/esbuild for details.
|
||||
Generated
Vendored
BIN
Binary file not shown.
Generated
Vendored
Executable → Regular
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@esbuild/linux-x64",
|
||||
"name": "@esbuild/linux-arm64",
|
||||
"version": "0.21.5",
|
||||
"description": "The Linux 64-bit binary for esbuild, a JavaScript bundler.",
|
||||
"description": "The Linux ARM 64-bit binary for esbuild, a JavaScript bundler.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/evanw/esbuild.git"
|
||||
@@ -15,6 +15,6 @@
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
# esbuild
|
||||
|
||||
This is the Linux 64-bit binary for esbuild, a JavaScript bundler and minifier. See https://github.com/evanw/esbuild for details.
|
||||
Generated
Vendored
Executable → Regular
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
This is the linux-arm64-glibc build of @parcel/watcher. See https://github.com/parcel-bundler/watcher for details.
|
||||
Generated
Vendored
Executable → Regular
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@parcel/watcher-linux-x64-glibc",
|
||||
"name": "@parcel/watcher-linux-arm64-glibc",
|
||||
"version": "2.5.1",
|
||||
"main": "watcher.node",
|
||||
"repository": {
|
||||
@@ -25,7 +25,7 @@
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
Generated
Vendored
BIN
Binary file not shown.
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-present Devon Govett
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
This is the linux-arm64-musl build of @parcel/watcher. See https://github.com/parcel-bundler/watcher for details.
|
||||
Generated
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@parcel/watcher-linux-arm64-musl",
|
||||
"version": "2.5.1",
|
||||
"main": "watcher.node",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/parcel-bundler/watcher.git"
|
||||
},
|
||||
"description": "A native C++ Node module for querying and subscribing to filesystem events. Used by Parcel 2.",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"files": [
|
||||
"watcher.node"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
]
|
||||
}
|
||||
Generated
Vendored
BIN
Binary file not shown.
Generated
Vendored
-1
@@ -1 +0,0 @@
|
||||
This is the linux-x64-glibc build of @parcel/watcher. See https://github.com/parcel-bundler/watcher for details.
|
||||
Generated
Vendored
BIN
Binary file not shown.
+3
@@ -0,0 +1,3 @@
|
||||
# `@rollup/rollup-linux-arm64-gnu`
|
||||
|
||||
This is the **aarch64-unknown-linux-gnu** binary for `rollup`
|
||||
Generated
Vendored
Executable → Regular
+4
-4
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@rollup/rollup-linux-x64-gnu",
|
||||
"name": "@rollup/rollup-linux-arm64-gnu",
|
||||
"version": "4.53.3",
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
"arm64"
|
||||
],
|
||||
"files": [
|
||||
"rollup.linux-x64-gnu.node"
|
||||
"rollup.linux-arm64-gnu.node"
|
||||
],
|
||||
"description": "Native bindings for Rollup",
|
||||
"author": "Lukas Taegert-Atkinson",
|
||||
@@ -21,5 +21,5 @@
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"main": "./rollup.linux-x64-gnu.node"
|
||||
"main": "./rollup.linux-arm64-gnu.node"
|
||||
}
|
||||
Generated
Vendored
BIN
Binary file not shown.
Generated
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# `@rollup/rollup-linux-arm64-musl`
|
||||
|
||||
This is the **aarch64-unknown-linux-musl** binary for `rollup`
|
||||
Generated
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@rollup/rollup-linux-arm64-musl",
|
||||
"version": "4.53.3",
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"files": [
|
||||
"rollup.linux-arm64-musl.node"
|
||||
],
|
||||
"description": "Native bindings for Rollup",
|
||||
"author": "Lukas Taegert-Atkinson",
|
||||
"homepage": "https://rollupjs.org/",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/rollup/rollup.git"
|
||||
},
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"main": "./rollup.linux-arm64-musl.node"
|
||||
}
|
||||
wp-content/themes/homeproz/node_modules/@rollup/rollup-linux-arm64-musl/rollup.linux-arm64-musl.node
Generated
Vendored
BIN
Binary file not shown.
-3
@@ -1,3 +0,0 @@
|
||||
# `@rollup/rollup-linux-x64-gnu`
|
||||
|
||||
This is the **x86_64-unknown-linux-gnu** binary for `rollup`
|
||||
Generated
Vendored
BIN
Binary file not shown.
+5
@@ -1246,6 +1246,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
@@ -1607,6 +1608,7 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -1814,6 +1816,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -2106,6 +2109,7 @@
|
||||
"integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^5.0.2",
|
||||
@@ -2320,6 +2324,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Name: City Landing Page
|
||||
*
|
||||
* SEO-optimized city/region landing page with featured MLS listings
|
||||
*
|
||||
* @package HomeProz
|
||||
*/
|
||||
|
||||
// Prevent direct access
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
get_header();
|
||||
|
||||
// Get city name from ACF or page title
|
||||
$city_name = get_field('city_name') ?: get_the_title();
|
||||
$city_slug = sanitize_title($city_name);
|
||||
|
||||
// Get custom content
|
||||
$intro_content = get_field('city_intro_content');
|
||||
$listings_heading = get_field('listings_section_heading') ?: 'Homes for Sale in ' . $city_name;
|
||||
$show_all_text = get_field('show_all_button_text') ?: 'View All ' . $city_name . ' Listings';
|
||||
$max_listings = get_field('max_listings_to_show') ?: 8;
|
||||
|
||||
// Get MLS listings for this city
|
||||
$listings = array();
|
||||
if (function_exists('mls_get_properties')) {
|
||||
$listings = mls_get_properties(array(
|
||||
'city' => $city_name,
|
||||
'status' => 'Active',
|
||||
'limit' => $max_listings,
|
||||
'orderby' => 'list_price',
|
||||
'order' => 'DESC',
|
||||
));
|
||||
}
|
||||
|
||||
// Count total listings for this city
|
||||
$total_listings = 0;
|
||||
if (function_exists('mls_get_property_count')) {
|
||||
$total_listings = mls_get_property_count(array(
|
||||
'city' => $city_name,
|
||||
'status' => 'Active',
|
||||
));
|
||||
}
|
||||
|
||||
// Properties search URL filtered by city
|
||||
$search_url = add_query_arg('city', urlencode($city_name), home_url('/properties/'));
|
||||
?>
|
||||
|
||||
<main id="primary" class="site-main City_Landing_Page">
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="city-hero">
|
||||
<div class="container">
|
||||
<h1 class="city-hero-title"><?php echo esc_html($city_name); ?> Real Estate</h1>
|
||||
<p class="city-hero-subtitle">Find homes for sale in <?php echo esc_html($city_name); ?>, Minnesota</p>
|
||||
<?php if ($total_listings > 0) : ?>
|
||||
<p class="city-hero-count"><?php echo esc_html($total_listings); ?> active <?php echo $total_listings === 1 ? 'listing' : 'listings'; ?> available</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About This City -->
|
||||
<?php if ($intro_content || have_posts()) : ?>
|
||||
<section class="city-about">
|
||||
<div class="container">
|
||||
<div class="city-about-content">
|
||||
<?php if ($intro_content) : ?>
|
||||
<?php echo wp_kses_post($intro_content); ?>
|
||||
<?php else : ?>
|
||||
<?php
|
||||
// Fall back to page content if no ACF content
|
||||
while (have_posts()) :
|
||||
the_post();
|
||||
the_content();
|
||||
endwhile;
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Featured Listings -->
|
||||
<?php if (!empty($listings)) : ?>
|
||||
<section class="city-listings">
|
||||
<div class="container">
|
||||
<header class="section-header">
|
||||
<h2 class="section-title"><?php echo esc_html($listings_heading); ?></h2>
|
||||
</header>
|
||||
|
||||
<div class="property-grid property-grid-4">
|
||||
<?php
|
||||
foreach ($listings as $property) :
|
||||
set_query_var('mls_property', $property);
|
||||
get_template_part('template-parts/property/property-card-mls');
|
||||
endforeach;
|
||||
?>
|
||||
</div>
|
||||
|
||||
<?php if ($total_listings > count($listings)) : ?>
|
||||
<div class="city-listings-cta">
|
||||
<a href="<?php echo esc_url($search_url); ?>" class="btn btn-primary btn-lg">
|
||||
<?php echo esc_html($show_all_text); ?>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php else : ?>
|
||||
<section class="city-no-listings">
|
||||
<div class="container">
|
||||
<p>No active listings in <?php echo esc_html($city_name); ?> at this time. Check back soon or <a href="<?php echo esc_url(home_url('/properties/')); ?>">browse all properties</a>.</p>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Contact CTA -->
|
||||
<?php
|
||||
get_template_part('template-parts/components/cta-section', null, array(
|
||||
'title' => 'Looking for a Home in ' . $city_name . '?',
|
||||
'text' => 'Our local agents know ' . $city_name . ' inside and out. Let us help you find the perfect property.',
|
||||
'button_text' => 'Contact an Agent',
|
||||
'button_url' => home_url('/contact/'),
|
||||
'style' => 'accent',
|
||||
));
|
||||
?>
|
||||
|
||||
</main>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* City Landing Page Styles
|
||||
*/
|
||||
|
||||
.City_Landing_Page {
|
||||
// Hero Section
|
||||
.city-hero {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-white);
|
||||
padding: 4rem 0;
|
||||
text-align: center;
|
||||
|
||||
&-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-subtitle {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.5rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&-count {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
// About Section
|
||||
.city-about {
|
||||
padding: 3rem 0;
|
||||
background-color: var(--color-surface);
|
||||
|
||||
&-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
||||
h2, h3 {
|
||||
color: var(--color-primary);
|
||||
margin-top: 1.5rem;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 1.5rem;
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.7;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listings Section
|
||||
.city-listings {
|
||||
padding: 3rem 0 4rem;
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.75rem;
|
||||
color: var(--color-primary);
|
||||
margin: 0;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-cta {
|
||||
text-align: center;
|
||||
margin-top: 2.5rem;
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No Listings
|
||||
.city-no-listings {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Property Grid for city pages - 4 columns on large screens
|
||||
.property-grid-4 {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ while (have_posts()) :
|
||||
$agent_gallery = get_field('agent_gallery', $agent_id);
|
||||
$agent_social_links = get_field('agent_social_links', $agent_id);
|
||||
$agent_credentials = get_field('agent_credentials', $agent_id);
|
||||
$agent_testimonials = get_field('agent_testimonials', $agent_id);
|
||||
|
||||
// Get featured image
|
||||
$featured_image_id = get_post_thumbnail_id($agent_id);
|
||||
@@ -172,6 +173,35 @@ while (have_posts()) :
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Testimonials -->
|
||||
<?php if ($agent_testimonials && is_array($agent_testimonials) && count($agent_testimonials) > 0) : ?>
|
||||
<section class="agent-section agent-testimonials">
|
||||
<h2 class="section-title">Client Testimonials</h2>
|
||||
<div class="testimonials-grid">
|
||||
<?php foreach ($agent_testimonials as $testimonial) :
|
||||
$quote = $testimonial['quote'] ?? '';
|
||||
$client_name = $testimonial['client_name'] ?? '';
|
||||
$context = $testimonial['context'] ?? '';
|
||||
?>
|
||||
<blockquote class="testimonial-card">
|
||||
<div class="testimonial-quote">
|
||||
<svg class="quote-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z"/>
|
||||
</svg>
|
||||
<p><?php echo esc_html($quote); ?></p>
|
||||
</div>
|
||||
<footer class="testimonial-attribution">
|
||||
<cite class="client-name"><?php echo esc_html($client_name); ?></cite>
|
||||
<?php if ($context) : ?>
|
||||
<span class="client-context"><?php echo esc_html($context); ?></span>
|
||||
<?php endif; ?>
|
||||
</footer>
|
||||
</blockquote>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Image Gallery -->
|
||||
<?php
|
||||
if ($agent_gallery && is_array($agent_gallery) && count($agent_gallery) > 0) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../template-parts/components/hero-section.js';
|
||||
import '../template-parts/components/hero-location-search.js';
|
||||
import '../template-parts/home/featured-listings.js';
|
||||
import '../template-parts/property/property-filters.js';
|
||||
import '../template-parts/property/mobile-map.js';
|
||||
import '../template-parts/property/property-gallery.js';
|
||||
import '../template-parts/agent/agent-gallery.js';
|
||||
import '../template-parts/content/content-mortgage-calculator.js';
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
@import '../template-parts/content/content-mortgage-calculator.scss';
|
||||
@import '../template-parts/property/property-card.scss';
|
||||
@import '../template-parts/property/property-filters.scss';
|
||||
@import '../template-parts/property/mobile-map.scss';
|
||||
@import '../template-parts/property/property-gallery.scss';
|
||||
@import '../template-parts/property/single-property.scss';
|
||||
@import '../template-parts/agent/single-agent.scss';
|
||||
@@ -44,6 +45,7 @@
|
||||
|
||||
// Import page templates
|
||||
@import '../page-templates/page-templates.scss';
|
||||
@import '../page-city-landing.scss';
|
||||
|
||||
// ============================================
|
||||
// CSS Custom Properties (Design Tokens)
|
||||
|
||||
@@ -125,6 +125,16 @@
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.btn {
|
||||
min-width: 275px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -280,6 +290,78 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Testimonials Section
|
||||
.agent-testimonials {
|
||||
.testimonials-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.testimonial-card {
|
||||
background-color: var(--color-bg-card);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin: 0;
|
||||
border-left: 3px solid var(--color-accent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.testimonial-quote {
|
||||
position: relative;
|
||||
|
||||
.quote-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--color-accent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
padding-left: 2rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--color-text);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.testimonial-attribution {
|
||||
padding-left: 2rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.client-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.client-context {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
&::before {
|
||||
content: '-';
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gallery Section
|
||||
.agent-gallery-section {
|
||||
.agent-gallery-grid {
|
||||
|
||||
@@ -91,6 +91,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
// When inside hero-mobile-only, use flexbox centering instead of absolute
|
||||
// This ensures the hero section grows to fit the card + padding
|
||||
.hero-mobile-only .hero-section--card {
|
||||
// Override to use flexbox centering - parent grows to fit content
|
||||
min-height: max(70vh, auto);
|
||||
padding: 20px 0; // 20px top + 20px bottom = 40px total buffer
|
||||
justify-content: center; // Center the card horizontally
|
||||
|
||||
.hero-card {
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: auto;
|
||||
transform: none;
|
||||
max-width: 520px;
|
||||
margin: 0 auto; // Ensure horizontal centering
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
// Ensure top margin on medium-short viewports (700-960px)
|
||||
@media (min-height: 700px) and (max-height: 960px) {
|
||||
margin-top: 20px;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
// Scale down on short viewports
|
||||
@media (max-height: 700px) {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero-section--card .hero-section-logo {
|
||||
display: block;
|
||||
max-width: 360px;
|
||||
@@ -145,6 +178,16 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 0.875rem;
|
||||
justify-content: center;
|
||||
|
||||
@media (max-width: 549px) {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero-section--card .hero-location-search {
|
||||
|
||||
@@ -62,6 +62,11 @@
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.service-card {
|
||||
|
||||
@@ -122,6 +122,30 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// Team grid - centered even when not full width
|
||||
.agents-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
|
||||
.agent-card-item {
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
width: calc(50% - 1rem);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: calc(33.333% - 1.334rem);
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: calc(25% - 1.5rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broker Section
|
||||
.about-broker-section {
|
||||
padding: 3rem 0;
|
||||
|
||||
@@ -103,23 +103,22 @@
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
gap: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
margin-bottom: 0.75rem;
|
||||
.menu-item a {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9375rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9375rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-accent-light);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--color-accent-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,750 @@
|
||||
/**
|
||||
* Mobile Map View JavaScript
|
||||
*
|
||||
* Bottom sheet interface for mobile property browsing
|
||||
* Uses same AJAX endpoints as desktop map for consistency
|
||||
*
|
||||
* @package HomeProz
|
||||
*/
|
||||
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
// Only run on mobile viewports
|
||||
if (window.innerWidth >= 1024) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile Bottom Sheet Manager
|
||||
*/
|
||||
var MobileSheet = {
|
||||
$sheet: null,
|
||||
$handle: null,
|
||||
$content: null,
|
||||
$filters: null,
|
||||
$filterToggle: null,
|
||||
$propertyList: null,
|
||||
$propertyCount: null,
|
||||
|
||||
// Sheet states and heights (2 states only)
|
||||
states: {
|
||||
collapsed: 120,
|
||||
expanded: null // Calculated as 100vh - 60px
|
||||
},
|
||||
currentState: 'collapsed',
|
||||
|
||||
// Drag handling
|
||||
isDragging: false,
|
||||
startY: 0,
|
||||
startHeight: 0,
|
||||
currentHeight: 0,
|
||||
lastY: 0,
|
||||
lastTime: 0,
|
||||
velocity: 0,
|
||||
|
||||
/**
|
||||
* Initialize the sheet
|
||||
*/
|
||||
init: function() {
|
||||
this.$sheet = $('#mobile-bottom-sheet');
|
||||
if (!this.$sheet.length) return;
|
||||
|
||||
this.$handle = $('#sheet-drag-handle');
|
||||
this.$content = $('#sheet-content');
|
||||
this.$filters = $('#sheet-filters');
|
||||
this.$filterToggle = $('#sheet-filter-toggle');
|
||||
this.$propertyList = $('#sheet-property-list');
|
||||
this.$propertyCount = $('#mobile-property-count');
|
||||
|
||||
// Calculate dynamic heights
|
||||
this.calculateHeights();
|
||||
|
||||
// Bind events
|
||||
this.bindEvents();
|
||||
|
||||
// Set initial state
|
||||
this.setState('collapsed');
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate viewport-dependent heights
|
||||
*/
|
||||
calculateHeights: function() {
|
||||
var vh = window.innerHeight;
|
||||
this.states.expanded = vh - 60;
|
||||
},
|
||||
|
||||
/**
|
||||
* Bind all events
|
||||
*/
|
||||
bindEvents: function() {
|
||||
var self = this;
|
||||
|
||||
// Drag handle events (touch)
|
||||
this.$handle.on('touchstart', function(e) {
|
||||
self.onDragStart(e);
|
||||
});
|
||||
|
||||
$(document).on('touchmove', function(e) {
|
||||
if (self.isDragging) {
|
||||
self.onDragMove(e);
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('touchend touchcancel', function(e) {
|
||||
if (self.isDragging) {
|
||||
self.onDragEnd(e);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle tap on drag handle to cycle states
|
||||
this.$handle.on('click', function() {
|
||||
if (!self.isDragging) {
|
||||
self.cycleState();
|
||||
}
|
||||
});
|
||||
|
||||
// Filter toggle
|
||||
this.$filterToggle.on('click', function(e) {
|
||||
e.stopPropagation(); // Prevent header click from firing
|
||||
self.toggleFilters();
|
||||
});
|
||||
|
||||
// Header click to expand/collapse (except when clicking filter button)
|
||||
this.$sheet.find('.sheet-header').on('click', function(e) {
|
||||
// Only toggle if not clicking the filter button
|
||||
if (!$(e.target).closest('#sheet-filter-toggle').length) {
|
||||
self.cycleState();
|
||||
}
|
||||
});
|
||||
|
||||
// Filter changes
|
||||
$('.sheet-filter-select').on('change', function() {
|
||||
self.onFilterChange();
|
||||
});
|
||||
|
||||
// Recalculate on resize
|
||||
$(window).on('resize', function() {
|
||||
self.calculateHeights();
|
||||
self.setState(self.currentState, true);
|
||||
});
|
||||
|
||||
// Prevent body scroll when sheet is expanded
|
||||
this.$content.on('touchmove', function(e) {
|
||||
if (self.currentState === 'expanded') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Start dragging
|
||||
*/
|
||||
onDragStart: function(e) {
|
||||
this.isDragging = true;
|
||||
this.startY = e.touches[0].clientY;
|
||||
this.lastY = this.startY;
|
||||
this.lastTime = Date.now();
|
||||
this.startHeight = this.$sheet.height();
|
||||
this.velocity = 0;
|
||||
this.$sheet.addClass('is-dragging');
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle drag move
|
||||
*/
|
||||
onDragMove: function(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
var currentY = e.touches[0].clientY;
|
||||
var currentTime = Date.now();
|
||||
var deltaY = this.startY - currentY;
|
||||
var newHeight = this.startHeight + deltaY;
|
||||
|
||||
// Calculate velocity (pixels per millisecond)
|
||||
var timeDelta = currentTime - this.lastTime;
|
||||
if (timeDelta > 0) {
|
||||
this.velocity = (this.lastY - currentY) / timeDelta;
|
||||
}
|
||||
this.lastY = currentY;
|
||||
this.lastTime = currentTime;
|
||||
|
||||
// Clamp height
|
||||
var minHeight = this.states.collapsed;
|
||||
var maxHeight = this.states.expanded;
|
||||
newHeight = Math.max(minHeight, Math.min(maxHeight, newHeight));
|
||||
|
||||
this.currentHeight = newHeight;
|
||||
this.$sheet.css('height', newHeight + 'px');
|
||||
|
||||
// Prevent default to stop page scroll
|
||||
e.preventDefault();
|
||||
},
|
||||
|
||||
/**
|
||||
* End dragging - snap based on gesture direction and velocity
|
||||
*/
|
||||
onDragEnd: function(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
this.isDragging = false;
|
||||
this.$sheet.removeClass('is-dragging');
|
||||
|
||||
var totalDelta = this.startY - this.lastY; // Positive = swiped up
|
||||
|
||||
// Use velocity for quick swipes, or distance for slow drags
|
||||
var velocityThreshold = 0.5; // pixels per ms
|
||||
var distanceThreshold = 50; // pixels
|
||||
|
||||
if (Math.abs(this.velocity) > velocityThreshold) {
|
||||
// Fast swipe - use velocity direction
|
||||
if (this.velocity > 0) {
|
||||
this.setState('expanded');
|
||||
} else {
|
||||
this.setState('collapsed');
|
||||
}
|
||||
} else if (Math.abs(totalDelta) > distanceThreshold) {
|
||||
// Slow drag - use direction
|
||||
if (totalDelta > 0) {
|
||||
this.setState('expanded');
|
||||
} else {
|
||||
this.setState('collapsed');
|
||||
}
|
||||
} else {
|
||||
// Small movement - snap back to current state
|
||||
this.setState(this.currentState);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle state on tap
|
||||
*/
|
||||
cycleState: function() {
|
||||
if (this.currentState === 'collapsed') {
|
||||
this.setState('expanded');
|
||||
} else {
|
||||
this.setState('collapsed');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set sheet state
|
||||
*/
|
||||
setState: function(state, skipAnimation) {
|
||||
this.currentState = state;
|
||||
this.$sheet.attr('data-state', state);
|
||||
|
||||
var height = this.states[state];
|
||||
if (skipAnimation) {
|
||||
this.$sheet.addClass('is-dragging');
|
||||
}
|
||||
this.$sheet.css('height', height + 'px');
|
||||
if (skipAnimation) {
|
||||
// Remove after a frame to allow CSS to apply
|
||||
var self = this;
|
||||
requestAnimationFrame(function() {
|
||||
self.$sheet.removeClass('is-dragging');
|
||||
});
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle filters visibility
|
||||
*/
|
||||
toggleFilters: function() {
|
||||
this.$filters.toggleClass('is-visible');
|
||||
this.$filterToggle.toggleClass('is-active');
|
||||
|
||||
// Expand sheet if collapsed and filters are being shown
|
||||
if (this.$filters.hasClass('is-visible') && this.currentState === 'collapsed') {
|
||||
this.setState('expanded');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle filter change
|
||||
*/
|
||||
onFilterChange: function() {
|
||||
var filters = this.getFilters();
|
||||
MobileMap.updateFilters(filters);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current filter values
|
||||
*/
|
||||
getFilters: function() {
|
||||
return {
|
||||
property_type: $('#mobile-filter-type').val() || '',
|
||||
city: $('#mobile-filter-city').val() || '',
|
||||
min_beds: $('#mobile-filter-beds').val() || '',
|
||||
min_price: $('#mobile-filter-min-price').val() || '',
|
||||
max_price: $('#mobile-filter-max-price').val() || ''
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Update property count display
|
||||
*/
|
||||
updateCount: function(count) {
|
||||
this.$propertyCount.text(count);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show loading state in property list
|
||||
*/
|
||||
showLoading: function() {
|
||||
this.$propertyList.html(
|
||||
'<div class="sheet-property-loading">' +
|
||||
'<div class="spinner"></div>' +
|
||||
'<span>Loading properties...</span>' +
|
||||
'</div>'
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Render property cards in the list
|
||||
* @param {Array} properties - Property data array
|
||||
* @param {boolean} skipCountUpdate - Don't update count (for density/cluster mode)
|
||||
*/
|
||||
renderProperties: function(properties, skipCountUpdate) {
|
||||
var self = this;
|
||||
var html = '';
|
||||
|
||||
if (!properties || properties.length === 0) {
|
||||
html = '<div class="sheet-no-properties">' +
|
||||
'<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">' +
|
||||
'<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>Zoom in to see individual properties</p>' +
|
||||
'</div>';
|
||||
if (!skipCountUpdate) {
|
||||
this.updateCount(0);
|
||||
}
|
||||
} else {
|
||||
properties.forEach(function(prop) {
|
||||
html += self.renderPropertyCard(prop);
|
||||
});
|
||||
this.updateCount(properties.length);
|
||||
}
|
||||
|
||||
this.$propertyList.html(html);
|
||||
},
|
||||
|
||||
/**
|
||||
* Render a single property card
|
||||
*/
|
||||
renderPropertyCard: function(prop) {
|
||||
var statusClass = 'badge-active';
|
||||
if (prop.status === 'Pending') {
|
||||
statusClass = 'badge-pending';
|
||||
} else if (prop.status === 'Closed' || prop.status === 'Sold') {
|
||||
statusClass = 'badge-sold';
|
||||
}
|
||||
|
||||
// Use image URL from API (includes signature for auth)
|
||||
var imageUrl = prop.image || '';
|
||||
var imageStyle = imageUrl ? 'background-image: url(' + imageUrl + ')' : '';
|
||||
|
||||
var specs = [];
|
||||
if (prop.beds) specs.push(prop.beds + ' bed');
|
||||
if (prop.baths) specs.push(prop.baths + ' bath');
|
||||
if (prop.sqft) specs.push(Number(prop.sqft).toLocaleString() + ' sqft');
|
||||
|
||||
// Price comes pre-formatted from API (e.g., "$375,000")
|
||||
var priceFormatted = prop.price || '$0';
|
||||
|
||||
return '<a href="' + prop.url + '" class="sheet-property-card" data-property-id="' + prop.id + '">' +
|
||||
'<div class="sheet-card-image" style="' + imageStyle + '">' +
|
||||
(prop.status ? '<span class="sheet-card-badge ' + statusClass + '">' + prop.status + '</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="sheet-card-content">' +
|
||||
'<div class="sheet-card-price">' + priceFormatted + '</div>' +
|
||||
'<div class="sheet-card-address">' + (prop.address || 'Property') + '</div>' +
|
||||
'<div class="sheet-card-specs">' + specs.join(' • ') + '</div>' +
|
||||
'</div>' +
|
||||
'</a>';
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Mobile Map Manager
|
||||
* Uses same clustering logic and endpoints as desktop map
|
||||
*/
|
||||
var MobileMap = {
|
||||
map: null,
|
||||
markerLayer: null,
|
||||
clusterLayer: null,
|
||||
densityLayer: null,
|
||||
currentFilters: {},
|
||||
currentMode: null,
|
||||
debounceTimer: null,
|
||||
|
||||
/**
|
||||
* Initialize the mobile map
|
||||
*/
|
||||
init: function() {
|
||||
var $container = $('#mobile-property-map');
|
||||
if (!$container.length || typeof L === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get initial filters from URL params
|
||||
this.currentFilters = this.getFiltersFromUrl();
|
||||
|
||||
// Initialize map
|
||||
this.map = L.map('mobile-property-map', {
|
||||
zoomControl: false
|
||||
}).setView([45.0, -93.5], 7);
|
||||
|
||||
// Add zoom control to top-right
|
||||
L.control.zoom({
|
||||
position: 'topright'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add OpenStreetMap tiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OSM'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Create layers for different visualization modes
|
||||
this.densityLayer = L.layerGroup().addTo(this.map);
|
||||
this.clusterLayer = L.layerGroup().addTo(this.map);
|
||||
this.markerLayer = L.layerGroup().addTo(this.map);
|
||||
|
||||
// Bind map events
|
||||
var self = this;
|
||||
this.map.on('moveend zoomend', function() {
|
||||
self.onMapMove();
|
||||
});
|
||||
|
||||
// Initial load - fit to all properties
|
||||
this.fitToAllProperties();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get filters from URL params
|
||||
*/
|
||||
getFiltersFromUrl: function() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
property_type: params.get('property_type') || '',
|
||||
city: params.get('city') || '',
|
||||
min_beds: params.get('beds') || '',
|
||||
min_price: params.get('min_price') || '',
|
||||
max_price: params.get('max_price') || '',
|
||||
status: 'Active'
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle map move/zoom - debounced
|
||||
*/
|
||||
onMapMove: function() {
|
||||
var self = this;
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(function() {
|
||||
self.loadClusters();
|
||||
}, 150);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fit map to show all properties
|
||||
*/
|
||||
fitToAllProperties: function() {
|
||||
var self = this;
|
||||
|
||||
$.ajax({
|
||||
url: homeprozAjax.ajaxUrl,
|
||||
type: 'GET',
|
||||
data: {
|
||||
action: 'homeproz_get_filter_bounds',
|
||||
property_type: this.currentFilters.property_type || '',
|
||||
city: this.currentFilters.city || '',
|
||||
min_price: this.currentFilters.min_price || '',
|
||||
max_price: this.currentFilters.max_price || '',
|
||||
min_beds: this.currentFilters.min_beds || ''
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success && response.data) {
|
||||
var bounds = response.data;
|
||||
var latPadding = (bounds.ne_lat - bounds.sw_lat) * 0.15;
|
||||
var lngPadding = (bounds.ne_lng - bounds.sw_lng) * 0.15;
|
||||
|
||||
var paddedBounds = L.latLngBounds(
|
||||
[bounds.sw_lat - latPadding, bounds.sw_lng - lngPadding],
|
||||
[bounds.ne_lat + latPadding, bounds.ne_lng + lngPadding]
|
||||
);
|
||||
|
||||
self.map.fitBounds(paddedBounds);
|
||||
} else {
|
||||
self.loadClusters();
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
self.loadClusters();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Update filters and reload
|
||||
*/
|
||||
updateFilters: function(filters) {
|
||||
this.currentFilters = $.extend({}, this.currentFilters, filters);
|
||||
this.fitToAllProperties();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load clusters/markers based on current viewport
|
||||
*/
|
||||
loadClusters: function() {
|
||||
if (!this.map) return;
|
||||
|
||||
var self = this;
|
||||
var bounds = this.map.getBounds();
|
||||
var zoom = this.map.getZoom();
|
||||
|
||||
var boundsArray = [
|
||||
bounds.getSouthWest().lat,
|
||||
bounds.getSouthWest().lng,
|
||||
bounds.getNorthEast().lat,
|
||||
bounds.getNorthEast().lng
|
||||
];
|
||||
|
||||
MobileSheet.showLoading();
|
||||
|
||||
$.ajax({
|
||||
url: homeprozAjax.ajaxUrl,
|
||||
type: 'GET',
|
||||
data: {
|
||||
action: 'mls_get_clusters',
|
||||
zoom: zoom,
|
||||
bounds: boundsArray,
|
||||
status: this.currentFilters.status || 'Active',
|
||||
property_type: this.currentFilters.property_type || '',
|
||||
city: this.currentFilters.city || '',
|
||||
min_price: this.currentFilters.min_price || '',
|
||||
max_price: this.currentFilters.max_price || '',
|
||||
min_beds: this.currentFilters.min_beds || ''
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success && response.data) {
|
||||
var data = response.data;
|
||||
self.currentMode = data.type;
|
||||
|
||||
switch (data.type) {
|
||||
case 'density':
|
||||
self.renderDensity(data.dots);
|
||||
MobileSheet.updateCount(data.total || 0);
|
||||
MobileSheet.renderProperties([], true); // Skip count update
|
||||
break;
|
||||
case 'clusters':
|
||||
self.renderClusters(data.clusters);
|
||||
MobileSheet.updateCount(data.total || 0);
|
||||
MobileSheet.renderProperties([], true); // Skip count update
|
||||
break;
|
||||
case 'markers':
|
||||
self.renderMarkers(data.markers);
|
||||
MobileSheet.renderProperties(data.markers);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
MobileSheet.renderProperties([]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all layers
|
||||
*/
|
||||
clearAllLayers: function() {
|
||||
this.densityLayer.clearLayers();
|
||||
this.clusterLayer.clearLayers();
|
||||
this.markerLayer.clearLayers();
|
||||
},
|
||||
|
||||
/**
|
||||
* Render density dots (low zoom levels)
|
||||
*/
|
||||
renderDensity: function(dots) {
|
||||
this.clearAllLayers();
|
||||
var self = this;
|
||||
var zoom = this.map.getZoom();
|
||||
|
||||
dots.forEach(function(dot) {
|
||||
var color = self.getDensityColor(dot.count, zoom);
|
||||
var size = self.getDensitySize(dot.count, zoom);
|
||||
|
||||
var icon = L.divIcon({
|
||||
html: '<div class="density-dot" style="background-color: ' + color + '; width: ' + size + 'px; height: ' + size + 'px;"></div>',
|
||||
className: 'density-dot-container',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2]
|
||||
});
|
||||
|
||||
var marker = L.marker([dot.lat, dot.lng], { icon: icon });
|
||||
|
||||
marker.on('click', function() {
|
||||
self.map.setView([dot.lat, dot.lng], self.map.getZoom() + 2);
|
||||
});
|
||||
|
||||
marker.bindTooltip(dot.count + ' properties', {
|
||||
className: 'density-tooltip'
|
||||
});
|
||||
|
||||
self.densityLayer.addLayer(marker);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get density color based on count and zoom
|
||||
*/
|
||||
getDensityColor: function(count, zoom) {
|
||||
var threshold = Math.max(40, Math.round(600 / Math.pow(1.4, zoom - 3)));
|
||||
var ratio = count / threshold;
|
||||
|
||||
if (ratio >= 1.5) return 'rgba(180, 83, 9, 0.8)';
|
||||
if (ratio >= 1.0) return 'rgba(217, 119, 6, 0.8)';
|
||||
if (ratio >= 0.6) return 'rgba(245, 158, 11, 0.8)';
|
||||
if (ratio >= 0.3) return 'rgba(234, 179, 8, 0.8)';
|
||||
if (ratio >= 0.15) return 'rgba(132, 204, 22, 0.8)';
|
||||
return 'rgba(34, 197, 94, 0.8)';
|
||||
},
|
||||
|
||||
/**
|
||||
* Get density size based on count and zoom
|
||||
*/
|
||||
getDensitySize: function(count, zoom) {
|
||||
var threshold = Math.max(40, Math.round(600 / Math.pow(1.4, zoom - 3)));
|
||||
var ratio = count / threshold;
|
||||
|
||||
if (ratio >= 1.5) return 11;
|
||||
if (ratio >= 1.0) return 10;
|
||||
if (ratio >= 0.6) return 8;
|
||||
if (ratio >= 0.3) return 7;
|
||||
return 6;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render clusters (medium zoom levels)
|
||||
*/
|
||||
renderClusters: function(clusters) {
|
||||
this.clearAllLayers();
|
||||
var self = this;
|
||||
|
||||
clusters.forEach(function(cluster) {
|
||||
var size = 'small';
|
||||
var iconSize = 30;
|
||||
if (cluster.count > 200) {
|
||||
size = 'large';
|
||||
iconSize = 40;
|
||||
} else if (cluster.count >= 100) {
|
||||
size = 'medium';
|
||||
iconSize = 35;
|
||||
}
|
||||
|
||||
var icon = L.divIcon({
|
||||
html: '<div><span>' + cluster.count + '</span></div>',
|
||||
className: 'marker-cluster marker-cluster-' + size + ' server-cluster',
|
||||
iconSize: L.point(iconSize, iconSize)
|
||||
});
|
||||
|
||||
var marker = L.marker([cluster.lat, cluster.lng], { icon: icon });
|
||||
|
||||
marker.on('click', function() {
|
||||
self.map.setView([cluster.lat, cluster.lng], self.map.getZoom() + 2);
|
||||
});
|
||||
|
||||
var priceRange = '$' + self.formatNumber(cluster.min_price);
|
||||
if (cluster.max_price !== cluster.min_price) {
|
||||
priceRange += ' - $' + self.formatNumber(cluster.max_price);
|
||||
}
|
||||
marker.bindTooltip(cluster.count + ' properties<br>' + priceRange, {
|
||||
className: 'cluster-tooltip'
|
||||
});
|
||||
|
||||
self.clusterLayer.addLayer(marker);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Render individual markers (high zoom levels)
|
||||
*/
|
||||
renderMarkers: function(properties) {
|
||||
this.clearAllLayers();
|
||||
var self = this;
|
||||
|
||||
properties.forEach(function(prop, index) {
|
||||
if (!prop.lat || !prop.lng) return;
|
||||
|
||||
// Create price marker
|
||||
var priceLabel = self.formatPrice(prop.price);
|
||||
|
||||
var icon = L.divIcon({
|
||||
className: 'mobile-marker',
|
||||
html: '<div class="mobile-marker-inner">' + priceLabel + '</div>',
|
||||
iconSize: [70, 28],
|
||||
iconAnchor: [35, 28]
|
||||
});
|
||||
|
||||
var marker = L.marker([prop.lat, prop.lng], {
|
||||
icon: icon,
|
||||
zIndexOffset: index
|
||||
});
|
||||
|
||||
marker.on('click', function() {
|
||||
// Navigate directly to property page
|
||||
window.location.href = prop.url;
|
||||
});
|
||||
|
||||
self.markerLayer.addLayer(marker);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Format price for marker label display
|
||||
* Accepts either a number or formatted string like "$375,000"
|
||||
*/
|
||||
formatPrice: function(price) {
|
||||
// If price is a string, strip non-numeric characters
|
||||
if (typeof price === 'string') {
|
||||
price = parseInt(price.replace(/[^0-9]/g, ''), 10);
|
||||
}
|
||||
price = Number(price);
|
||||
if (isNaN(price)) return '$0';
|
||||
if (price >= 1000000) {
|
||||
return '$' + (price / 1000000).toFixed(1) + 'M';
|
||||
} else if (price >= 1000) {
|
||||
return '$' + Math.round(price / 1000) + 'k';
|
||||
}
|
||||
return '$' + price;
|
||||
},
|
||||
|
||||
/**
|
||||
* Format number with commas
|
||||
*/
|
||||
formatNumber: function(num) {
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on document ready
|
||||
$(document).ready(function() {
|
||||
// Only init on mobile
|
||||
if (window.innerWidth < 1024) {
|
||||
MobileSheet.init();
|
||||
MobileMap.init();
|
||||
}
|
||||
});
|
||||
|
||||
// Expose for external access
|
||||
window.MobileSheet = MobileSheet;
|
||||
window.MobileMap = MobileMap;
|
||||
|
||||
})(jQuery);
|
||||
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* Mobile Map View Styles
|
||||
*
|
||||
* Bottom sheet interface for mobile property browsing with map
|
||||
*
|
||||
* @package HomeProz
|
||||
*/
|
||||
|
||||
// Only show mobile map view below 1024px
|
||||
.mobile-map-view {
|
||||
display: none;
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide desktop-only elements on mobile
|
||||
.desktop-only {
|
||||
@media (max-width: 1023px) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen map container
|
||||
.mobile-map-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-bg-dark);
|
||||
|
||||
// Leaflet map fills container
|
||||
.leaflet-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom Sheet
|
||||
.mobile-bottom-sheet {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-bg-dark);
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
border-top: 3px solid var(--color-accent);
|
||||
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000; // Must be higher than Leaflet controls (800)
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: height 0.3s ease-out;
|
||||
will-change: height;
|
||||
max-height: calc(100vh - 60px); // Leave space for map peek
|
||||
|
||||
// Sheet states (2 states: collapsed and expanded)
|
||||
&[data-state="collapsed"] {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
&[data-state="expanded"] {
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
// Disable transitions when dragging
|
||||
&.is-dragging {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Drag Handle
|
||||
.sheet-drag-handle {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-handle-bar {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background-color: var(--color-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
// Sheet Header
|
||||
.sheet-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.sheet-property-count {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
|
||||
span {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-filter-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
background-color: var(--color-bg-dark);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: rgba(159, 55, 48, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
// Sheet Content (scrollable area)
|
||||
.sheet-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
// Filters Section
|
||||
.sheet-filters {
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg-dark);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: none; // Hidden by default
|
||||
|
||||
&.is-visible {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sheet-filter-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&.sheet-filter-reset {
|
||||
justify-content: flex-end;
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-filter-select {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
padding-right: 2rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
// Property List
|
||||
.sheet-property-list {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.sheet-property-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
// Mobile property cards (compact horizontal layout)
|
||||
.sheet-property-card {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--color-bg-dark);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.is-highlighted {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
&.is-highlighted {
|
||||
background-color: rgba(159, 55, 48, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-card-image {
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
height: 80px;
|
||||
background-color: var(--color-bg-card);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 0.375rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.is-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-card-badge {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
left: 0.25rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--color-active);
|
||||
color: white;
|
||||
|
||||
&.badge-pending {
|
||||
background-color: var(--color-pending);
|
||||
}
|
||||
|
||||
&.badge-sold {
|
||||
background-color: var(--color-sold);
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-card-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sheet-card-price {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.sheet-card-address {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sheet-card-specs {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
.sheet-no-properties {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Map marker popup (simplified for mobile)
|
||||
.mobile-map-popup {
|
||||
.leaflet-popup-content-wrapper {
|
||||
background-color: var(--color-bg-card);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
background-color: var(--color-bg-card);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-popup-card {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.mobile-popup-price {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mobile-popup-address {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-popup-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-accent-light);
|
||||
}
|
||||
}
|
||||
|
||||
// Spinner (reuse existing)
|
||||
.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;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom map markers
|
||||
.mobile-marker {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.mobile-marker-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-bg-card);
|
||||
border: 2px solid var(--color-accent);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-text);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-cluster-marker {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.mobile-cluster-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: var(--color-accent);
|
||||
border: 3px solid rgba(255, 255, 255, 0.8);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
// Adjust layout when mobile map is active
|
||||
@media (max-width: 1023px) {
|
||||
// When mobile map view exists, hide header/footer for full-screen map
|
||||
body:has(.mobile-map-view) {
|
||||
.site-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-main {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,12 @@ if ($lot_size) {
|
||||
$agent_name = $property->list_agent_name;
|
||||
$office_name = $property->list_office_name;
|
||||
|
||||
// Check if this is a HomeProz listing
|
||||
$is_homeproz_listing = false;
|
||||
if ($office_name) {
|
||||
$is_homeproz_listing = (stripos($office_name, 'HomeProz') !== false || stripos($office_name, 'Home Proz') !== false);
|
||||
}
|
||||
|
||||
// Set page title to property address
|
||||
$mls_page_title = $full_address;
|
||||
if ($price) {
|
||||
@@ -407,19 +413,22 @@ get_header();
|
||||
|
||||
<!-- 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 if ($is_homeproz_listing) : ?>
|
||||
<h3 class="widget-title">Your HomeProz Agent</h3>
|
||||
<?php if ($agent_name) : ?>
|
||||
<p class="agent-name"><?php echo esc_html($agent_name); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php else : ?>
|
||||
<h3 class="widget-title">Interested in This Property?</h3>
|
||||
<p class="agent-intro">Our team can help you learn more about this listing and schedule a showing.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$inquiry_url = add_query_arg('listing', $listing_key, home_url('/property-inquiry/'));
|
||||
$button_text = $is_homeproz_listing ? 'Schedule a Showing' : 'Inquire About This Property';
|
||||
?>
|
||||
<a href="<?php echo esc_url($inquiry_url); ?>" class="btn btn-primary btn-block">
|
||||
Request More Information
|
||||
<?php echo esc_html($button_text); ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -660,6 +660,13 @@ body.lightbox-open {
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.agent-intro {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Share Widget
|
||||
|
||||
Reference in New Issue
Block a user