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:
root
2026-01-23 21:28:44 +00:00
parent c2d5b2248d
commit 57b752f54e
60 changed files with 5323 additions and 189 deletions
@@ -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)
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),
);
}
/**
+133 -2
View File
@@ -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'); ?>
View File
View File
View File
+1 -1
View File
File diff suppressed because one or more lines are too long
Vendored Executable → Regular
+1 -1
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -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 &lt;script&gt;&lt;/script&gt; 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');
+371
View File
@@ -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
View File
@@ -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"
},
@@ -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.
@@ -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
View File
@@ -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.
@@ -0,0 +1 @@
This is the linux-arm64-glibc build of @parcel/watcher. See https://github.com/parcel-bundler/watcher for details.
@@ -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"
@@ -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.
@@ -0,0 +1 @@
This is the linux-arm64-musl build of @parcel/watcher. See https://github.com/parcel-bundler/watcher for details.
@@ -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"
]
}
@@ -1 +0,0 @@
This is the linux-x64-glibc build of @parcel/watcher. See https://github.com/parcel-bundler/watcher for details.
@@ -0,0 +1,3 @@
# `@rollup/rollup-linux-arm64-gnu`
This is the **aarch64-unknown-linux-gnu** binary for `rollup`
@@ -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"
}
@@ -0,0 +1,3 @@
# `@rollup/rollup-linux-arm64-musl`
This is the **aarch64-unknown-linux-musl** binary for `rollup`
@@ -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"
}
@@ -1,3 +0,0 @@
# `@rollup/rollup-linux-x64-gnu`
This is the **x86_64-unknown-linux-gnu** binary for `rollup`
+5
View File
@@ -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) {
+1
View File
@@ -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';
+2
View File
@@ -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(' &bull; ') + '</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: '&copy; 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