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 '' . esc_html($status) . ''; 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) ? 'Yes' : 'No'; break; case 'mp_featured': echo get_field('is_featured', $post_id) ? 'Yes' : 'No'; break; case 'mp_view': $listing_key = 'MANUAL-' . $post_id; $url = add_query_arg('listing', $listing_key, home_url('/properties/')); echo 'View'; 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'] = 'View Property'; 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; } ?>
Enter an MLS ID to copy data from an existing MLS listing: