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
+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