57b752f54e
- 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>
1257 lines
43 KiB
PHP
Executable File
1257 lines
43 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* Manual Property Entry System
|
|
*
|
|
* Allows manual entry of properties that integrate with MLS-synced listings.
|
|
* Supports override of MLS listings by matching MLS ID.
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
class MLS_Manual_Property {
|
|
|
|
/**
|
|
* @var MLS_DB
|
|
*/
|
|
private $db;
|
|
|
|
/**
|
|
* @var MLS_Geocoder
|
|
*/
|
|
private $geocoder;
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
public function __construct($db = null) {
|
|
$this->db = $db ?: new MLS_DB();
|
|
|
|
// Load geocoder if available
|
|
if (class_exists('MLS_Geocoder')) {
|
|
$this->geocoder = new MLS_Geocoder();
|
|
}
|
|
|
|
$this->init_hooks();
|
|
}
|
|
|
|
/**
|
|
* Initialize hooks
|
|
*/
|
|
private function init_hooks() {
|
|
// Register CPT
|
|
add_action('init', array($this, 'register_post_type'));
|
|
|
|
// Register ACF fields
|
|
add_action('acf/init', array($this, 'register_acf_fields'));
|
|
|
|
// Sync to database on save
|
|
add_action('acf/save_post', array($this, 'sync_to_database'), 20);
|
|
|
|
// Delete from database on trash/delete
|
|
add_action('wp_trash_post', array($this, 'on_trash_post'));
|
|
add_action('before_delete_post', array($this, 'on_delete_post'));
|
|
|
|
// Restore from trash
|
|
add_action('untrashed_post', array($this, 'on_untrash_post'));
|
|
|
|
// Admin columns
|
|
add_filter('manage_manual_property_posts_columns', array($this, 'add_admin_columns'));
|
|
add_action('manage_manual_property_posts_custom_column', array($this, 'render_admin_columns'), 10, 2);
|
|
add_filter('manage_edit-manual_property_sortable_columns', array($this, 'sortable_columns'));
|
|
|
|
// Admin row actions
|
|
add_filter('post_row_actions', array($this, 'add_row_actions'), 10, 2);
|
|
|
|
// Clone from MLS AJAX handlers
|
|
add_action('wp_ajax_mls_search_for_clone', array($this, 'ajax_search_for_clone'));
|
|
add_action('wp_ajax_mls_clone_listing', array($this, 'ajax_clone_listing'));
|
|
|
|
// Admin scripts
|
|
add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_scripts'));
|
|
|
|
// Add clone button to admin
|
|
add_action('edit_form_top', array($this, 'render_clone_button'));
|
|
}
|
|
|
|
/**
|
|
* Register the Custom Post Type
|
|
*/
|
|
public function register_post_type() {
|
|
$labels = array(
|
|
'name' => 'Manual Properties',
|
|
'singular_name' => 'Manual Property',
|
|
'menu_name' => 'Manual Properties',
|
|
'add_new' => 'Add Property',
|
|
'add_new_item' => 'Add New Property',
|
|
'edit_item' => 'Edit Property',
|
|
'new_item' => 'New Property',
|
|
'view_item' => 'View Property',
|
|
'search_items' => 'Search Properties',
|
|
'not_found' => 'No properties found',
|
|
'not_found_in_trash' => 'No properties found in trash',
|
|
'all_items' => 'All Properties',
|
|
);
|
|
|
|
$args = array(
|
|
'labels' => $labels,
|
|
'public' => false,
|
|
'show_ui' => true,
|
|
'show_in_menu' => true,
|
|
'menu_position' => 26,
|
|
'menu_icon' => 'dashicons-building',
|
|
'capability_type' => 'post',
|
|
'hierarchical' => false,
|
|
'supports' => array('title'),
|
|
'has_archive' => false,
|
|
'rewrite' => false,
|
|
'query_var' => false,
|
|
'show_in_rest' => false,
|
|
);
|
|
|
|
register_post_type('manual_property', $args);
|
|
}
|
|
|
|
/**
|
|
* Register ACF fields programmatically
|
|
*/
|
|
public function register_acf_fields() {
|
|
if (!function_exists('acf_add_local_field_group')) {
|
|
return;
|
|
}
|
|
|
|
acf_add_local_field_group(array(
|
|
'key' => 'group_manual_property',
|
|
'title' => 'Property Details',
|
|
'fields' => $this->get_field_definitions(),
|
|
'location' => array(
|
|
array(
|
|
array(
|
|
'param' => 'post_type',
|
|
'operator' => '==',
|
|
'value' => 'manual_property',
|
|
),
|
|
),
|
|
),
|
|
'menu_order' => 0,
|
|
'position' => 'normal',
|
|
'style' => 'default',
|
|
'label_placement' => 'top',
|
|
'instruction_placement' => 'label',
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Get ACF field definitions
|
|
*/
|
|
private function get_field_definitions() {
|
|
return array(
|
|
// Tab: Basic Info
|
|
array(
|
|
'key' => 'field_mp_tab_basic',
|
|
'label' => 'Basic Info',
|
|
'type' => 'tab',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_listing_id',
|
|
'label' => 'MLS #',
|
|
'name' => 'listing_id',
|
|
'type' => 'text',
|
|
'instructions' => 'Optional. If set, this listing will override any MLS-synced listing with the same MLS ID.',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_standard_status',
|
|
'label' => 'Status',
|
|
'name' => 'standard_status',
|
|
'type' => 'select',
|
|
'choices' => array(
|
|
'Active' => 'Active',
|
|
'Pending' => 'Pending',
|
|
'Sold' => 'Sold',
|
|
'Cancelled' => 'Cancelled',
|
|
'Expired' => 'Expired',
|
|
'Withdrawn' => 'Withdrawn',
|
|
),
|
|
'default_value' => 'Active',
|
|
'required' => 1,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_list_price',
|
|
'label' => 'List Price',
|
|
'name' => 'list_price',
|
|
'type' => 'number',
|
|
'prepend' => '$',
|
|
'min' => 0,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_close_price',
|
|
'label' => 'Close Price',
|
|
'name' => 'close_price',
|
|
'type' => 'number',
|
|
'prepend' => '$',
|
|
'min' => 0,
|
|
'instructions' => 'Final sale price (for sold properties).',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_property_type',
|
|
'label' => 'Property Type',
|
|
'name' => 'property_type',
|
|
'type' => 'select',
|
|
'choices' => array(
|
|
'Residential' => 'Residential',
|
|
'Land' => 'Land',
|
|
'Commercial' => 'Commercial',
|
|
'Multi-Family' => 'Multi-Family',
|
|
'Farm' => 'Farm',
|
|
),
|
|
'default_value' => 'Residential',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_property_sub_type',
|
|
'label' => 'Property Subtype',
|
|
'name' => 'property_sub_type',
|
|
'type' => 'text',
|
|
'instructions' => 'E.g., Single Family, Townhouse, Condo, etc.',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_is_homeproz',
|
|
'label' => 'HomeProz Listing',
|
|
'name' => 'is_homeproz',
|
|
'type' => 'true_false',
|
|
'message' => 'This is a HomeProz listing',
|
|
'default_value' => 1,
|
|
'ui' => 1,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_is_featured',
|
|
'label' => 'Featured Listing',
|
|
'name' => 'is_featured',
|
|
'type' => 'true_false',
|
|
'message' => 'Feature this listing on the homepage',
|
|
'default_value' => 0,
|
|
'ui' => 1,
|
|
),
|
|
|
|
// Tab: Location
|
|
array(
|
|
'key' => 'field_mp_tab_location',
|
|
'label' => 'Location',
|
|
'type' => 'tab',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_full_address',
|
|
'label' => 'Full Address',
|
|
'name' => 'full_address',
|
|
'type' => 'text',
|
|
'instructions' => 'Enter the full address. City, state, zip, and coordinates will be auto-filled via geocoding.',
|
|
'placeholder' => '123 Main St, Albert Lea, MN 56007',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_unit_number',
|
|
'label' => 'Unit Number',
|
|
'name' => 'unit_number',
|
|
'type' => 'text',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_city',
|
|
'label' => 'City',
|
|
'name' => 'city',
|
|
'type' => 'text',
|
|
'instructions' => 'Auto-filled from geocoding, but can be edited.',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_state',
|
|
'label' => 'State',
|
|
'name' => 'state_or_province',
|
|
'type' => 'text',
|
|
'default_value' => 'MN',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_postal_code',
|
|
'label' => 'ZIP Code',
|
|
'name' => 'postal_code',
|
|
'type' => 'text',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_county',
|
|
'label' => 'County',
|
|
'name' => 'county',
|
|
'type' => 'text',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_latitude',
|
|
'label' => 'Latitude',
|
|
'name' => 'latitude',
|
|
'type' => 'number',
|
|
'step' => 'any',
|
|
'instructions' => 'Auto-filled from geocoding.',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_longitude',
|
|
'label' => 'Longitude',
|
|
'name' => 'longitude',
|
|
'type' => 'number',
|
|
'step' => 'any',
|
|
'instructions' => 'Auto-filled from geocoding.',
|
|
),
|
|
|
|
// Tab: Property Details
|
|
array(
|
|
'key' => 'field_mp_tab_details',
|
|
'label' => 'Details',
|
|
'type' => 'tab',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_beds',
|
|
'label' => 'Bedrooms',
|
|
'name' => 'bedrooms_total',
|
|
'type' => 'number',
|
|
'min' => 0,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_baths',
|
|
'label' => 'Bathrooms',
|
|
'name' => 'bathrooms_total',
|
|
'type' => 'number',
|
|
'min' => 0,
|
|
'step' => 0.5,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_baths_full',
|
|
'label' => 'Full Bathrooms',
|
|
'name' => 'bathrooms_full',
|
|
'type' => 'number',
|
|
'min' => 0,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_baths_half',
|
|
'label' => 'Half Bathrooms',
|
|
'name' => 'bathrooms_half',
|
|
'type' => 'number',
|
|
'min' => 0,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_sqft',
|
|
'label' => 'Square Feet',
|
|
'name' => 'living_area',
|
|
'type' => 'number',
|
|
'min' => 0,
|
|
'append' => 'sq ft',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_lot_acres',
|
|
'label' => 'Lot Size (Acres)',
|
|
'name' => 'lot_size_area',
|
|
'type' => 'number',
|
|
'min' => 0,
|
|
'step' => 0.01,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_year_built',
|
|
'label' => 'Year Built',
|
|
'name' => 'year_built',
|
|
'type' => 'number',
|
|
'min' => 1800,
|
|
'max' => 2100,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_stories',
|
|
'label' => 'Stories',
|
|
'name' => 'stories',
|
|
'type' => 'number',
|
|
'min' => 0,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_garage',
|
|
'label' => 'Garage Spaces',
|
|
'name' => 'garage_spaces',
|
|
'type' => 'number',
|
|
'min' => 0,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_style',
|
|
'label' => 'Architectural Style',
|
|
'name' => 'architectural_style',
|
|
'type' => 'text',
|
|
),
|
|
|
|
// Tab: Description
|
|
array(
|
|
'key' => 'field_mp_tab_description',
|
|
'label' => 'Description',
|
|
'type' => 'tab',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_remarks',
|
|
'label' => 'Public Description',
|
|
'name' => 'public_remarks',
|
|
'type' => 'textarea',
|
|
'rows' => 6,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_private_remarks',
|
|
'label' => 'Private Notes',
|
|
'name' => 'private_remarks',
|
|
'type' => 'textarea',
|
|
'rows' => 3,
|
|
'instructions' => 'Internal notes (not shown on frontend).',
|
|
),
|
|
|
|
// Tab: Media
|
|
array(
|
|
'key' => 'field_mp_tab_media',
|
|
'label' => 'Media',
|
|
'type' => 'tab',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_gallery',
|
|
'label' => 'Property Photos',
|
|
'name' => 'gallery',
|
|
'type' => 'gallery',
|
|
'return_format' => 'array',
|
|
'preview_size' => 'medium',
|
|
'library' => 'all',
|
|
'min' => 0,
|
|
'max' => 50,
|
|
'instructions' => 'Upload property photos. First image is the primary photo.',
|
|
),
|
|
|
|
// Tab: Agent
|
|
array(
|
|
'key' => 'field_mp_tab_agent',
|
|
'label' => 'Agent',
|
|
'type' => 'tab',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_agent',
|
|
'label' => 'Listing Agent',
|
|
'name' => 'agent',
|
|
'type' => 'post_object',
|
|
'post_type' => array('agent'),
|
|
'return_format' => 'id',
|
|
'allow_null' => 1,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_co_agent',
|
|
'label' => 'Co-Listing Agent',
|
|
'name' => 'co_list_agent',
|
|
'type' => 'post_object',
|
|
'post_type' => array('agent'),
|
|
'return_format' => 'id',
|
|
'allow_null' => 1,
|
|
),
|
|
|
|
// Tab: Dates
|
|
array(
|
|
'key' => 'field_mp_tab_dates',
|
|
'label' => 'Dates',
|
|
'type' => 'tab',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_list_date',
|
|
'label' => 'List Date',
|
|
'name' => 'list_date',
|
|
'type' => 'date_picker',
|
|
'display_format' => 'F j, Y',
|
|
'return_format' => 'Y-m-d',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_contract_date',
|
|
'label' => 'Contract Date',
|
|
'name' => 'contract_date',
|
|
'type' => 'date_picker',
|
|
'display_format' => 'F j, Y',
|
|
'return_format' => 'Y-m-d',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_close_date',
|
|
'label' => 'Close Date',
|
|
'name' => 'close_date',
|
|
'type' => 'date_picker',
|
|
'display_format' => 'F j, Y',
|
|
'return_format' => 'Y-m-d',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_expiration_date',
|
|
'label' => 'Expiration Date',
|
|
'name' => 'expiration_date',
|
|
'type' => 'date_picker',
|
|
'display_format' => 'F j, Y',
|
|
'return_format' => 'Y-m-d',
|
|
),
|
|
|
|
// Tab: Additional
|
|
array(
|
|
'key' => 'field_mp_tab_additional',
|
|
'label' => 'Additional',
|
|
'type' => 'tab',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_virtual_tour',
|
|
'label' => 'Virtual Tour URL',
|
|
'name' => 'virtual_tour_url',
|
|
'type' => 'url',
|
|
),
|
|
array(
|
|
'key' => 'field_mp_directions',
|
|
'label' => 'Directions',
|
|
'name' => 'directions',
|
|
'type' => 'textarea',
|
|
'rows' => 3,
|
|
),
|
|
array(
|
|
'key' => 'field_mp_hoa_fee',
|
|
'label' => 'HOA/Association Fee',
|
|
'name' => 'association_fee',
|
|
'type' => 'number',
|
|
'prepend' => '$',
|
|
'append' => '/month',
|
|
'min' => 0,
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Sync property data to database on save
|
|
*/
|
|
public function sync_to_database($post_id) {
|
|
// Only for manual_property
|
|
if (get_post_type($post_id) !== 'manual_property') {
|
|
return;
|
|
}
|
|
|
|
// Skip autosave
|
|
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
|
|
return;
|
|
}
|
|
|
|
// Skip revisions
|
|
if (wp_is_post_revision($post_id)) {
|
|
return;
|
|
}
|
|
|
|
// Skip if trashed
|
|
if (get_post_status($post_id) === 'trash') {
|
|
return;
|
|
}
|
|
|
|
global $wpdb;
|
|
|
|
// Generate listing key
|
|
$listing_key = 'MANUAL-' . $post_id;
|
|
|
|
// Geocode address if changed
|
|
$full_address = get_field('full_address', $post_id);
|
|
$this->maybe_geocode_address($post_id, $full_address);
|
|
|
|
// Parse address components
|
|
$address_parts = $this->parse_address($full_address);
|
|
|
|
// Get gallery photos count
|
|
$gallery = get_field('gallery', $post_id);
|
|
$photos_count = is_array($gallery) ? count($gallery) : 0;
|
|
|
|
// Prepare data
|
|
$data = array(
|
|
'wp_post_id' => $post_id,
|
|
'listing_key' => $listing_key,
|
|
'listing_id' => get_field('listing_id', $post_id) ?: null,
|
|
'standard_status' => get_field('standard_status', $post_id) ?: 'Active',
|
|
'list_price' => get_field('list_price', $post_id) ?: null,
|
|
'close_price' => get_field('close_price', $post_id) ?: null,
|
|
'original_list_price' => get_field('list_price', $post_id) ?: null,
|
|
'street_number' => $address_parts['street_number'] ?? null,
|
|
'street_name' => $address_parts['street_name'] ?? null,
|
|
'street_suffix' => $address_parts['street_suffix'] ?? null,
|
|
'unit_number' => get_field('unit_number', $post_id) ?: null,
|
|
'full_address' => $full_address ?: null,
|
|
'city' => get_field('city', $post_id) ?: null,
|
|
'state_or_province' => get_field('state_or_province', $post_id) ?: 'MN',
|
|
'postal_code' => get_field('postal_code', $post_id) ?: null,
|
|
'county' => get_field('county', $post_id) ?: null,
|
|
'latitude' => get_field('latitude', $post_id) ?: null,
|
|
'longitude' => get_field('longitude', $post_id) ?: null,
|
|
'property_type' => get_field('property_type', $post_id) ?: null,
|
|
'property_sub_type' => get_field('property_sub_type', $post_id) ?: null,
|
|
'bedrooms_total' => get_field('bedrooms_total', $post_id) ?: null,
|
|
'bathrooms_total' => get_field('bathrooms_total', $post_id) ?: null,
|
|
'bathrooms_full' => get_field('bathrooms_full', $post_id) ?: null,
|
|
'bathrooms_half' => get_field('bathrooms_half', $post_id) ?: null,
|
|
'living_area' => get_field('living_area', $post_id) ?: null,
|
|
'lot_size_area' => get_field('lot_size_area', $post_id) ?: null,
|
|
'lot_size_units' => 'Acres',
|
|
'year_built' => get_field('year_built', $post_id) ?: null,
|
|
'stories' => get_field('stories', $post_id) ?: null,
|
|
'garage_spaces' => get_field('garage_spaces', $post_id) ?: null,
|
|
'architectural_style' => get_field('architectural_style', $post_id) ?: null,
|
|
'public_remarks' => get_field('public_remarks', $post_id) ?: null,
|
|
'private_remarks' => get_field('private_remarks', $post_id) ?: null,
|
|
'directions' => get_field('directions', $post_id) ?: null,
|
|
'list_agent_post_id' => get_field('agent', $post_id) ?: null,
|
|
'co_list_agent_post_id' => get_field('co_list_agent', $post_id) ?: null,
|
|
'is_homeproz' => get_field('is_homeproz', $post_id) ? 1 : 0,
|
|
'is_featured' => get_field('is_featured', $post_id) ? 1 : 0,
|
|
'virtual_tour_url' => get_field('virtual_tour_url', $post_id) ?: null,
|
|
'association_fee' => get_field('association_fee', $post_id) ?: null,
|
|
'list_date' => get_field('list_date', $post_id) ?: null,
|
|
'contract_date' => get_field('contract_date', $post_id) ?: null,
|
|
'close_date' => get_field('close_date', $post_id) ?: null,
|
|
'expiration_date' => get_field('expiration_date', $post_id) ?: null,
|
|
'photos_count' => $photos_count,
|
|
);
|
|
|
|
// Check if record exists
|
|
$table = $this->db->manual_properties_table();
|
|
$exists = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT id FROM {$table} WHERE wp_post_id = %d",
|
|
$post_id
|
|
));
|
|
|
|
if ($exists) {
|
|
$wpdb->update($table, $data, array('wp_post_id' => $post_id));
|
|
} else {
|
|
$wpdb->insert($table, $data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Geocode address if changed
|
|
*/
|
|
private function maybe_geocode_address($post_id, $full_address) {
|
|
if (!$this->geocoder || empty($full_address)) {
|
|
return;
|
|
}
|
|
|
|
// Check if address changed
|
|
$stored_address = get_post_meta($post_id, '_geocoded_address', true);
|
|
if ($stored_address === $full_address) {
|
|
return; // Already geocoded
|
|
}
|
|
|
|
// Geocode the address
|
|
$result = $this->geocoder->geocode($full_address);
|
|
if (!$result) {
|
|
return;
|
|
}
|
|
|
|
// Update fields
|
|
if (!empty($result['city'])) {
|
|
update_field('city', $result['city'], $post_id);
|
|
}
|
|
if (!empty($result['state'])) {
|
|
update_field('state_or_province', $result['state'], $post_id);
|
|
}
|
|
if (!empty($result['postal_code'])) {
|
|
update_field('postal_code', $result['postal_code'], $post_id);
|
|
}
|
|
if (!empty($result['county'])) {
|
|
update_field('county', $result['county'], $post_id);
|
|
}
|
|
if (!empty($result['latitude'])) {
|
|
update_field('latitude', $result['latitude'], $post_id);
|
|
}
|
|
if (!empty($result['longitude'])) {
|
|
update_field('longitude', $result['longitude'], $post_id);
|
|
}
|
|
|
|
// Mark as geocoded
|
|
update_post_meta($post_id, '_geocoded_address', $full_address);
|
|
}
|
|
|
|
/**
|
|
* Parse address string into components
|
|
*/
|
|
private function parse_address($address) {
|
|
if (empty($address)) {
|
|
return array();
|
|
}
|
|
|
|
$parts = array();
|
|
|
|
// Try to extract street number
|
|
if (preg_match('/^(\d+)\s+/', $address, $matches)) {
|
|
$parts['street_number'] = $matches[1];
|
|
$address = trim(substr($address, strlen($matches[0])));
|
|
}
|
|
|
|
// Try to extract street suffix from common patterns
|
|
$suffixes = array('St', 'Street', 'Ave', 'Avenue', 'Blvd', 'Boulevard', 'Dr', 'Drive', 'Ln', 'Lane', 'Rd', 'Road', 'Way', 'Ct', 'Court', 'Cir', 'Circle', 'Pl', 'Place');
|
|
foreach ($suffixes as $suffix) {
|
|
if (preg_match('/^(.+?)\s+(' . preg_quote($suffix, '/') . ')\b/i', $address, $matches)) {
|
|
$parts['street_name'] = $matches[1];
|
|
$parts['street_suffix'] = $matches[2];
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $parts;
|
|
}
|
|
|
|
/**
|
|
* Handle post trash
|
|
*/
|
|
public function on_trash_post($post_id) {
|
|
if (get_post_type($post_id) !== 'manual_property') {
|
|
return;
|
|
}
|
|
|
|
global $wpdb;
|
|
$table = $this->db->manual_properties_table();
|
|
|
|
// Update status to indicate trashed (optional: could also delete)
|
|
$wpdb->update(
|
|
$table,
|
|
array('standard_status' => 'Withdrawn'),
|
|
array('wp_post_id' => $post_id)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle post delete
|
|
*/
|
|
public function on_delete_post($post_id) {
|
|
if (get_post_type($post_id) !== 'manual_property') {
|
|
return;
|
|
}
|
|
|
|
global $wpdb;
|
|
$table = $this->db->manual_properties_table();
|
|
|
|
$wpdb->delete($table, array('wp_post_id' => $post_id));
|
|
}
|
|
|
|
/**
|
|
* Handle post restore from trash
|
|
*/
|
|
public function on_untrash_post($post_id) {
|
|
if (get_post_type($post_id) !== 'manual_property') {
|
|
return;
|
|
}
|
|
|
|
// Re-sync to database
|
|
$this->sync_to_database($post_id);
|
|
}
|
|
|
|
/**
|
|
* Add admin columns
|
|
*/
|
|
public function add_admin_columns($columns) {
|
|
$new_columns = array();
|
|
|
|
foreach ($columns as $key => $value) {
|
|
$new_columns[$key] = $value;
|
|
if ($key === 'title') {
|
|
$new_columns['mp_status'] = 'Status';
|
|
$new_columns['mp_price'] = 'Price';
|
|
$new_columns['mp_city'] = 'City';
|
|
$new_columns['mp_homeproz'] = 'HomeProz';
|
|
$new_columns['mp_featured'] = 'Featured';
|
|
$new_columns['mp_view'] = 'View';
|
|
}
|
|
}
|
|
|
|
// Remove date column, move to end
|
|
if (isset($new_columns['date'])) {
|
|
unset($new_columns['date']);
|
|
$new_columns['date'] = 'Date';
|
|
}
|
|
|
|
return $new_columns;
|
|
}
|
|
|
|
/**
|
|
* Render admin column values
|
|
*/
|
|
public function render_admin_columns($column, $post_id) {
|
|
switch ($column) {
|
|
case 'mp_status':
|
|
$status = get_field('standard_status', $post_id);
|
|
$status_colors = array(
|
|
'Active' => '#28a745',
|
|
'Pending' => '#ffc107',
|
|
'Sold' => '#6c757d',
|
|
'Cancelled' => '#dc3545',
|
|
'Expired' => '#6c757d',
|
|
'Withdrawn' => '#6c757d',
|
|
);
|
|
$color = $status_colors[$status] ?? '#6c757d';
|
|
echo '<span style="background:' . esc_attr($color) . ';color:#fff;padding:2px 8px;border-radius:3px;font-size:12px;">' . esc_html($status) . '</span>';
|
|
break;
|
|
|
|
case 'mp_price':
|
|
$price = get_field('list_price', $post_id);
|
|
echo $price ? '$' . number_format($price) : '-';
|
|
break;
|
|
|
|
case 'mp_city':
|
|
echo esc_html(get_field('city', $post_id) ?: '-');
|
|
break;
|
|
|
|
case 'mp_homeproz':
|
|
echo get_field('is_homeproz', $post_id) ? '<span style="color:green;">Yes</span>' : 'No';
|
|
break;
|
|
|
|
case 'mp_featured':
|
|
echo get_field('is_featured', $post_id) ? '<span style="color:green;">Yes</span>' : 'No';
|
|
break;
|
|
|
|
case 'mp_view':
|
|
$listing_key = 'MANUAL-' . $post_id;
|
|
$url = add_query_arg('listing', $listing_key, home_url('/properties/'));
|
|
echo '<a href="' . esc_url($url) . '" target="_blank">View</a>';
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make columns sortable
|
|
*/
|
|
public function sortable_columns($columns) {
|
|
$columns['mp_status'] = 'mp_status';
|
|
$columns['mp_price'] = 'mp_price';
|
|
$columns['mp_city'] = 'mp_city';
|
|
return $columns;
|
|
}
|
|
|
|
/**
|
|
* Add row actions
|
|
*/
|
|
public function add_row_actions($actions, $post) {
|
|
if ($post->post_type !== 'manual_property') {
|
|
return $actions;
|
|
}
|
|
|
|
// Add view link
|
|
$listing_key = 'MANUAL-' . $post->ID;
|
|
$url = add_query_arg('listing', $listing_key, home_url('/properties/'));
|
|
$actions['view_property'] = '<a href="' . esc_url($url) . '" target="_blank">View Property</a>';
|
|
|
|
return $actions;
|
|
}
|
|
|
|
/**
|
|
* Enqueue admin scripts
|
|
*/
|
|
public function enqueue_admin_scripts($hook) {
|
|
global $post_type;
|
|
|
|
if ($post_type !== 'manual_property') {
|
|
return;
|
|
}
|
|
|
|
wp_enqueue_script(
|
|
'mls-manual-property-admin',
|
|
MLS_PLUGIN_URL . 'admin/js/manual-property.js',
|
|
array('jquery'),
|
|
MLS_PLUGIN_VERSION,
|
|
true
|
|
);
|
|
|
|
wp_localize_script('mls-manual-property-admin', 'mlsManualProperty', array(
|
|
'ajaxUrl' => admin_url('admin-ajax.php'),
|
|
'nonce' => wp_create_nonce('mls_clone_listing'),
|
|
));
|
|
|
|
wp_enqueue_style(
|
|
'mls-manual-property-admin',
|
|
MLS_PLUGIN_URL . 'admin/css/manual-property.css',
|
|
array(),
|
|
MLS_PLUGIN_VERSION
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Render clone button on add new page
|
|
*/
|
|
public function render_clone_button($post) {
|
|
if ($post->post_type !== 'manual_property') {
|
|
return;
|
|
}
|
|
|
|
// Only show on new posts
|
|
if ($post->post_status !== 'auto-draft') {
|
|
return;
|
|
}
|
|
|
|
?>
|
|
<div id="mls-clone-section" style="background:#f0f0f1;border:1px solid #c3c4c7;padding:15px;margin-bottom:20px;border-radius:4px;">
|
|
<h3 style="margin-top:0;">Clone from MLS Listing</h3>
|
|
<p>Enter an MLS ID to copy data from an existing MLS listing:</p>
|
|
<input type="text" id="mls-clone-search" placeholder="Enter MLS ID (e.g., 6693299)" style="width:200px;">
|
|
<button type="button" id="mls-clone-btn" class="button button-secondary">Clone Listing</button>
|
|
<span id="mls-clone-status" style="margin-left:10px;"></span>
|
|
</div>
|
|
<?php
|
|
}
|
|
|
|
/**
|
|
* AJAX: Search for MLS listing to clone
|
|
*/
|
|
public function ajax_search_for_clone() {
|
|
check_ajax_referer('mls_clone_listing', 'nonce');
|
|
|
|
if (!current_user_can('edit_posts')) {
|
|
wp_send_json_error('Unauthorized');
|
|
}
|
|
|
|
$mls_id = sanitize_text_field($_POST['mls_id'] ?? '');
|
|
if (empty($mls_id)) {
|
|
wp_send_json_error('MLS ID required');
|
|
}
|
|
|
|
$property = mls_get_property($mls_id);
|
|
if (!$property) {
|
|
wp_send_json_error('Listing not found');
|
|
}
|
|
|
|
wp_send_json_success(array(
|
|
'listing_key' => $property->listing_key,
|
|
'listing_id' => $property->listing_id,
|
|
'address' => trim($property->street_number . ' ' . $property->street_name . ' ' . $property->street_suffix),
|
|
'city' => $property->city,
|
|
'price' => $property->list_price,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* AJAX: Clone MLS listing
|
|
*/
|
|
public function ajax_clone_listing() {
|
|
check_ajax_referer('mls_clone_listing', 'nonce');
|
|
|
|
if (!current_user_can('edit_posts')) {
|
|
wp_send_json_error('Unauthorized');
|
|
}
|
|
|
|
$listing_key = sanitize_text_field($_POST['listing_key'] ?? '');
|
|
$post_id = intval($_POST['post_id'] ?? 0);
|
|
|
|
if (empty($listing_key) || !$post_id) {
|
|
wp_send_json_error('Missing parameters');
|
|
}
|
|
|
|
$property = mls_get_property($listing_key);
|
|
if (!$property) {
|
|
wp_send_json_error('Listing not found');
|
|
}
|
|
|
|
// Clone field values
|
|
$this->clone_property_fields($post_id, $property);
|
|
|
|
// Clone images
|
|
$images_imported = $this->clone_property_images($post_id, $listing_key);
|
|
|
|
wp_send_json_success(array(
|
|
'message' => 'Listing cloned successfully',
|
|
'images_imported' => $images_imported,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Clone property fields from MLS listing
|
|
*/
|
|
private function clone_property_fields($post_id, $property) {
|
|
// Update post title and change status from auto-draft to publish
|
|
$address = trim($property->street_number . ' ' . $property->street_name . ' ' . $property->street_suffix);
|
|
$title = $address . ', ' . $property->city . ', ' . $property->state_or_province . ' ' . $property->postal_code;
|
|
wp_update_post(array(
|
|
'ID' => $post_id,
|
|
'post_title' => $title,
|
|
'post_status' => 'publish',
|
|
));
|
|
|
|
// Map MLS fields to ACF fields
|
|
$field_map = array(
|
|
'listing_id' => $property->listing_id,
|
|
'standard_status' => $property->standard_status,
|
|
'list_price' => $property->list_price,
|
|
'close_price' => $property->close_price,
|
|
'property_type' => $property->property_type,
|
|
'property_sub_type' => $property->property_sub_type,
|
|
'is_homeproz' => $property->is_homeproz ? 1 : 0,
|
|
'full_address' => $address . ', ' . $property->city . ', ' . $property->state_or_province . ' ' . $property->postal_code,
|
|
'unit_number' => $property->unit_number,
|
|
'city' => $property->city,
|
|
'state_or_province' => $property->state_or_province,
|
|
'postal_code' => $property->postal_code,
|
|
'county' => $property->county,
|
|
'latitude' => $property->latitude,
|
|
'longitude' => $property->longitude,
|
|
'bedrooms_total' => $property->bedrooms_total,
|
|
'bathrooms_total' => $property->bathrooms_total,
|
|
'bathrooms_full' => $property->bathrooms_full,
|
|
'bathrooms_half' => $property->bathrooms_half,
|
|
'living_area' => $property->living_area,
|
|
'lot_size_area' => $property->lot_size_area,
|
|
'year_built' => $property->year_built,
|
|
'garage_spaces' => $property->garage_spaces,
|
|
'public_remarks' => $property->public_remarks,
|
|
'directions' => $property->directions,
|
|
'list_date' => $property->listing_contract_date,
|
|
'close_date' => $property->close_date,
|
|
);
|
|
|
|
foreach ($field_map as $field_name => $value) {
|
|
if ($value !== null && $value !== '') {
|
|
update_field($field_name, $value, $post_id);
|
|
}
|
|
}
|
|
|
|
// Match and set listing agent by MLS ID
|
|
if (!empty($property->list_agent_mls_id)) {
|
|
$agent_post_id = $this->find_agent_by_mls_id($property->list_agent_mls_id);
|
|
if ($agent_post_id) {
|
|
update_field('agent', $agent_post_id, $post_id);
|
|
}
|
|
}
|
|
|
|
// Match and set co-listing agent by MLS ID
|
|
if (!empty($property->co_list_agent_mls_id)) {
|
|
$co_agent_post_id = $this->find_agent_by_mls_id($property->co_list_agent_mls_id);
|
|
if ($co_agent_post_id) {
|
|
update_field('co_list_agent', $co_agent_post_id, $post_id);
|
|
}
|
|
}
|
|
|
|
// Mark the geocoded address
|
|
update_post_meta($post_id, '_geocoded_address', $field_map['full_address']);
|
|
}
|
|
|
|
/**
|
|
* Clone property images from MLS listing
|
|
*/
|
|
private function clone_property_images($post_id, $listing_key) {
|
|
// Get all images for the listing (fetch up to 20 on-demand)
|
|
$media = mls_get_property_images($listing_key, 20);
|
|
|
|
if (empty($media)) {
|
|
return 0;
|
|
}
|
|
|
|
$attachment_ids = array();
|
|
$imported = 0;
|
|
|
|
foreach ($media as $item) {
|
|
// Try local_url first, then use the image endpoint URL which fetches on-demand
|
|
$image_url = null;
|
|
|
|
if (!empty($item->local_url)) {
|
|
$image_url = $item->local_url;
|
|
} elseif (function_exists('mls_get_image_url')) {
|
|
// Use the image endpoint which will fetch on-demand and return the image
|
|
$image_url = mls_get_image_url($listing_key, $item->media_order, 'full');
|
|
}
|
|
|
|
if (empty($image_url)) {
|
|
continue;
|
|
}
|
|
|
|
// Download image to media library
|
|
$attachment_id = $this->sideload_image($image_url, $post_id);
|
|
if ($attachment_id) {
|
|
$attachment_ids[] = $attachment_id;
|
|
$imported++;
|
|
}
|
|
|
|
// Limit to 20 images to avoid timeout
|
|
if ($imported >= 20) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Update gallery field
|
|
if (!empty($attachment_ids)) {
|
|
update_field('gallery', $attachment_ids, $post_id);
|
|
}
|
|
|
|
return $imported;
|
|
}
|
|
|
|
/**
|
|
* Find an Agent CPT post by MLS ID
|
|
*
|
|
* @param string $mls_id The agent's MLS ID
|
|
* @return int|null Agent post ID or null if not found
|
|
*/
|
|
private function find_agent_by_mls_id($mls_id) {
|
|
if (empty($mls_id)) {
|
|
return null;
|
|
}
|
|
|
|
$agent_query = new WP_Query(array(
|
|
'post_type' => 'agent',
|
|
'posts_per_page' => 1,
|
|
'post_status' => 'publish',
|
|
'meta_query' => array(
|
|
array(
|
|
'key' => 'agent_mls_id',
|
|
'value' => $mls_id,
|
|
'compare' => '=',
|
|
),
|
|
),
|
|
));
|
|
|
|
$agent_id = null;
|
|
if ($agent_query->have_posts()) {
|
|
$agent_id = $agent_query->posts[0]->ID;
|
|
}
|
|
wp_reset_postdata();
|
|
|
|
return $agent_id;
|
|
}
|
|
|
|
/**
|
|
* Sideload image to media library
|
|
*/
|
|
private function sideload_image($url, $post_id) {
|
|
require_once ABSPATH . 'wp-admin/includes/media.php';
|
|
require_once ABSPATH . 'wp-admin/includes/file.php';
|
|
require_once ABSPATH . 'wp-admin/includes/image.php';
|
|
|
|
// Download file
|
|
$tmp = download_url($url);
|
|
if (is_wp_error($tmp)) {
|
|
return 0;
|
|
}
|
|
|
|
// Get filename from URL
|
|
$filename = basename(parse_url($url, PHP_URL_PATH));
|
|
|
|
$file_array = array(
|
|
'name' => $filename,
|
|
'tmp_name' => $tmp,
|
|
);
|
|
|
|
// Sideload to media library
|
|
$attachment_id = media_handle_sideload($file_array, $post_id);
|
|
|
|
if (is_wp_error($attachment_id)) {
|
|
@unlink($tmp);
|
|
return 0;
|
|
}
|
|
|
|
return $attachment_id;
|
|
}
|
|
|
|
/**
|
|
* Get all manual property listing IDs that should override MLS
|
|
*
|
|
* @return array Array of MLS IDs (listing_id) that have manual overrides
|
|
*/
|
|
public static function get_override_listing_ids() {
|
|
global $wpdb;
|
|
|
|
$db = new MLS_DB();
|
|
$table = $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();
|
|
}
|
|
|
|
$ids = $wpdb->get_col(
|
|
"SELECT listing_id FROM {$table}
|
|
WHERE listing_id IS NOT NULL
|
|
AND listing_id != ''
|
|
AND standard_status NOT IN ('Withdrawn')"
|
|
);
|
|
|
|
return array_filter($ids);
|
|
}
|
|
|
|
/**
|
|
* Get images for a manual property
|
|
*
|
|
* @param string $listing_key Manual property listing key (MANUAL-xxx)
|
|
* @return array Array of image objects compatible with MLS media format
|
|
*/
|
|
public static function get_manual_property_images($listing_key) {
|
|
// Extract post ID from listing key
|
|
if (strpos($listing_key, 'MANUAL-') !== 0) {
|
|
return array();
|
|
}
|
|
|
|
$post_id = (int) str_replace('MANUAL-', '', $listing_key);
|
|
if (!$post_id) {
|
|
return array();
|
|
}
|
|
|
|
$gallery = get_field('gallery', $post_id);
|
|
if (empty($gallery) || !is_array($gallery)) {
|
|
return array();
|
|
}
|
|
|
|
$images = array();
|
|
$order = 0;
|
|
|
|
foreach ($gallery as $image) {
|
|
$order++;
|
|
$images[] = (object) array(
|
|
'id' => $image['ID'],
|
|
'listing_key' => $listing_key,
|
|
'media_key' => 'manual-' . $image['ID'],
|
|
'media_type' => 'Photo',
|
|
'media_order' => $order,
|
|
'media_url' => $image['url'],
|
|
'local_path' => get_attached_file($image['ID']),
|
|
'local_url' => $image['url'],
|
|
'file_size' => filesize(get_attached_file($image['ID'])) ?: null,
|
|
'mime_type' => $image['mime_type'],
|
|
'image_width' => $image['width'],
|
|
'image_height' => $image['height'],
|
|
'downloaded_at' => get_the_date('Y-m-d H:i:s', $image['ID']),
|
|
'download_status' => 'complete',
|
|
);
|
|
}
|
|
|
|
return $images;
|
|
}
|
|
|
|
/**
|
|
* Get a manual property by listing key or listing ID
|
|
*
|
|
* @param string $identifier Listing key (MANUAL-xxx) or listing_id
|
|
* @return object|null
|
|
*/
|
|
public static function get_manual_property($identifier) {
|
|
global $wpdb;
|
|
|
|
$db = new MLS_DB();
|
|
$table = $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
|
|
$property = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT * FROM {$table} WHERE listing_key = %s",
|
|
$identifier
|
|
));
|
|
|
|
if (!$property) {
|
|
// Try by listing_id
|
|
$property = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT * FROM {$table} WHERE listing_id = %s",
|
|
$identifier
|
|
));
|
|
}
|
|
|
|
return $property;
|
|
}
|
|
}
|