| 1 |
<?php |
| 2 |
/** |
| 3 |
* Standalone (theme_id 990) search-field catalog + classifier. |
| 4 |
* |
| 5 |
* The single source of truth for "what can a search form filter on". A field is |
| 6 |
* only offered if the fast listings table can actually filter it, which keeps |
| 7 |
* search off slow meta lookups. Two groups, in this display order: |
| 8 |
* |
| 9 |
* 1. taxonomies — every standalone taxonomy (§7). City/State/Zip/Type/Status |
| 10 |
* are backed by a fast column and matched by the term *name*; Area/County/ |
| 11 |
* Label/Features have no column and are matched by a term-join on *slug*. |
| 12 |
* 2. columns — the fast-table fact columns, EXCEPT the geo coordinates |
| 13 |
* (latitude/longitude) and the internal identity columns. |
| 14 |
* |
| 15 |
* Each entry also carries how it renders and which query param(s) it submits, so |
| 16 |
* the Gutenberg field picker, the front-end form and the WHERE-builder all read |
| 17 |
* one definition. Pure data + lookup: no WordPress, no DB. |
| 18 |
* |
| 19 |
* @package Mlsimport |
| 20 |
*/ |
| 21 |
|
| 22 |
if ( ! defined( 'ABSPATH' ) ) { |
| 23 |
exit; |
| 24 |
} |
| 25 |
|
| 26 |
/** |
| 27 |
* Classifies and describes the fields a standalone search form may filter by. |
| 28 |
*/ |
| 29 |
class Mlsimport_Page_Block_Search_Fields { |
| 30 |
|
| 31 |
/** |
| 32 |
* Taxonomy search fields, in display order. Mirrors the standalone taxonomy |
| 33 |
* registry (§7). Per field: |
| 34 |
* label editor/front-end label. |
| 35 |
* tax the taxonomy slug whose terms populate the control. |
| 36 |
* match 'column-in' (fast column IN names) or 'term-join' |
| 37 |
* (wp_term_relationships subquery on slug). |
| 38 |
* multi true — every plugin taxonomy is a multi-select. |
| 39 |
* value the option value the control submits: 'name' or 'slug'. |
| 40 |
* join for term-join fields only: 'and' (a listing must carry every chosen |
| 41 |
* term — amenities, ADR-0004) or 'or' (any chosen term — locations, |
| 42 |
* labels, which a listing only holds one of). |
| 43 |
*/ |
| 44 |
private const TAXONOMY_FIELDS = array( |
| 45 |
'property_type' => array( 'label' => 'Property Type', 'tax' => 'mlsimport_property_type', 'match' => 'column-in', 'multi' => true, 'value' => 'name' ), |
| 46 |
'listing_type' => array( 'label' => 'Listing Type', 'tax' => 'mlsimport_listing_type', 'match' => 'column-in', 'multi' => true, 'value' => 'name' ), |
| 47 |
'status' => array( 'label' => 'Status', 'tax' => 'mlsimport_status', 'match' => 'column-in', 'multi' => true, 'value' => 'name' ), |
| 48 |
'city' => array( 'label' => 'City', 'tax' => 'mlsimport_city', 'match' => 'column-in', 'multi' => true, 'value' => 'name' ), |
| 49 |
'area' => array( 'label' => 'Area', 'tax' => 'mlsimport_area', 'match' => 'term-join', 'multi' => true, 'value' => 'slug', 'join' => 'or' ), |
| 50 |
'county' => array( 'label' => 'County', 'tax' => 'mlsimport_county', 'match' => 'term-join', 'multi' => true, 'value' => 'slug', 'join' => 'or' ), |
| 51 |
'state' => array( 'label' => 'State', 'tax' => 'mlsimport_state', 'match' => 'column-in', 'multi' => true, 'value' => 'name' ), |
| 52 |
'zip' => array( 'label' => 'ZIP Code', 'tax' => 'mlsimport_zip', 'match' => 'column-in', 'multi' => true, 'value' => 'name' ), |
| 53 |
'high_school_district' => array( 'label' => 'High School District', 'tax' => 'mlsimport_high_school_district', 'match' => 'term-join', 'multi' => true, 'value' => 'slug', 'join' => 'or' ), |
| 54 |
'features' => array( 'label' => 'Features', 'tax' => 'mlsimport_feature', 'match' => 'term-join', 'multi' => true, 'value' => 'slug', 'join' => 'and' ), |
| 55 |
'label' => array( 'label' => 'Label', 'tax' => 'mlsimport_label', 'match' => 'term-join', 'multi' => true, 'value' => 'slug', 'join' => 'or' ), |
| 56 |
); |
| 57 |
|
| 58 |
/** |
| 59 |
* Fast-table column search fields, in display order — the property facts the |
| 60 |
* operator searches by. Latitude/longitude (geo) and the internal identity |
| 61 |
* columns are deliberately absent. Per field: |
| 62 |
* label editor/front-end label. |
| 63 |
* control 'range' (min/max number pair), 'number', 'date' or 'text'. |
| 64 |
* params the query param(s) the control submits. A range submits two. |
| 65 |
*/ |
| 66 |
private const COLUMN_FIELDS = array( |
| 67 |
// One text box that stands in for the four separate location taxonomies: the |
| 68 |
// visitor types (or picks from the autocomplete) a city, area, county or ZIP |
| 69 |
// and the WHERE-builder ORs the value across all four plus the FULLTEXT |
| 70 |
// search_text column, which already carries UnparsedAddress — so a street |
| 71 |
// address typed in full matches too, with no address column to maintain. |
| 72 |
'location' => array( 'label' => 'Location', 'control' => 'location', 'params' => array( 'location' ) ), |
| 73 |
'price' => array( 'label' => 'Price', 'control' => 'range', 'params' => array( 'price_min', 'price_max' ) ), |
| 74 |
'beds_baths' => array( 'label' => 'Beds & Baths', 'control' => 'beds_baths', 'params' => array( 'beds', 'baths' ) ), |
| 75 |
'sqft' => array( 'label' => 'Living Area (sq ft)', 'control' => 'range', 'params' => array( 'sqft_min', 'sqft_max' ) ), |
| 76 |
'lot' => array( 'label' => 'Lot Size', 'control' => 'range', 'params' => array( 'lot_min', 'lot_max' ) ), |
| 77 |
'year' => array( 'label' => 'Year Built', 'control' => 'range', 'params' => array( 'year_min', 'year_max' ) ), |
| 78 |
'list_date' => array( 'label' => 'Listed After', 'control' => 'date', 'params' => array( 'list_date_min' ) ), |
| 79 |
'garage' => array( 'label' => 'Garage Spaces', 'control' => 'number', 'params' => array( 'garage_min' ) ), |
| 80 |
'stories' => array( 'label' => 'Stories', 'control' => 'number', 'params' => array( 'stories' ) ), |
| 81 |
'hoa' => array( 'label' => 'Max HOA Fee', 'control' => 'number', 'params' => array( 'hoa_max' ) ), |
| 82 |
'dom' => array( 'label' => 'Max Days on Market', 'control' => 'number', 'params' => array( 'dom_max' ) ), |
| 83 |
'subdivision' => array( 'label' => 'Subdivision', 'control' => 'text', 'params' => array( 'subdivision' ) ), |
| 84 |
// The public MLS number off a sign or flyer (RESO ListingId, e.g. TB8541851): |
| 85 |
// an exact match on the listing_id column, so it lands on that one listing. |
| 86 |
'mls_number' => array( 'label' => 'MLS #', 'control' => 'text', 'params' => array( 'listing_id' ) ), |
| 87 |
); |
| 88 |
|
| 89 |
/** |
| 90 |
* Classify a search field by how the fast table can filter it. |
| 91 |
* |
| 92 |
* @param string $field Field key (e.g. 'price', 'city', 'features'). |
| 93 |
* @return string 'taxonomy', 'scalar', or '' when the field is not searchable. |
| 94 |
*/ |
| 95 |
public static function classify( string $field ): string { |
| 96 |
if ( isset( self::TAXONOMY_FIELDS[ $field ] ) ) { |
| 97 |
return 'taxonomy'; |
| 98 |
} |
| 99 |
if ( isset( self::COLUMN_FIELDS[ $field ] ) ) { |
| 100 |
return 'scalar'; |
| 101 |
} |
| 102 |
return ''; |
| 103 |
} |
| 104 |
|
| 105 |
/** |
| 106 |
* Filter a configured field list to only the searchable fields, preserving the |
| 107 |
* builder's order. The guard the search form builder applies to its repeater. |
| 108 |
* |
| 109 |
* @param string[] $fields Requested field keys, in display order. |
| 110 |
* @return string[] The subset that is searchable. |
| 111 |
*/ |
| 112 |
public static function allowed( array $fields ): array { |
| 113 |
$out = array(); |
| 114 |
// Keep a field only if it classifies as taxonomy or scalar; drop unknowns. |
| 115 |
foreach ( $fields as $field ) { |
| 116 |
if ( '' !== self::classify( (string) $field ) ) { |
| 117 |
$out[] = (string) $field; |
| 118 |
} |
| 119 |
} |
| 120 |
return $out; |
| 121 |
} |
| 122 |
|
| 123 |
/** |
| 124 |
* Every searchable field key, taxonomies then columns — the order the field |
| 125 |
* picker and the full search form offer them. |
| 126 |
* |
| 127 |
* @return string[] |
| 128 |
*/ |
| 129 |
public static function catalog(): array { |
| 130 |
return array_merge( array_keys( self::TAXONOMY_FIELDS ), array_keys( self::COLUMN_FIELDS ) ); |
| 131 |
} |
| 132 |
|
| 133 |
/** |
| 134 |
* The field picker's options: field key => label, taxonomies then columns. |
| 135 |
* |
| 136 |
* @return array<string,string> |
| 137 |
*/ |
| 138 |
public static function labels(): array { |
| 139 |
$out = array(); |
| 140 |
// Taxonomy fields first (they lead the picker), then the column fields. |
| 141 |
foreach ( self::TAXONOMY_FIELDS as $key => $def ) { |
| 142 |
$out[ $key ] = $def['label']; |
| 143 |
} |
| 144 |
foreach ( self::COLUMN_FIELDS as $key => $def ) { |
| 145 |
$out[ $key ] = $def['label']; |
| 146 |
} |
| 147 |
return $out; |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* The field keys that render as a dual-handle range slider. The search form's |
| 152 |
* per-row "slider minimum/maximum" overrides only apply to these, so the editor |
| 153 |
* can show those two controls conditionally instead of on every row. |
| 154 |
* |
| 155 |
* @return string[] |
| 156 |
*/ |
| 157 |
public static function range_fields(): array { |
| 158 |
$out = array(); |
| 159 |
// A range control is the only one with a configurable floor and ceiling. |
| 160 |
foreach ( self::COLUMN_FIELDS as $key => $def ) { |
| 161 |
if ( 'range' === $def['control'] ) { |
| 162 |
$out[] = $key; |
| 163 |
} |
| 164 |
} |
| 165 |
return $out; |
| 166 |
} |
| 167 |
|
| 168 |
/** |
| 169 |
* The full definition for one field, normalized with its group, or null when |
| 170 |
* the field is not searchable. Taxonomy fields gain group=taxonomy + tax/match/ |
| 171 |
* multi/value; column fields gain group=column + control/params. |
| 172 |
* |
| 173 |
* @param string $field Field key. |
| 174 |
* @return array|null |
| 175 |
*/ |
| 176 |
public static function definition( string $field ) { |
| 177 |
if ( isset( self::TAXONOMY_FIELDS[ $field ] ) ) { |
| 178 |
return array_merge( array( 'key' => $field, 'group' => 'taxonomy' ), self::TAXONOMY_FIELDS[ $field ] ); |
| 179 |
} |
| 180 |
if ( isset( self::COLUMN_FIELDS[ $field ] ) ) { |
| 181 |
return array_merge( array( 'key' => $field, 'group' => 'column' ), self::COLUMN_FIELDS[ $field ] ); |
| 182 |
} |
| 183 |
return null; |
| 184 |
} |
| 185 |
|
| 186 |
/** |
| 187 |
* The search field that filters a given taxonomy, as { key, value }, or null |
| 188 |
* when the taxonomy has no search field. Lets a taxonomy term archive pre-filter |
| 189 |
* the grid (and pre-select the search form) by its queried term: 'key' is the |
| 190 |
* query param, 'value' says whether that param carries the term name or slug. |
| 191 |
* |
| 192 |
* @param string $taxonomy Taxonomy slug. |
| 193 |
* @return array{key:string,value:string}|null |
| 194 |
*/ |
| 195 |
public static function field_for_taxonomy( string $taxonomy ) { |
| 196 |
// Reverse-lookup: find the search field whose 'tax' matches this taxonomy. |
| 197 |
foreach ( self::TAXONOMY_FIELDS as $key => $def ) { |
| 198 |
if ( $def['tax'] === $taxonomy ) { |
| 199 |
return array( 'key' => $key, 'value' => $def['value'] ); |
| 200 |
} |
| 201 |
} |
| 202 |
return null; |
| 203 |
} |
| 204 |
|
| 205 |
/** |
| 206 |
* The term-join taxonomy fields: query param => { tax, join }. The listings |
| 207 |
* query resolves each submitted slug to a term_taxonomy_id and filters by them |
| 208 |
* per the join mode ('and' = carry every term, 'or' = carry any). Lets the query |
| 209 |
* stay in sync with the catalog without re-declaring the join set. |
| 210 |
* |
| 211 |
* @return array<string,array{tax:string,join:string}> |
| 212 |
*/ |
| 213 |
public static function term_join_params(): array { |
| 214 |
$out = array(); |
| 215 |
// Collect only the term-join fields; column-in fields filter via a fast column. |
| 216 |
foreach ( self::TAXONOMY_FIELDS as $key => $def ) { |
| 217 |
if ( 'term-join' === $def['match'] ) { |
| 218 |
$out[ $key ] = array( |
| 219 |
'tax' => $def['tax'], |
| 220 |
// Default to 'or'; only an explicit 'and' requires every chosen term. |
| 221 |
'join' => isset( $def['join'] ) && 'and' === $def['join'] ? 'and' : 'or', |
| 222 |
); |
| 223 |
} |
| 224 |
} |
| 225 |
return $out; |
| 226 |
} |
| 227 |
|
| 228 |
/** |
| 229 |
* The taxonomies the combined Location field reaches beyond the fast city/zip |
| 230 |
* columns — area and county, which have no column of their own. One list, read |
| 231 |
* by both the autocomplete suggestion source and the WHERE-builder's location |
| 232 |
* clause, so the two can never offer and match different things. |
| 233 |
* |
| 234 |
* @return string[] Taxonomy slugs. |
| 235 |
*/ |
| 236 |
public static function location_taxonomies(): array { |
| 237 |
return array( self::TAXONOMY_FIELDS['area']['tax'], self::TAXONOMY_FIELDS['county']['tax'] ); |
| 238 |
} |
| 239 |
} |
| 240 |
|