PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2.1
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 6.0.5 All 36 releases
mlsimport / includes / standalone / page-blocks.php

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

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