PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / trunk
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings vtrunk
7.2.2 7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 All 37 releases
mlsimport / includes / standalone / page-blocks.php

page-blocks.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings trunk, at includes/standalone/page-blocks.php

1,428 lines 67.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Standalone (theme_id 990) page-block render functions.
4 *
5 * Each page block is a global function with the fixed signature
6 * mlsimport_page_block_<slug>( array $args ): string. It reads only from its args,
7 * runs them through the tested selection resolver and the existing render layer,
8 * and RETURNS an HTML string. One function backs every builder (Shortcode,
9 * Gutenberg, Elementor) — the adapters are thin wrappers, this is the single
10 * source of markup. See docs/adr/0007 and CONTEXT.md (Page block).
11 *
12 * @package Mlsimport
13 */
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 require_once __DIR__ . '/class-mlsimport-standalone-render.php';
20 require_once __DIR__ . '/class-mlsimport-standalone-shortcodes.php';
21 require_once __DIR__ . '/class-mlsimport-standalone-ajax.php';
22 require_once __DIR__ . '/class-mlsimport-standalone-settings.php';
23 require_once __DIR__ . '/class-mlsimport-page-block-selection.php';
24 require_once __DIR__ . '/class-mlsimport-page-block-search-fields.php';
25 require_once __DIR__ . '/class-mlsimport-property-lead.php';
26 require_once __DIR__ . '/class-mlsimport-property-section-assets.php';
27 require_once __DIR__ . '/featured-card.php';
28
29 /**
30 * Resolve a block's selection args into the listing-card HTML (the shared engine
31 * behind every "set" block). Honours decision 6: explicit IDs render that exact
32 * ordered set; otherwise the taxonomy + sort + count selection runs the query.
33 *
34 * @param array $args Selection args.
35 * @return string Card HTML ('' when nothing matches).
36 */
37 function mlsimport_page_block_cards( array $args ): string {
38 /** Short-circuit an explicit-set block's cards (live mode addresses listings by ListingKey). @since 6.6 */
39 $pre = apply_filters( 'mlsimport_page_block_ids_cards_pre', null, $args, '' );
40 if ( is_string( $pre ) ) {
41 return $pre;
42 }
43
44 $selection = Mlsimport_Page_Block_Selection::resolve( $args );
45
46 if ( 'ids' === $selection['mode'] ) {
47 return Mlsimport_Standalone_Render::cards_for_posts( $selection['ids'] );
48 }
49
50 $data = Mlsimport_Standalone_Render::prepare( $selection['params'] );
51 return Mlsimport_Standalone_Render::render_cards( $data );
52 }
53
54 /**
55 * Wrap card HTML in a page-block grid container (or an empty-results message).
56 *
57 * @param string $slug Block slug (for the BEM modifier class).
58 * @param string $cards Card HTML.
59 * @return string
60 */
61 function mlsimport_page_block_grid( string $slug, string $cards ): string {
62 $class = 'mlsimport-page-block mlsimport-page-block--' . sanitize_html_class( str_replace( '_', '-', $slug ) );
63 if ( '' === $cards ) {
64 $empty = '<p class="mlsimport-results__empty">' . esc_html__( 'No listings found.', 'mlsimport' ) . '</p>';
65 return '<div class="' . esc_attr( $class ) . '">' . $empty . '</div>';
66 }
67 return '<div class="' . esc_attr( $class ) . '"><div class="mlsimport-results__grid">' . $cards . '</div></div>';
68 }
69
70 /**
71 * Property List — the full listings surface: the same pre-filled filter bar +
72 * AJAX repaint + pager as the Half Map's list pane, seeded on first load from the
73 * block's initial-filter presets. Same render_grid path as the Half Map and Search
74 * Results, so shortcode / Gutenberg / Elementor emit identical markup and the
75 * search bar (above the grid) filters it via AJAX. atts_to_args whitelists the
76 * presets to the real filter keys (injection-safe: the query binds every value);
77 * search_fields / fields_per_row are display config injected after (not filter
78 * keys). show_filter_bar off keeps the form in the DOM as the AJAX state carrier.
79 *
80 * @param array $args Block args (initial-filter presets, count, show_filter_bar,
81 * search_fields, fields_per_row).
82 * @return string
83 */
84 function mlsimport_page_block_item_list( array $args ): string {
85 $filter_args = Mlsimport_Standalone_Shortcodes::atts_to_args( $args );
86
87 // The block's "Per page" is the page size unless a preset already carries a limit.
88 if ( ! isset( $filter_args['limit'] ) ) {
89 $filter_args['limit'] = isset( $args['count'] ) ? max( 1, (int) $args['count'] ) : 12;
90 }
91
92 // Search-bar display config — which fields show and how many per row — injected
93 // after atts_to_args strips non-filter keys (identical to the Half Map).
94 if ( isset( $args['search_fields'] ) && '' !== (string) $args['search_fields'] ) {
95 $filter_args['search_fields'] = (string) $args['search_fields'];
96 }
97 if ( isset( $args['fields_per_row'] ) && '' !== (string) $args['fields_per_row'] ) {
98 $filter_args['fields_per_row'] = (string) $args['fields_per_row'];
99 }
100 $show = isset( $args['show_filter_bar'] ) ? (string) $args['show_filter_bar'] : '1';
101 if ( in_array( $show, array( '', '0', 'no', 'false' ), true ) ) {
102 $filter_args['hide_search_form'] = true;
103 }
104
105 return Mlsimport_Standalone_Render::render_grid( $filter_args );
106 }
107
108 /**
109 * List Items by ID — an explicit, ordered set of properties addressed by their
110 * IDs, paginated. The full ID list is the result set; count is the page size and
111 * paging is GET-based (?page=N), matching the Search Results block. Only the
112 * current page's IDs are rendered, then the shared pager.
113 *
114 * @param array $args Block args (ids, count).
115 * @return string
116 */
117 function mlsimport_page_block_list_by_id( array $args ): string {
118 /** Short-circuit the List-by-ID block (live mode addresses listings by ListingKey). @since 6.4 */
119 $pre = apply_filters( 'mlsimport_page_block_list_by_id_pre', null, $args );
120 if ( is_string( $pre ) ) {
121 return $pre;
122 }
123
124 $selection = Mlsimport_Page_Block_Selection::resolve( $args );
125 $ids = 'ids' === $selection['mode'] ? $selection['ids'] : array();
126 if ( empty( $ids ) ) {
127 return mlsimport_page_block_grid( 'list_by_id', '' );
128 }
129
130 $per_page = isset( $args['count'] ) ? max( 1, (int) $args['count'] ) : 12;
131 $total = count( $ids );
132 // Page on a NON-reserved key. This block is dropped onto a normal Page, which is a
133 // SINGULAR post, and WP's redirect_canonical() strips a bare ?page= there (it is the
134 // reserved <!--nextpage--> var) — so a ?page=2 link 301s back to page 1 and pagination
135 // silently never advances. agent-sections.php hit the same wall and pages on its own
136 // key; this surface uses `mlsimport_page` for the identical reason.
137 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only GET paging of a fixed ID set.
138 $current = isset( $_GET['mlsimport_page'] ) ? max( 1, (int) $_GET['mlsimport_page'] ) : 1;
139
140 $page_ids = array_slice( $ids, ( $current - 1 ) * $per_page, $per_page );
141 $cards = Mlsimport_Standalone_Render::cards_for_posts( $page_ids );
142
143 // Same pager markup as the listings grid (render_grid): the <nav> inside a
144 // .mlsimport-results__pager wrapper, so every listing surface paginates identically.
145 // The pager links carry the same non-reserved `mlsimport_page` key we read above.
146 return '<div class="mlsimport-page-block mlsimport-page-block--list-by-id">'
147 . '<div class="mlsimport-results__grid">' . $cards . '</div>'
148 . '<div class="mlsimport-results__pager">' . Mlsimport_Pagination::render( $total, $per_page, $current, array( 'param' => 'mlsimport_page' ) ) . '</div>'
149 . '</div>';
150 }
151
152 /**
153 * Saved Properties ("My saved properties") — the client-hydrated view of the
154 * visitor's favorites. The server prints only an empty shell (grid + pager +
155 * empty/loading states); favorites.js supplies the authoritative { k, p } list
156 * (localStorage for anonymous visitors, the bootstrap blob for logged-in users),
157 * POSTs it to the mlsimport_saved_cards resolver, and injects the cards + the
158 * shared pager. One code path for both auth states — the resolver is the single
159 * place that maps a saved list to current published cards. Off-market entries the
160 * resolver reports are pruned from the client store.
161 *
162 * @param array $args Block args (count = per page).
163 * @return string
164 */
165 function mlsimport_page_block_saved( array $args ): string {
166 $per_page = isset( $args['count'] ) ? max( 1, (int) $args['count'] ) : 12;
167
168 // In a page-builder preview there is no visitor, and favorites.js never hydrates a
169 // widget the builder injects after page load — so the live shell would sit forever on
170 // "Loading your saved properties…". Show an author-facing placeholder instead, so the
171 // editor makes clear what the block does rather than looking stuck. The real block
172 // (below) renders unchanged on the published page.
173 if ( mlsimport_is_builder_preview() ) {
174 return '<div class="mlsimport-page-block mlsimport-page-block--saved mlsimport-saved mlsimport-saved--preview">'
175 . '<p class="mlsimport-results__empty">'
176 . esc_html__( 'Saved Properties — on the live page, each visitor sees the listings they have saved here. There is nothing to preview in the editor.', 'mlsimport' )
177 . '</p></div>';
178 }
179
180 return '<div class="mlsimport-page-block mlsimport-page-block--saved mlsimport-saved" data-mlsimport-saved data-per-page="' . esc_attr( (string) $per_page ) . '">'
181 . '<div class="mlsimport-saved__status" data-mlsimport-saved-loading>' . esc_html__( 'Loading your saved properties…', 'mlsimport' ) . '</div>'
182 . '<p class="mlsimport-results__empty" data-mlsimport-saved-empty hidden>' . esc_html__( 'You haven\'t saved any properties yet.', 'mlsimport' ) . '</p>'
183 . '<div class="mlsimport-results__grid" data-mlsimport-saved-grid></div>'
184 . '<div class="mlsimport-results__pager" data-mlsimport-saved-pager></div>'
185 . '</div>';
186 }
187
188 /**
189 * Search Results — the full listings surface (the same pre-filled filter bar +
190 * AJAX repaint + pager as the MLS Listings block), seeded on first load from the
191 * GET search the Search Form submitted. atts_to_args whitelists the URL to the
192 * real filter keys (injection-safe: the query binds every value), so the refine
193 * bar pre-fills with the visitor's search and they can narrow it in place without
194 * a reload. The only difference from the MLS Listings block is where the initial
195 * args come from — there, saved block attributes; here, the request. Decision 7 / 10.
196 *
197 * @param array $args Block args (count = per page).
198 * @return string
199 */
200 function mlsimport_page_block_results( array $args ): string {
201 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only GET search; the query binds every value.
202 $request = isset( $_GET ) ? wp_unslash( $_GET ) : array();
203 $qargs = Mlsimport_Standalone_Shortcodes::atts_to_args( $request );
204
205 // The block's "Per page" is the page size unless the URL already carries one.
206 if ( ! isset( $qargs['limit'] ) ) {
207 $qargs['limit'] = isset( $args['count'] ) ? max( 1, (int) $args['count'] ) : 12;
208 }
209
210 // Refine-bar display config — the same three controls the MLS Listings block
211 // exposes: which fields show, how many per row, and whether the bar shows at all.
212 if ( isset( $args['search_fields'] ) && '' !== (string) $args['search_fields'] ) {
213 $qargs['search_fields'] = (string) $args['search_fields'];
214 }
215 if ( isset( $args['fields_per_row'] ) && '' !== (string) $args['fields_per_row'] ) {
216 $qargs['fields_per_row'] = (string) $args['fields_per_row'];
217 }
218 // "Show filter bar" off ('', '0', or Elementor/Gutenberg's empty switcher) hides
219 // the bar. render_grid keeps the form in the DOM (CSS hides it) so it stays the
220 // state carrier for AJAX paging — the visitor's search survives page changes.
221 $show = isset( $args['show_filter_bar'] ) ? (string) $args['show_filter_bar'] : '1';
222 if ( in_array( $show, array( '', '0', 'no', 'false' ), true ) ) {
223 $qargs['hide_search_form'] = true;
224 }
225
226 // This is the one surface that offers "Save this search" (issue #221): the flag
227 // tells the mlsimport_results_toolbar listener to print its button here.
228 $qargs['saved_search'] = true;
229
230 return Mlsimport_Standalone_Render::render_grid( $qargs );
231 }
232
233 /**
234 * Content Slider — the selected listings rendered as a Splide carousel. Reuses the
235 * cards and the Splide assets already loaded for property galleries.
236 *
237 * @param array $args Selection args.
238 * @return string
239 */
240 function mlsimport_page_block_slider( array $args ): string {
241 /** Short-circuit an explicit-set block's cards (live mode addresses listings by ListingKey). @since 6.6 */
242 $pre = apply_filters( 'mlsimport_page_block_ids_cards_pre', null, $args, 'splide__slide' );
243
244 // Each card is wrapped as a Splide slide (<li class="splide__slide">). A
245 // dedicated .mlsimport-content-slider class (not the single-property gallery's
246 // .mlsimport-property-slider) keeps the gallery's image-cover/fixed-height rules
247 // off the property cards. mlsimport-property-slider.js mounts it as a multi-card
248 // carousel (arrows, 3/2/1 per view) — the WpResidence content-slider behaviour.
249 if ( is_string( $pre ) ) {
250 $slides = $pre;
251 } else {
252 $selection = Mlsimport_Page_Block_Selection::resolve( $args );
253 if ( 'ids' === $selection['mode'] ) {
254 $slides = Mlsimport_Standalone_Render::cards_for_posts( $selection['ids'], 'splide__slide' );
255 } else {
256 // Full initial-filter presets (the same set the Half Map / listings block
257 // accept): atts_to_args normalizes every listings filter key, and the slider's
258 // own friendly How-many + Sort (count/sort, not filter keys) are added from the
259 // resolved selection params. atts_to_args wins on overlap (normalized taxonomies).
260 $params = Mlsimport_Standalone_Shortcodes::atts_to_args( $args ) + $selection['params'];
261 $data = Mlsimport_Standalone_Render::prepare( $params );
262 $slides = Mlsimport_Standalone_Render::render_cards( $data, 'splide__slide' );
263 }
264 }
265 if ( '' === $slides ) {
266 return '';
267 }
268
269 return '<div class="mlsimport-page-block mlsimport-page-block--slider">'
270 . '<div class="mlsimport-content-slider splide" role="group" aria-label="' . esc_attr__( 'Properties', 'mlsimport' ) . '">'
271 . '<div class="splide__track"><ul class="splide__list">'
272 . $slides
273 . '</ul></div></div></div>';
274 }
275
276 /**
277 * Whether the current render is a page-builder preview by an editor — the Gutenberg
278 * block editor (dynamic blocks render over the REST API) or the Elementor editor (its
279 * canvas renders in a front-end preview iframe, and an edited widget re-renders over
280 * admin-ajax, which reports edit mode). False for every public front-end request.
281 *
282 * A block that hydrates client-side (favorites) or stands in a chosen listing uses this
283 * to show an author-facing placeholder instead of a live state the editor cannot build.
284 *
285 * @return bool
286 */
287 function mlsimport_is_builder_preview(): bool {
288 // Only ever for someone editing a page — never for a public request.
289 if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'edit_posts' ) ) {
290 return false;
291 }
292 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
293 return true;
294 }
295 if ( class_exists( '\Elementor\Plugin' ) && isset( \Elementor\Plugin::$instance->preview ) ) {
296 return \Elementor\Plugin::$instance->preview->is_preview_mode()
297 || \Elementor\Plugin::$instance->editor->is_edit_mode();
298 }
299 return false;
300 }
301
302 /**
303 * The listing a page-builder preview stands in with when the author has not picked
304 * one yet: the newest published property. Returns 0 anywhere else — a live page
305 * must never show a property the author never chose.
306 *
307 * @return int
308 */
309 function mlsimport_preview_fallback_property_id(): int {
310 if ( ! mlsimport_is_builder_preview() ) {
311 return 0;
312 }
313
314 $ids = get_posts(
315 array(
316 'post_type' => 'mlsimport_property',
317 'post_status' => 'publish',
318 'posts_per_page' => 1,
319 'orderby' => 'date',
320 'order' => 'DESC',
321 'fields' => 'ids',
322 )
323 );
324 return $ids ? (int) $ids[0] : 0;
325 }
326
327 /**
328 * Options for a post-type-backed multiselect: every published post of $post_type as
329 * an ordered { post_id => title } map. The parallel of mlsimport_category_term_options()
330 * for posts — it powers the Team Directory agent picker (and any future post picker).
331 * Admin-only callers resolve it; the front end reads the saved ids and never needs
332 * the list. An empty selection means "all", so an empty list here is a valid state.
333 *
334 * @param string $post_type Post type to list.
335 * @return array<string,string> post_id => title, or empty if the type is unknown.
336 */
337 function mlsimport_post_type_options( string $post_type ): array {
338 if ( '' === $post_type || ! post_type_exists( $post_type ) ) {
339 return array();
340 }
341 $posts = get_posts(
342 array(
343 'post_type' => $post_type,
344 'post_status' => 'publish',
345 'posts_per_page' => -1,
346 'orderby' => 'title',
347 'order' => 'ASC',
348 'no_found_rows' => true,
349 )
350 );
351 $options = array();
352 foreach ( $posts as $post ) {
353 $options[ (string) $post->ID ] = (string) get_the_title( $post );
354 }
355 return $options;
356 }
357
358 /**
359 * Featured Property — one property in one of six designs (decision 11). The design
360 * only switches a modifier class; all six are styled in our CSS. Reuses the one
361 * card template so the markup never forks.
362 *
363 * @param array $args Block args (id, design 1-6).
364 * @return string
365 */
366 function mlsimport_page_block_featured( array $args ): string {
367 /** Short-circuit the Featured card (live mode addresses the listing by ListingKey). @since 6.6 */
368 $card = apply_filters( 'mlsimport_page_block_featured_card_pre', null, $args );
369 if ( ! is_string( $card ) ) {
370 $id = isset( $args['id'] ) ? (int) $args['id'] : 0;
371 // Nothing picked yet: a builder preview borrows the newest listing so the
372 // editor shows a real card instead of an empty widget. Front end stays 0.
373 if ( $id <= 0 ) {
374 $id = mlsimport_preview_fallback_property_id();
375 }
376 if ( $id <= 0 ) {
377 return '';
378 }
379 $card = mlsimport_featured_card( $id, isset( $args['design'] ) ? (int) $args['design'] : 1 );
380 }
381
382 if ( '' === $card ) {
383 return '';
384 }
385 // Every .mlsimport-featured rule lives in the section stylesheet, which only the
386 // single-property page enqueues — without this the card renders unstyled.
387 Mlsimport_Property_Section_Assets::enqueue();
388 return '<div class="mlsimport-page-block mlsimport-page-block--featured">' . $card . '</div>';
389 }
390
391 /**
392 * Map with Listings — the selected listings rendered on a map.
393 *
394 * Two paths, chosen by the selection:
395 * - hand-picked "by IDs": a small, fixed set, so the markers are embedded
396 * directly and rendered client-side (no clustering needed).
397 * - filter "query": potentially thousands of listings, so the container carries
398 * only the filter + the overall bounds, and the map fetches markers/clusters
399 * for the current viewport over AJAX (mlsimport_markers). This is what keeps a
400 * 5k-listing map realistic — the browser never loads the whole feed at once.
401 *
402 * @param array $args Selection args.
403 * @return string
404 */
405 function mlsimport_page_block_map( array $args ): string {
406 $selection = Mlsimport_Page_Block_Selection::resolve( $args );
407
408 /** Filter the Leaflet/OSM tile URL (shared with the single-property map). @since 6.3 */
409 $tile = (string) apply_filters( 'mlsimport_map_tile_url', 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' );
410
411 $base = ' data-tile="' . esc_attr( $tile ) . '"';
412
413 /** Short-circuit the hand-picked marker set (live mode addresses listings by ListingKey). @since 6.6 */
414 $pre_markers = apply_filters( 'mlsimport_page_block_map_markers_pre', null, $args );
415
416 // Hand-picked set: embed the chosen listings directly (small by definition).
417 if ( is_array( $pre_markers ) || 'ids' === $selection['mode'] ) {
418 $markers = is_array( $pre_markers ) ? $pre_markers : Mlsimport_Standalone_Render::markers_for_posts( $selection['ids'] );
419 if ( empty( $markers ) ) {
420 return '';
421 }
422 mlsimport_page_block_map_enqueue();
423 return '<div class="mlsimport-page-block mlsimport-page-block--map">'
424 . '<div class="mlsimport-map" data-mode="ids"' . $base
425 . ' data-markers="' . esc_attr( (string) wp_json_encode( $markers ) ) . '"></div>'
426 . '</div>';
427 }
428
429 // Filter set: fit to all matches, then load each viewport over AJAX. Full initial-
430 // filter presets (the same set the Half Map / listings block accept): atts_to_args
431 // normalizes every listings filter key, and the map's own friendly How-many (count,
432 // not a filter key) is added from the resolved selection params. atts_to_args wins on
433 // overlap (normalized taxonomies). Mirrors mlsimport_page_block_slider.
434 $params = Mlsimport_Standalone_Shortcodes::atts_to_args( $args ) + $selection['params'];
435 $bounds = Mlsimport_Standalone_Listings_Query::bounds( $params );
436 if ( null === $bounds ) {
437 return '';
438 }
439
440 return '<div class="mlsimport-page-block mlsimport-page-block--map">'
441 . mlsimport_page_block_map_node( $params, $bounds, $tile )
442 . '</div>';
443 }
444
445 /**
446 * Build the query-mode map element (the .mlsimport-map div the page-block map
447 * script mounts) for a filter selection. Embeds the tile URL, the filter as
448 * data-filters, and the optional overall bounds; enqueues the map assets.
449 * Shared by the Map block and the Half Map block so the marker container never
450 * forks. The caller wraps it (Map block hides itself when there are no bounds;
451 * Half Map always shows the map alongside the list).
452 *
453 * @param array $params Filter query params (the map's data-filters).
454 * @param array|null $bounds Overall bounds {lat_min,lat_max,lng_min,lng_max}, or null.
455 * @param string $tile Leaflet/OSM tile URL.
456 * @return string
457 */
458 function mlsimport_page_block_map_node( array $params, ?array $bounds, string $tile ): string {
459 mlsimport_page_block_map_enqueue();
460
461 $attrs = ' data-mode="query"'
462 . ' data-tile="' . esc_attr( $tile ) . '"'
463 . ' data-filters="' . esc_attr( (string) wp_json_encode( $params ) ) . '"';
464 if ( null !== $bounds ) {
465 $attrs .= ' data-bounds="' . esc_attr( (string) wp_json_encode( $bounds ) ) . '"';
466 }
467
468 return '<div class="mlsimport-map"' . $attrs . '></div>';
469 }
470
471 /**
472 * Half Map — a full-height split surface: the MLS listings block (filter bar +
473 * AJAX results) on one side, a viewport-clustered map on the other. The same
474 * search form drives both panes — the list repaints over AJAX (mlsimport-listings.js)
475 * and a thin coordinator (mlsimport-half-map.js) pushes the same params to the map
476 * — so the two can never disagree. Initial-filter presets (every listings filter
477 * key) seed both panes identically, exactly like the standalone listings block.
478 *
479 * @param array $args Block args: every filter key (initial filter) + search_fields,
480 * map_side, height.
481 * @return string
482 */
483 function mlsimport_page_block_half_map( array $args ): string {
484 // Same atts->args path as the listings block, so the initial filter behaves
485 // identically. search_fields is display config (which filters show), not a
486 // filter key, so it is injected after atts_to_args strips non-filter keys.
487 $filter_args = Mlsimport_Standalone_Shortcodes::atts_to_args( $args );
488 if ( isset( $args['search_fields'] ) && '' !== $args['search_fields'] ) {
489 $filter_args['search_fields'] = (string) $args['search_fields'];
490 }
491 // fields_per_row is search-form display config (how many fields per row), not a
492 // filter key, so it is injected after atts_to_args strips non-filter keys.
493 if ( isset( $args['fields_per_row'] ) && '' !== (string) $args['fields_per_row'] ) {
494 $filter_args['fields_per_row'] = (string) $args['fields_per_row'];
495 }
496
497 // List pane: the exact MLS listings block (filter bar + AJAX results grid).
498 $list = Mlsimport_Standalone_Render::render_grid( $filter_args );
499
500 // Map pane: the same filter as a query-mode map. The map ignores paging.
501 $params = $filter_args;
502 unset( $params['limit'], $params['page'], $params['search_fields'], $params['fields_per_row'] );
503
504 /** Filter the Leaflet/OSM tile URL (shared with the single-property map). @since 6.3 */
505 $tile = (string) apply_filters( 'mlsimport_map_tile_url', 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' );
506 $bounds = Mlsimport_Standalone_Listings_Query::bounds( $params );
507
508 // Draw-on-map toolbar. The Clear button is hidden until a shape exists (the
509 // coordinator toggles it). mlsimport-half-map.js wires both via data-draw.
510 $draw_tools = '';
511 /** Filter whether the draw-on-map tools render (polygon search needs the local table — live mode hides them). @since 6.6 */
512 if ( apply_filters( 'mlsimport_half_map_draw_tools', true ) ) {
513 $draw_tools = '<div class="mlsimport-half-map__draw-tools">'
514 . '<button type="button" class="mlsimport-half-map__draw-btn" data-draw="start" data-label-drawing="' . esc_attr__( 'Click start point to finish', 'mlsimport' ) . '">' . esc_html__( 'Draw area', 'mlsimport' ) . '</button>'
515 . '<button type="button" class="mlsimport-half-map__draw-btn mlsimport-half-map__draw-btn--clear is-hidden" data-draw="clear">' . esc_html__( 'Clear area', 'mlsimport' ) . '</button>'
516 . '</div>';
517 }
518 $map = '<div class="mlsimport-page-block--map">' . $draw_tools . mlsimport_page_block_map_node( $params, $bounds, $tile ) . '</div>';
519
520 $side = isset( $args['map_side'] ) && 'left' === $args['map_side'] ? 'left' : 'right';
521 $height = isset( $args['height'] ) && '' !== (string) $args['height'] ? (string) $args['height'] : '100vh';
522
523 $out = '<div class="mlsimport-page-block mlsimport-half-map mlsimport-half-map--map-' . esc_attr( $side ) . '"'
524 . ' style="--mlsimport-half-map-height:' . esc_attr( $height ) . '">';
525 // Mobile-only List/Map switch (hidden on desktop via CSS).
526 $out .= '<div class="mlsimport-half-map__switch" role="tablist">'
527 . '<button type="button" class="mlsimport-half-map__tab is-active" data-view="list">' . esc_html__( 'List', 'mlsimport' ) . '</button>'
528 . '<button type="button" class="mlsimport-half-map__tab" data-view="map">' . esc_html__( 'Map', 'mlsimport' ) . '</button>'
529 . '</div>';
530 $out .= '<div class="mlsimport-half-map__list">' . $list . '</div>';
531 $out .= '<div class="mlsimport-half-map__map">' . $map . '</div>';
532 $out .= '</div>';
533
534 return $out;
535 }
536
537 /**
538 * Enqueue the map block's Leaflet/OSM assets + the multi-marker init script.
539 * Mirrors the single-property map's enqueue but loads the page-block map script.
540 *
541 * @return void
542 */
543 function mlsimport_page_block_map_enqueue(): void {
544 if ( ! function_exists( 'wp_enqueue_script' ) ) {
545 return;
546 }
547 Mlsimport_Property_Section_Assets::ensure_registered();
548
549 wp_enqueue_style( 'mlsimport-leaflet' );
550 wp_enqueue_script( 'mlsimport-leaflet' );
551 wp_enqueue_script( 'mlsimport-page-block-map' );
552
553 // AJAX config for viewport marker loading (query mode). Nonce pairs with
554 // Mlsimport_Standalone_Ajax::MARKERS_ACTION verified in handle_markers(). The
555 // map defaults (starting point + zoom) ride along so the script can fall back to
556 // the configured view when there are no listings to fit to.
557 wp_localize_script(
558 'mlsimport-page-block-map',
559 'MLSImportMap',
560 array(
561 'ajaxurl' => admin_url( 'admin-ajax.php' ),
562 'action' => Mlsimport_Standalone_Ajax::MARKERS_ACTION,
563 'nonce' => wp_create_nonce( Mlsimport_Standalone_Ajax::MARKERS_ACTION ),
564 'zoomInText' => __( 'Zoom in to view listings', 'mlsimport' ),
565 ) + mlsimport_standalone_map_js_config()
566 );
567 }
568
569 /**
570 * The "search fields per row" choice as a ready-to-print inline style that sets the
571 * --mlsimport-search-cols custom property. The search form, the listings grid form
572 * and the half-map list form all lay their fields out as a grid of this many
573 * columns, so one property drives every search surface. Only the 3–6 options the
574 * widgets offer are honoured; anything unset or out of range returns '' so the
575 * surface's own CSS fallback (its default column count) applies instead.
576 *
577 * @param mixed $raw Configured fields-per-row value.
578 * @return string e.g. ' style="--mlsimport-search-cols:4"', or '' when unset/invalid.
579 */
580 function mlsimport_search_cols_style( $raw ): string {
581 $cols = (int) $raw;
582 if ( $cols < 3 || $cols > 6 ) {
583 return '';
584 }
585 return ' style="--mlsimport-search-cols:' . $cols . '"';
586 }
587
588 /**
589 * The active filters the search form must carry as HIDDEN inputs: every filter that is
590 * set but has NO visible field the visitor could re-submit it with.
591 *
592 * Why this exists. The search form is the AJAX layer's entire state —
593 * mlsimport-listings.js builds each filter/paginate request from the form's FormData and
594 * nothing else. A filter that is not in the form is therefore DROPPED the moment the
595 * visitor refines or turns a page: the server-rendered first page honours the block's
596 * preset and looks perfectly right, and page 2 silently widens to the unfiltered set.
597 *
598 * A filter loses its field two ways, both of them things a site builder does on purpose:
599 * the block sets a preset and switches that field OFF in "Search fields" (a "Miami condos"
600 * landing page, where visitors must not change the city), or the filter has no search
601 * field at all (the map box). Either way the fix is the same, so there is one rule rather
602 * than a growing list of special cases: a filter is either EDITABLE in the bar, or it
603 * rides along HIDDEN.
604 *
605 * @param array $args Current filter values (the render args).
606 * @param array|null $visible Visible field keys, or null when every field shows.
607 * @return array<string,mixed> name => value, ready to print. A multi-value filter uses a
608 * "key[]" name so collectParams() re-groups it as an array.
609 */
610 function mlsimport_search_form_hidden_args( array $args, ?array $visible ): array {
611 $shows = static function ( string $field ) use ( $visible ): bool {
612 return null === $visible || in_array( $field, $visible, true );
613 };
614
615 // Every param the visitor CAN edit, because a field that submits it is on screen.
616 $editable = array();
617 if ( $shows( 'keywords' ) ) {
618 $editable[] = 'keywords';
619 }
620 // The Sort control is a single select named "orderby"; it never submits "order",
621 // so the direction always rides along hidden.
622 if ( $shows( 'sort' ) ) {
623 $editable[] = 'orderby';
624 }
625 foreach ( Mlsimport_Page_Block_Search_Fields::catalog() as $field ) {
626 if ( ! $shows( $field ) ) {
627 continue;
628 }
629 $def = Mlsimport_Page_Block_Search_Fields::definition( $field );
630 // A taxonomy field submits its own key; a column field submits its param list.
631 $editable = array_merge( $editable, isset( $def['params'] ) ? (array) $def['params'] : array( $field ) );
632 }
633
634 $hidden = array();
635 foreach ( Mlsimport_Standalone_Shortcodes::filter_keys() as $key ) {
636 // 'page' is runtime paging — the JS sets it per request, never the form.
637 if ( 'page' === $key || in_array( $key, $editable, true ) ) {
638 continue;
639 }
640 if ( ! isset( $args[ $key ] ) ) {
641 continue;
642 }
643 $value = $args[ $key ];
644 if ( is_array( $value ) ) {
645 $value = array_values( array_filter( array_map( 'strval', $value ), 'strlen' ) );
646 if ( ! $value ) {
647 continue;
648 }
649 $hidden[ $key . '[]' ] = $value;
650 continue;
651 }
652 if ( '' === (string) $value || '0' === (string) $value ) {
653 continue;
654 }
655 $hidden[ $key ] = (string) $value;
656 }
657
658 /** Filter the hidden filter inputs the search form carries through an AJAX repaint. @since 6.6 */
659 return (array) apply_filters( 'mlsimport_search_form_hidden_args', $hidden, $args, $visible );
660 }
661
662 /**
663 * Search Form — a GET form that submits the chosen filters to a results page
664 * (decision 7). Each field is a repeater row ( field + optional label ); only rows
665 * whose field the fast index can filter survive (the classifier), so the form can
666 * never introduce a slow meta search. A comma-list also works (shortcode/legacy).
667 *
668 * The fields lay out on the same 12-column grid the Contact Form block uses, each
669 * row spanning the columns its Width says — the submit button included, so it can
670 * sit inline as the last cell of a row instead of always claiming one of its own.
671 *
672 * @param array $args Block args (results_url, fields, hide_labels, button_*).
673 * @return string
674 */
675 function mlsimport_page_block_search_form( array $args ): string {
676 $action = isset( $args['results_url'] ) ? (string) $args['results_url'] : '';
677 $rows = mlsimport_page_block_normalize_rows( isset( $args['fields'] ) ? $args['fields'] : array(), 'search' );
678
679 $valid = array();
680 foreach ( $rows as $row ) {
681 if ( '' !== Mlsimport_Page_Block_Search_Fields::classify( $row['field'] ) ) {
682 $valid[] = $row;
683 }
684 }
685 /** Filter the search form's field rows. @since 6.4 */
686 $valid = (array) apply_filters( 'mlsimport_search_form_fields', $valid, $args );
687 if ( empty( $valid ) ) {
688 return '';
689 }
690
691 $hide_labels = ! empty( $args['hide_labels'] );
692
693 $out = '<form class="mlsimport-page-block mlsimport-search-form" method="get" action="' . esc_url( $action ) . '">';
694 // An optional heading, spanning the full grid row above the fields.
695 $title = trim( (string) ( $args['title'] ?? '' ) );
696 if ( '' !== $title ) {
697 $out .= '<h3 class="mlsimport-search-form__title">' . esc_html( $title ) . '</h3>';
698 }
699 foreach ( $valid as $row ) {
700 $out .= mlsimport_page_block_search_input( $row, $hide_labels );
701 }
702 $out .= mlsimport_search_form_submit( $args );
703 $out .= '</form>';
704 return $out;
705 }
706
707 /**
708 * The search form's submit button: its configured text, size class, width span and
709 * optional icon, tinted by the configured colour. Colour is inline because it is a
710 * free-form value the stylesheet cannot enumerate; size and width are classes.
711 *
712 * @param array $args Block args (button_text, button_color, button_size, button_icon, button_width).
713 * @return string
714 */
715 function mlsimport_search_form_submit( array $args ): string {
716 $text = isset( $args['button_text'] ) && '' !== trim( (string) $args['button_text'] )
717 ? (string) $args['button_text']
718 : __( 'Search', 'mlsimport' );
719 $size = in_array( (string) ( $args['button_size'] ?? '' ), array( 'small', 'medium', 'large' ), true ) ? (string) $args['button_size'] : 'medium';
720 $class = 'mlsimport-search-form__submit mlsimport-search-form__submit--' . $size
721 . mlsimport_page_block_width_class( (string) ( $args['button_width'] ?? '' ), 'mlsimport-search-form__submit' );
722
723 // A colour only reaches the page if it is a real CSS colour literal; anything
724 // else is dropped rather than printed into the style attribute.
725 $color = (string) ( $args['button_color'] ?? '' );
726 $style = preg_match( '/^(#[0-9a-fA-F]{3,8}|rgba?\([\d\s.,%]+\)|[a-zA-Z]+)$/', $color )
727 ? ' style="background-color:' . esc_attr( $color ) . '"'
728 : '';
729
730 $icon = (string) ( $args['button_icon'] ?? '' );
731 $img = '' !== $icon ? '<img class="mlsimport-search-form__submit-icon" src="' . esc_url( $icon ) . '" alt="" />' : '';
732
733 return '<button type="submit" class="' . esc_attr( $class ) . '"' . $style . '>' . $img . '<span>' . esc_html( $text ) . '</span></button>';
734 }
735
736 /**
737 * Render one search input from a repeater row, resolving the field's definition
738 * from the catalog so taxonomies become term dropdowns and columns their proper
739 * number/range/date/text inputs. The row may override the field label and supplies
740 * its own placeholder and grid width.
741 *
742 * @param array $row { field, label, placeholder, width }.
743 * @param bool $hide_labels Drop the visible label and lean on the placeholder.
744 * @return string
745 */
746 function mlsimport_page_block_search_input( array $row, bool $hide_labels = false ): string {
747 $def = Mlsimport_Page_Block_Search_Fields::definition( (string) $row['field'] );
748 if ( null === $def ) {
749 return '';
750 }
751 $class = 'mlsimport-search-form__field' . mlsimport_page_block_width_class( (string) $row['width'], 'mlsimport-search-form__field' );
752 return mlsimport_render_search_field( $def, array(), (string) $row['label'], $class, array(
753 'placeholder' => (string) $row['placeholder'],
754 'hide_label' => $hide_labels,
755 // Slider bound overrides; only the range control reads them.
756 'min_value' => (string) ( $row['min_value'] ?? '' ),
757 'max_value' => (string) ( $row['max_value'] ?? '' ),
758 ) );
759 }
760
761 /**
762 * Render one search field — the shared renderer behind both the Search Form block
763 * and the front-end search-form.php template, so the two never drift. Taxonomies
764 * render as a term <select> (multi for column-IN / features, single otherwise);
765 * columns render as a range pair, a number, a date, or a text input.
766 *
767 * @param array $def Field definition (Mlsimport_Page_Block_Search_Fields::definition).
768 * @param array $values Current values keyed by query param (for pre-fill); empty = none.
769 * @param string $label_override Optional label replacing the catalog label.
770 * @param string $field_class Wrapper class (the form's own field class namespace).
771 * @param array $opts { placeholder: string, hide_label: bool }. Hiding the
772 * label moves it into the placeholder when the row set
773 * none, so a control is never left unnamed.
774 * @return string Markup, or '' when a taxonomy field has no terms.
775 */
776 function mlsimport_render_search_field( array $def, array $values = array(), string $label_override = '', string $field_class = 'mlsimport-search-form__field', array $opts = array() ): string {
777 $label = '' !== $label_override ? $label_override : (string) $def['label'];
778
779 $hide_label = ! empty( $opts['hide_label'] );
780 $placeholder = isset( $opts['placeholder'] ) ? (string) $opts['placeholder'] : '';
781 // A hidden label has to survive somewhere: it becomes the placeholder unless the
782 // row wrote one. With the label visible, an unset placeholder stays unset.
783 if ( $hide_label && '' === $placeholder ) {
784 $placeholder = $label;
785 }
786 // aria-label keeps the control named for assistive tech once the <span> is gone.
787 $open = '<label class="' . esc_attr( $field_class ) . '"' . ( $hide_label ? ' aria-label="' . esc_attr( $label ) . '"' : '' ) . '>'
788 . ( $hide_label ? '' : '<span>' . esc_html( $label ) . '</span>' );
789 $ph = '' !== $placeholder ? ' placeholder="' . esc_attr( $placeholder ) . '"' : '';
790
791 if ( 'taxonomy' === $def['group'] ) {
792 /** Short-circuit a search field's options before terms are queried (live mode answers from the MLS enums). @since 6.6 */
793 $pairs = apply_filters( 'mlsimport_search_field_options_pre', null, $def );
794
795 if ( ! is_array( $pairs ) ) {
796 $terms = get_terms(
797 array(
798 'taxonomy' => $def['tax'],
799 'hide_empty' => false,
800 )
801 );
802
803 $is_slug = 'slug' === $def['value'];
804 $pairs = array();
805 if ( ! is_wp_error( $terms ) && is_array( $terms ) ) {
806 foreach ( $terms as $term ) {
807 $pairs[ (string) ( $is_slug ? $term->slug : $term->name ) ] = (string) $term->name;
808 }
809 }
810 }
811
812 /** Filter a search field's option list, value => label. @since 6.4 */
813 $pairs = (array) apply_filters( 'mlsimport_search_field_options', $pairs, $def );
814 if ( array() === $pairs ) {
815 return '';
816 }
817
818 $key = (string) $def['key'];
819 $multi = ! empty( $def['multi'] );
820 $current = isset( $values[ $key ] ) ? array_map( 'strval', (array) $values[ $key ] ) : array();
821 $name = $multi ? $key . '[]' : $key;
822
823 // A select has no placeholder attribute, so its empty first option carries the
824 // text instead: the row's placeholder when set, else the usual "Any".
825 $empty_text = '' !== $placeholder ? $placeholder : __( 'Any', 'mlsimport' );
826
827 $options = $multi ? '' : '<option value="">' . esc_html( $empty_text ) . '</option>';
828 foreach ( $pairs as $value => $text ) {
829 $value = (string) $value;
830 $options .= '<option value="' . esc_attr( $value ) . '"' . ( in_array( $value, $current, true ) ? ' selected' : '' ) . '>' . esc_html( (string) $text ) . '</option>';
831 }
832
833 if ( $multi ) {
834 return $open
835 . '<select name="' . esc_attr( $name ) . '" multiple class="mlsimport-multiselect" data-placeholder="' . esc_attr( $empty_text ) . '">' . $options . '</select>'
836 . '</label>';
837 }
838
839 return $open
840 . '<select name="' . esc_attr( $name ) . '">' . $options . '</select>'
841 . '</label>';
842 }
843
844 // Rich column controls (WPResidence-style popups): every range column (price,
845 // living area, lot size, year built) → the same dual-handle slider popup, beds_baths
846 // → one popup with Beds + Baths min-tile rows. Each still submits the same plain
847 // query params via hidden inputs, so the WHERE-builder is untouched and JS-off forms
848 // degrade to the hidden values.
849 $key = (string) $def['key'];
850 if ( 'beds_baths' === $key ) {
851 return mlsimport_render_beds_baths_field( $def, $values, $label, $field_class, $opts );
852 }
853 if ( 'range' === $def['control'] ) {
854 return mlsimport_render_range_slider_field( $def, $values, $label, $field_class, $opts );
855 }
856
857 $params = (array) $def['params'];
858 $val = static function ( $param ) use ( $values ) {
859 return isset( $values[ $param ] ) && ! is_array( $values[ $param ] ) ? (string) $values[ $param ] : '';
860 };
861
862 $param = (string) $params[0];
863
864 // The combined Location box: a plain text input the autocomplete script attaches
865 // to by its data attribute. It degrades to a free-text search with JS off — the
866 // WHERE-builder matches a typed city/ZIP/area/county/address either way.
867 if ( 'location' === $def['control'] ) {
868 // A <div>, not a <span>: a field's only direct <span> child is its label, so
869 // "has this field a visible label?" stays a single unambiguous check.
870 return $open
871 . '<div class="mlsimport-location">'
872 . '<input type="text" name="' . esc_attr( $param ) . '" value="' . esc_attr( $val( $param ) ) . '"' . $ph
873 . ' autocomplete="off" data-mlsimport-location="1" />'
874 . '<ul class="mlsimport-location__list" role="listbox" hidden></ul>'
875 . '</div>'
876 . '</label>';
877 }
878
879 $type = 'number' === $def['control'] ? 'number' : ( 'date' === $def['control'] ? 'date' : 'text' );
880 return $open
881 . '<input type="' . esc_attr( $type ) . '" name="' . esc_attr( $param ) . '" value="' . esc_attr( $val( $param ) ) . '"' . $ph . ' />'
882 . '</label>';
883 }
884
885 /**
886 * The price slider's upper bound: the highest listed price (cached a day), or a
887 * 1,000,000 fallback on an empty index. Filterable so a site can pin its own
888 * ceiling. The min is always 0.
889 *
890 * @return int
891 */
892 function mlsimport_search_price_ceiling(): int {
893 $cached = get_transient( 'mlsimport_search_price_ceiling' );
894 if ( false === $cached ) {
895 global $wpdb;
896 $table = Mlsimport_Standalone_Table::table_name();
897 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
898 $max = (int) $wpdb->get_var( "SELECT MAX(price) FROM {$table}" );
899 $cached = $max > 0 ? $max : 1000000;
900 set_transient( 'mlsimport_search_price_ceiling', $cached, DAY_IN_SECONDS );
901 }
902 /** Filter the price slider's upper bound. @since 6.4 */
903 return max( 1, (int) apply_filters( 'mlsimport_search_price_ceiling', (int) $cached ) );
904 }
905
906 /**
907 * Resolve a range column's slider metadata — its source column, number format and
908 * unit suffix. Year built reads its lower bound from the data (floored) so the
909 * slider spans real years, not 0..now. Kept beside the bounds query so the field
910 * catalog stays pure data.
911 *
912 * @param string $key Field key ('price', 'sqft', 'lot', 'year').
913 * @return array { column: string, format: string, unit: string, floored: bool }
914 */
915 function mlsimport_search_range_meta( string $key ): array {
916 $map = array(
917 'price' => array( 'column' => 'price', 'format' => 'money', 'unit' => '', 'floored' => false ),
918 'sqft' => array( 'column' => 'living_area', 'format' => 'number', 'unit' => 'ft²', 'floored' => false ),
919 'lot' => array( 'column' => 'lot_size', 'format' => 'number', 'unit' => 'ft²', 'floored' => false ),
920 'year' => array( 'column' => 'year_built', 'format' => 'year', 'unit' => '', 'floored' => true ),
921 );
922 return isset( $map[ $key ] ) ? $map[ $key ] : array( 'column' => '', 'format' => 'number', 'unit' => '', 'floored' => false );
923 }
924
925 /**
926 * The min/max bounds for a range column's slider, cached a day. The max is the
927 * column's highest value; the min is 0 unless $floored (year built), where it is
928 * the lowest non-zero value so the slider spans the real years. Price keeps its
929 * own filterable ceiling helper and never comes through here.
930 *
931 * @param string $column Fast-table column (living_area, lot_size, year_built).
932 * @param bool $floored Compute a real lower bound instead of 0.
933 * @return array { min: int, max: int }
934 */
935 function mlsimport_search_range_bounds( string $column, bool $floored ): array {
936 $cache_key = 'mlsimport_search_bounds_' . $column;
937 $cached = get_transient( $cache_key );
938 if ( false === $cached || ! is_array( $cached ) ) {
939 global $wpdb;
940 $table = Mlsimport_Standalone_Table::table_name();
941 // $column is one of a fixed internal set (never user input).
942 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
943 $max = (int) $wpdb->get_var( "SELECT MAX({$column}) FROM {$table}" );
944 $min = 0;
945 if ( $floored ) {
946 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
947 $min = (int) $wpdb->get_var( "SELECT MIN(NULLIF({$column}, 0)) FROM {$table}" );
948 }
949 $cached = array( 'min' => $min, 'max' => $max );
950 set_transient( $cache_key, $cached, DAY_IN_SECONDS );
951 }
952 return $cached;
953 }
954
955 /**
956 * Format a range value for display. 'money' → "$1,234"; 'year' → "1985" (no
957 * grouping); 'number' → "1,234 ft²". $compact gives the short toggle form
958 * ($900K, 1M ft²); years are never compacted. Mirrors formatRange() in the JS.
959 *
960 * @param float $n The value.
961 * @param string $format 'money' | 'number' | 'year'.
962 * @param string $unit Unit suffix for 'number' (e.g. ft²).
963 * @param bool $compact Short form for the narrow toggle button.
964 * @return string
965 */
966 function mlsimport_format_range_value( float $n, string $format, string $unit, bool $compact ): string {
967 if ( 'year' === $format ) {
968 return (string) (int) $n;
969 }
970 $prefix = 'money' === $format ? '$ ' : '';
971 $suffix = ( 'money' !== $format && '' !== $unit ) ? ' ' . $unit : '';
972 if ( $compact ) {
973 if ( $n >= 1000000 ) {
974 return $prefix . rtrim( rtrim( number_format( $n / 1000000, 1 ), '0' ), '.' ) . 'M' . $suffix;
975 }
976 if ( $n >= 1000 ) {
977 return $prefix . number_format( $n / 1000, 0 ) . 'K' . $suffix;
978 }
979 }
980 return $prefix . number_format( $n ) . $suffix;
981 }
982
983 /**
984 * Render a range column (price, living area, lot size, year built) as a dropdown
985 * popup with a dual-handle slider plus min/max inputs (WPResidence feel, vanilla).
986 * The visible inputs and slider are presentation only; two hidden inputs named for
987 * the field's query params carry the actual values. An untouched min (floor) or max
988 * (ceiling) submits empty, so the filter only applies once the user narrows it. JS
989 * enhances; without JS the hidden values still submit.
990 *
991 * @param array $def Field definition (a 'range' control).
992 * @param array $values Current values keyed by query param.
993 * @param string $label Resolved field label.
994 * @param string $field_class Wrapper class (the form's field namespace).
995 * @return string
996 */
997 function mlsimport_render_range_slider_field( array $def, array $values, string $label, string $field_class, array $opts = array() ): string {
998 $key = (string) $def['key'];
999 $meta = mlsimport_search_range_meta( $key );
1000
1001 // This control has no input to hang a placeholder on: its closed state is the
1002 // toggle button, so the placeholder becomes the toggle's "nothing chosen" text.
1003 $hide_label = ! empty( $opts['hide_label'] );
1004 $placeholder = isset( $opts['placeholder'] ) ? (string) $opts['placeholder'] : '';
1005
1006 if ( 'price' === $key ) {
1007 $floor = 0;
1008 $ceiling = mlsimport_search_price_ceiling();
1009 } else {
1010 $bounds = mlsimport_search_range_bounds( $meta['column'], (bool) $meta['floored'] );
1011 $floor = (int) $bounds['min'];
1012 $ceiling = (int) $bounds['max'];
1013 }
1014 // A row may pin either end of the slider. Empty means "keep the computed bound",
1015 // which is why these are checked as strings — a configured floor of 0 is real.
1016 if ( isset( $opts['min_value'] ) && '' !== (string) $opts['min_value'] ) {
1017 $floor = (int) $opts['min_value'];
1018 }
1019 if ( isset( $opts['max_value'] ) && '' !== (string) $opts['max_value'] ) {
1020 $ceiling = (int) $opts['max_value'];
1021 }
1022 $ceiling = max( $floor + 1, $ceiling );
1023 $step = 'year' === $meta['format'] ? 1 : 0; // 0 → the JS picks a nice step.
1024
1025 $param_min = (string) $def['params'][0];
1026 $param_max = (string) $def['params'][1];
1027 $cur_min = isset( $values[ $param_min ] ) && '' !== (string) $values[ $param_min ] ? (int) $values[ $param_min ] : null;
1028 $cur_max = isset( $values[ $param_max ] ) && '' !== (string) $values[ $param_max ] ? (int) $values[ $param_max ] : null;
1029
1030 $lo = null !== $cur_min ? $cur_min : $floor;
1031 $hi = null !== $cur_max ? $cur_max : $ceiling;
1032 $has = ( null !== $cur_min || null !== $cur_max );
1033
1034 $fmt = static function ( $n ) use ( $meta ) {
1035 return mlsimport_format_range_value( (float) $n, $meta['format'], $meta['unit'], false );
1036 };
1037 $compact = static function ( $n ) use ( $meta ) {
1038 return mlsimport_format_range_value( (float) $n, $meta['format'], $meta['unit'], true );
1039 };
1040
1041 $default = '' !== $placeholder ? $placeholder : ( $hide_label ? $label : __( 'Any', 'mlsimport' ) );
1042 $toggle = $has ? ( $compact( $lo ) . ' – ' . $compact( $hi ) ) : $default;
1043
1044 /* translators: %s: field label, e.g. "Living Area". */
1045 $min_aria = sprintf( __( 'Minimum %s', 'mlsimport' ), $label );
1046 /* translators: %s: field label, e.g. "Living Area". */
1047 $max_aria = sprintf( __( 'Maximum %s', 'mlsimport' ), $label );
1048
1049 $out = '<div class="' . esc_attr( $field_class ) . ' mlsimport-range"' . ( $hide_label ? ' aria-label="' . esc_attr( $label ) . '"' : '' ) . ' data-mlsimport-range data-format="' . esc_attr( $meta['format'] ) . '" data-unit="' . esc_attr( $meta['unit'] ) . '">';
1050 $out .= $hide_label ? '' : '<span>' . esc_html( $label ) . '</span>';
1051 $out .= '<button type="button" class="mlsimport-range__toggle" data-default="' . esc_attr( $default ) . '">' . esc_html( $toggle ) . '</button>';
1052 $out .= '<div class="mlsimport-range__popup">';
1053 $out .= '<div class="mlsimport-range__fields">';
1054 $out .= '<input type="text" class="mlsimport-range__display mlsimport-range__display--min" inputmode="numeric" value="' . esc_attr( $fmt( $lo ) ) . '" aria-label="' . esc_attr( $min_aria ) . '" />';
1055 $out .= '<span class="mlsimport-range__sep">–</span>';
1056 $out .= '<input type="text" class="mlsimport-range__display mlsimport-range__display--max" inputmode="numeric" value="' . esc_attr( $fmt( $hi ) ) . '" aria-label="' . esc_attr( $max_aria ) . '" />';
1057 $out .= '</div>';
1058 $out .= '<div class="mlsimport-range__slider" data-min="' . esc_attr( (string) $floor ) . '" data-max="' . esc_attr( (string) $ceiling ) . '"' . ( $step > 0 ? ' data-step="' . esc_attr( (string) $step ) . '"' : '' ) . '>';
1059 $out .= '<div class="mlsimport-range__rail"></div>';
1060 $out .= '<div class="mlsimport-range__range"></div>';
1061 $out .= '<span class="mlsimport-range__handle mlsimport-range__handle--min" tabindex="0" role="slider" aria-label="' . esc_attr( $min_aria ) . '"></span>';
1062 $out .= '<span class="mlsimport-range__handle mlsimport-range__handle--max" tabindex="0" role="slider" aria-label="' . esc_attr( $max_aria ) . '"></span>';
1063 $out .= '</div>';
1064 $out .= '<div class="mlsimport-range__actions">';
1065 $out .= '<button type="button" class="mlsimport-range__reset">' . esc_html__( 'Reset', 'mlsimport' ) . '</button>';
1066 $out .= '<button type="button" class="mlsimport-range__done">' . esc_html__( 'Done', 'mlsimport' ) . '</button>';
1067 $out .= '</div>';
1068 $out .= '<input type="hidden" name="' . esc_attr( $param_min ) . '" class="mlsimport-range__value-min" value="' . esc_attr( null !== $cur_min ? (string) $cur_min : '' ) . '" />';
1069 $out .= '<input type="hidden" name="' . esc_attr( $param_max ) . '" class="mlsimport-range__value-max" value="' . esc_attr( null !== $cur_max ? (string) $cur_max : '' ) . '" />';
1070 $out .= '</div>'; // .mlsimport-range__popup
1071 $out .= '</div>'; // .mlsimport-range
1072 return $out;
1073 }
1074
1075 /**
1076 * Render the combined Beds & Baths field as one dropdown popup with two rows of
1077 * "N+" minimum tiles (WPResidence feel, vanilla). Two hidden inputs named beds and
1078 * baths carry the chosen minimums; an unselected row submits empty. JS enhances;
1079 * without JS the hidden values still submit.
1080 *
1081 * @param array $def Field definition (key is 'beds_baths').
1082 * @param array $values Current values keyed by query param.
1083 * @param string $label Resolved field label.
1084 * @param string $field_class Wrapper class (the form's field namespace).
1085 * @return string
1086 */
1087 function mlsimport_render_beds_baths_field( array $def, array $values, string $label, string $field_class, array $opts = array() ): string {
1088 // Like the range control, this one closes to a toggle button rather than an
1089 // input, so the placeholder becomes the toggle's "nothing chosen" text.
1090 $hide_label = ! empty( $opts['hide_label'] );
1091 $placeholder = isset( $opts['placeholder'] ) ? (string) $opts['placeholder'] : '';
1092
1093 $beds = isset( $values['beds'] ) && '' !== (string) $values['beds'] ? (string) (int) $values['beds'] : '';
1094 $baths = isset( $values['baths'] ) && '' !== (string) $values['baths'] ? (string) (int) $values['baths'] : '';
1095
1096 $parts = array();
1097 if ( '' !== $beds ) {
1098 /* translators: %s: minimum bedrooms, e.g. "2+". */
1099 $parts[] = sprintf( __( '%s bd', 'mlsimport' ), $beds . '+' );
1100 }
1101 if ( '' !== $baths ) {
1102 /* translators: %s: minimum bathrooms, e.g. "2+". */
1103 $parts[] = sprintf( __( '%s ba', 'mlsimport' ), $baths . '+' );
1104 }
1105 $empty = '' !== $placeholder ? $placeholder : ( $hide_label ? $label : __( 'Beds | Baths', 'mlsimport' ) );
1106 $default = esc_attr( $empty );
1107 $toggle = empty( $parts ) ? $empty : implode( ' · ', $parts );
1108
1109 // One row of 1+..6+ tiles for a group ('beds' or 'baths'), the matching one marked.
1110 $grid = static function ( string $group, string $cur ) {
1111 $out = '<div class="mlsimport-bedsbaths__grid" data-group="' . esc_attr( $group ) . '">';
1112 for ( $i = 1; $i <= 6; $i++ ) {
1113 $val = (string) $i;
1114 $selected = $val === $cur ? ' is-selected' : '';
1115 $out .= '<button type="button" class="mlsimport-bedsbaths__item' . $selected . '" data-value="' . esc_attr( $val ) . '">' . esc_html( $val . '+' ) . '</button>';
1116 }
1117 return $out . '</div>';
1118 };
1119
1120 $out = '<div class="' . esc_attr( $field_class ) . ' mlsimport-bedsbaths"' . ( $hide_label ? ' aria-label="' . esc_attr( $label ) . '"' : '' ) . ' data-mlsimport-bedsbaths>';
1121 $out .= $hide_label ? '' : '<span>' . esc_html( $label ) . '</span>';
1122 $out .= '<button type="button" class="mlsimport-bedsbaths__toggle" data-default="' . $default . '">' . esc_html( $toggle ) . '</button>';
1123 $out .= '<div class="mlsimport-bedsbaths__popup">';
1124 $out .= '<h4 class="mlsimport-bedsbaths__heading">' . esc_html__( 'Beds', 'mlsimport' ) . '</h4>';
1125 $out .= $grid( 'beds', $beds );
1126 $out .= '<h4 class="mlsimport-bedsbaths__heading">' . esc_html__( 'Baths', 'mlsimport' ) . '</h4>';
1127 $out .= $grid( 'baths', $baths );
1128 $out .= '<div class="mlsimport-bedsbaths__actions">';
1129 $out .= '<button type="button" class="mlsimport-bedsbaths__reset">' . esc_html__( 'Reset', 'mlsimport' ) . '</button>';
1130 $out .= '<button type="button" class="mlsimport-bedsbaths__done">' . esc_html__( 'Done', 'mlsimport' ) . '</button>';
1131 $out .= '</div>';
1132 $out .= '<input type="hidden" name="beds" class="mlsimport-bedsbaths__value" data-group="beds" value="' . esc_attr( $beds ) . '" />';
1133 $out .= '<input type="hidden" name="baths" class="mlsimport-bedsbaths__value" data-group="baths" value="' . esc_attr( $baths ) . '" />';
1134 $out .= '</div>'; // .mlsimport-bedsbaths__popup
1135 $out .= '</div>'; // .mlsimport-bedsbaths
1136 return $out;
1137 }
1138
1139 /**
1140 * Contact Form — a configurable field set (repeater rows) that submits to the
1141 * shared lead endpoint. A contact lead carries no property/agent, so the recipient
1142 * resolves to the "Contact form recipients" setting (wired via the lead recipient
1143 * filter). A comma-list also works (shortcode/legacy).
1144 *
1145 * @param array $args Block args (title, fields, hide_labels, input_size, show_consent, button_*).
1146 * @return string
1147 */
1148 function mlsimport_page_block_contact_form( array $args ): string {
1149 $rows = mlsimport_page_block_normalize_rows( isset( $args['fields'] ) ? $args['fields'] : 'name,email,message', 'contact' );
1150 /** Filter the contact form's field rows. @since 6.4 */
1151 $rows = (array) apply_filters( 'mlsimport_contact_form_fields', $rows, $args );
1152 if ( empty( $rows ) ) {
1153 return '';
1154 }
1155
1156 $hide_labels = ! empty( $args['hide_labels'] );
1157 // Field size and button alignment are presets the stylesheet enumerates, so they
1158 // ride on the form as modifier classes rather than inline styles.
1159 $size = mlsimport_page_block_size( $args['input_size'] ?? '' );
1160 $align = in_array( (string) ( $args['button_align'] ?? '' ), array( 'start', 'center', 'end', 'stretch' ), true )
1161 ? (string) $args['button_align']
1162 : 'start';
1163 $class = 'mlsimport-page-block mlsimport-contact-form'
1164 . ' mlsimport-contact-form--' . $size
1165 . ' mlsimport-contact-form--btn-' . $align;
1166
1167 $out = '<form class="' . esc_attr( $class ) . '" method="post" data-mlsimport-lead="contact">';
1168 $out .= wp_nonce_field( Mlsimport_Property_Lead::NONCE, 'nonce', true, false );
1169 // Honeypot — a bot that fills this is dropped server-side.
1170 $out .= '<input type="text" name="mlsimport_hp" value="" class="mlsimport-hp" tabindex="-1" autocomplete="off" aria-hidden="true" />';
1171 $out .= '<input type="hidden" name="action" value="' . esc_attr( Mlsimport_Property_Lead::ACTION ) . '" />';
1172 $out .= '<input type="hidden" name="mlsimport_context" value="contact" />';
1173
1174 // An optional heading, spanning the full grid row above the fields.
1175 $title = trim( (string) ( $args['title'] ?? '' ) );
1176 if ( '' !== $title ) {
1177 $out .= '<h3 class="mlsimport-contact-form__title">' . esc_html( $title ) . '</h3>';
1178 }
1179
1180 foreach ( $rows as $row ) {
1181 $out .= mlsimport_page_block_contact_input( $row, $hide_labels );
1182 }
1183
1184 // The consent checkbox, worded by the site-wide consent settings — the same
1185 // field the property lead forms render, so consent reads identically everywhere.
1186 if ( ! empty( $args['show_consent'] ) && function_exists( 'mlsimport_property_lead_consent_field' ) ) {
1187 $out .= '<div class="mlsimport-contact-form__consent">' . mlsimport_property_lead_consent_field() . '</div>';
1188 }
1189
1190 $out .= mlsimport_contact_form_submit( $args );
1191 $out .= '<div class="mlsimport-property-lead-form__status mlsimport-contact-form__message" role="status"></div>';
1192 $out .= '</form>';
1193 return $out;
1194 }
1195
1196 /**
1197 * The contact form's submit button: its configured text, size class, width span and
1198 * colour. Mirrors mlsimport_search_form_submit() — the two forms share a grid and a
1199 * control vocabulary, so their buttons are built the same way.
1200 *
1201 * @param array $args Block args (button_text, button_color, button_size, button_width).
1202 * @return string
1203 */
1204 function mlsimport_contact_form_submit( array $args ): string {
1205 $text = isset( $args['button_text'] ) && '' !== trim( (string) $args['button_text'] )
1206 ? (string) $args['button_text']
1207 : __( 'Send', 'mlsimport' );
1208 $class = 'mlsimport-contact-form__submit mlsimport-contact-form__submit--' . mlsimport_page_block_size( $args['button_size'] ?? '' )
1209 . mlsimport_page_block_width_class( (string) ( $args['button_width'] ?? '' ), 'mlsimport-contact-form__submit' );
1210
1211 // A colour only reaches the page if it is a real CSS colour literal; anything
1212 // else is dropped rather than printed into the style attribute.
1213 $color = (string) ( $args['button_color'] ?? '' );
1214 $style = preg_match( '/^(#[0-9a-fA-F]{3,8}|rgba?\([\d\s.,%]+\)|[a-zA-Z]+)$/', $color )
1215 ? ' style="background-color:' . esc_attr( $color ) . '"'
1216 : '';
1217
1218 return '<button type="submit" class="' . esc_attr( $class ) . '"' . $style . '><span>' . esc_html( $text ) . '</span></button>';
1219 }
1220
1221 /**
1222 * Normalize a size arg to one of the three steps, defaulting to medium.
1223 *
1224 * @param mixed $raw Configured size.
1225 * @return string small | medium | large
1226 */
1227 function mlsimport_page_block_size( $raw ): string {
1228 return in_array( (string) $raw, array( 'small', 'medium', 'large' ), true ) ? (string) $raw : 'medium';
1229 }
1230
1231 /**
1232 * Render one contact input from a row, named with the mlsimport_ prefix the lead
1233 * processor reads. Honours the row's type, choices, placeholder, required flag
1234 * and width. A dropdown or radio row with no choices renders nothing — there is
1235 * no field to answer.
1236 *
1237 * @param array $row { name, type, label, placeholder, options, required, width }.
1238 * @param bool $hide_labels Drop the visible label and lean on the placeholder.
1239 * @return string
1240 */
1241 function mlsimport_page_block_contact_input( array $row, bool $hide_labels = false ): string {
1242 $key = (string) $row['name'];
1243 if ( '' === $key ) {
1244 return '';
1245 }
1246 $label = '' !== (string) $row['label'] ? (string) $row['label'] : ucwords( str_replace( '_', ' ', $key ) );
1247 $name = 'mlsimport_' . $key;
1248 $required = ( 'yes' === $row['required'] || true === $row['required'] || '1' === (string) $row['required'] ) ? ' required' : '';
1249 $class = 'mlsimport-contact-form__field' . mlsimport_page_block_width_class( (string) $row['width'] );
1250 $choices = mlsimport_page_block_choices( (string) $row['options'] );
1251
1252 // A hidden label has to survive somewhere: it becomes the placeholder unless the
1253 // row wrote one, and aria-label keeps the control named once the <span> is gone.
1254 $placeholder = (string) $row['placeholder'];
1255 if ( $hide_labels && '' === $placeholder ) {
1256 $placeholder = $label;
1257 }
1258 $ph = '' !== $placeholder ? ' placeholder="' . esc_attr( $placeholder ) . '"' : '';
1259 $aria = $hide_labels ? ' aria-label="' . esc_attr( $label ) . '"' : '';
1260
1261 if ( 'radio' === $row['type'] ) {
1262 if ( empty( $choices ) ) {
1263 return '';
1264 }
1265 $out = '<fieldset class="' . esc_attr( $class ) . ' mlsimport-contact-form__field--radio"><legend>' . esc_html( $label ) . '</legend>';
1266 foreach ( $choices as $choice ) {
1267 $out .= '<label><input type="radio" name="' . esc_attr( $name ) . '" value="' . esc_attr( $choice ) . '"' . $required . ' /><span>' . esc_html( $choice ) . '</span></label>';
1268 }
1269 return $out . '</fieldset>';
1270 }
1271
1272 if ( 'checkbox' === $row['type'] ) {
1273 return '<label class="' . esc_attr( $class ) . ' mlsimport-contact-form__field--checkbox">'
1274 . '<input type="checkbox" name="' . esc_attr( $name ) . '" value="yes"' . $required . ' />'
1275 . '<span>' . esc_html( $label ) . '</span></label>';
1276 }
1277
1278 if ( 'select' === $row['type'] ) {
1279 if ( empty( $choices ) ) {
1280 return '';
1281 }
1282 $empty = '' !== $placeholder ? $placeholder : __( 'Select…', 'mlsimport' );
1283 $input = '<select name="' . esc_attr( $name ) . '"' . $required . $aria . '><option value="">' . esc_html( $empty ) . '</option>';
1284 foreach ( $choices as $choice ) {
1285 $input .= '<option value="' . esc_attr( $choice ) . '">' . esc_html( $choice ) . '</option>';
1286 }
1287 $input .= '</select>';
1288 } elseif ( 'textarea' === $row['type'] ) {
1289 $input = '<textarea name="' . esc_attr( $name ) . '" rows="4"' . $ph . $required . $aria . '></textarea>';
1290 } else {
1291 $type = in_array( $row['type'], array( 'email', 'tel' ), true ) ? $row['type'] : 'text';
1292 $input = '<input type="' . esc_attr( $type ) . '" name="' . esc_attr( $name ) . '"' . $ph . $required . $aria . ' />';
1293 }
1294
1295 $caption = $hide_labels ? '' : '<span>' . esc_html( $label ) . '</span>';
1296 return '<label class="' . esc_attr( $class ) . '">' . $caption . $input . '</label>';
1297 }
1298
1299 /**
1300 * Split a row's comma-separated choice list into trimmed, non-empty choices. The
1301 * choice text is both the submitted value and the visible option label.
1302 *
1303 * @param string $raw Comma list.
1304 * @return array<int,string>
1305 */
1306 function mlsimport_page_block_choices( string $raw ): array {
1307 if ( '' === trim( $raw ) ) {
1308 return array();
1309 }
1310 return array_values( array_filter( array_map( 'trim', explode( ',', $raw ) ), static function ( $v ) {
1311 return '' !== $v;
1312 } ) );
1313 }
1314
1315 /**
1316 * The modifier class for a field's width. Unknown or empty widths get no class —
1317 * a field spans the full row by default.
1318 *
1319 * @param string $width One of two_thirds | half | third | quarter.
1320 * @return string Leading-space class, or ''.
1321 */
1322 function mlsimport_page_block_width_class( string $width, string $base = 'mlsimport-contact-form__field' ): string {
1323 if ( ! in_array( $width, array( 'two_thirds', 'half', 'third', 'quarter' ), true ) ) {
1324 return '';
1325 }
1326 return ' ' . $base . '--' . str_replace( '_', '-', $width );
1327 }
1328
1329 /**
1330 * Normalize a form's fields arg into a list of complete rows. Accepts a repeater
1331 * array (rows from Gutenberg/Elementor), or a comma-list string / array of field
1332 * keys (shortcode/legacy) which becomes default rows.
1333 *
1334 * A comma-list item may carry a width after a colon — "key:width", e.g.
1335 * fields="location:half,price:third,mls_number:third" — so a shortcode can lay
1336 * its fields out on a grid the way the block/Elementor repeater can. A bare key
1337 * (no colon) keeps the default full width; an unknown width is simply ignored by
1338 * mlsimport_page_block_width_class(), so the field still renders.
1339 *
1340 * @param mixed $raw Repeater rows, comma string, or array of keys.
1341 * @param string $kind 'search' | 'contact'.
1342 * @return array<int,array<string,mixed>>
1343 */
1344 function mlsimport_page_block_normalize_rows( $raw, string $kind ): array {
1345 if ( is_string( $raw ) ) {
1346 $raw = array_filter( array_map( 'trim', explode( ',', $raw ) ), static function ( $v ) {
1347 return '' !== $v;
1348 } );
1349 }
1350 if ( ! is_array( $raw ) ) {
1351 return array();
1352 }
1353
1354 $rows = array();
1355 foreach ( $raw as $item ) {
1356 if ( is_array( $item ) ) {
1357 $rows[] = mlsimport_page_block_normalize_row( $item, $kind );
1358 } elseif ( is_string( $item ) && '' !== trim( $item ) ) {
1359 // Split "key:width" BEFORE sanitizing: sanitize_key() strips the colon, so
1360 // sanitizing the whole item would glue the two halves into one bogus key.
1361 $parts = explode( ':', trim( $item ), 2 );
1362 $row = mlsimport_page_block_row_from_key( sanitize_key( $parts[0] ), $kind );
1363 // The width is sanitized on its own; a bare key leaves the default ''.
1364 $row['width'] = isset( $parts[1] ) ? sanitize_key( $parts[1] ) : '';
1365 $rows[] = $row;
1366 }
1367 }
1368 return $rows;
1369 }
1370
1371 /**
1372 * Fill a repeater row's missing keys with defaults for its kind.
1373 *
1374 * @param array $row Partial row.
1375 * @param string $kind 'search' | 'contact'.
1376 * @return array
1377 */
1378 function mlsimport_page_block_normalize_row( array $row, string $kind ): array {
1379 if ( 'search' === $kind ) {
1380 return array(
1381 'field' => isset( $row['field'] ) ? sanitize_key( (string) $row['field'] ) : '',
1382 'label' => isset( $row['label'] ) ? (string) $row['label'] : '',
1383 'placeholder' => isset( $row['placeholder'] ) ? (string) $row['placeholder'] : '',
1384 // Slider bound overrides. Only a numeric value is a bound; anything else
1385 // (empty, stray text) normalizes to '' and the computed bound stands.
1386 'min_value' => isset( $row['min_value'] ) && is_numeric( $row['min_value'] ) ? (string) (int) $row['min_value'] : '',
1387 'max_value' => isset( $row['max_value'] ) && is_numeric( $row['max_value'] ) ? (string) (int) $row['max_value'] : '',
1388 'width' => isset( $row['width'] ) ? (string) $row['width'] : '',
1389 );
1390 }
1391
1392 $label = isset( $row['label'] ) ? (string) $row['label'] : '';
1393 $name = isset( $row['name'] ) && '' !== (string) $row['name'] ? sanitize_key( (string) $row['name'] ) : sanitize_key( $label );
1394 return array(
1395 'name' => $name,
1396 'type' => isset( $row['type'] ) ? (string) $row['type'] : 'text',
1397 'label' => $label,
1398 'placeholder' => isset( $row['placeholder'] ) ? (string) $row['placeholder'] : '',
1399 'options' => isset( $row['options'] ) ? (string) $row['options'] : '',
1400 'required' => isset( $row['required'] ) ? $row['required'] : '',
1401 'width' => isset( $row['width'] ) ? (string) $row['width'] : '',
1402 );
1403 }
1404
1405 /**
1406 * Build a default row from a bare field key (the comma-list path).
1407 *
1408 * @param string $key Field key.
1409 * @param string $kind 'search' | 'contact'.
1410 * @return array
1411 */
1412 function mlsimport_page_block_row_from_key( string $key, string $kind ): array {
1413 if ( 'search' === $kind ) {
1414 return array( 'field' => $key, 'label' => '', 'placeholder' => '', 'min_value' => '', 'max_value' => '', 'width' => '' );
1415 }
1416
1417 $type = 'email' === $key ? 'email' : ( 'phone' === $key ? 'tel' : ( 'message' === $key ? 'textarea' : 'text' ) );
1418 return array(
1419 'name' => $key,
1420 'type' => $type,
1421 'label' => '',
1422 'placeholder' => '',
1423 'options' => '',
1424 'required' => in_array( $key, array( 'name', 'email' ), true ) ? 'yes' : '',
1425 'width' => '',
1426 );
1427 }
1428