PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.1.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.1.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-block-registry.php

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

603 lines 31.6 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 manifest + dispatcher.
4 *
5 * Page blocks are the building blocks a site builder drops onto a normal page
6 * (home, landing, agent) — a property list, a slider, a map, a search form, a
7 * contact form, a featured property. They are the page-building counterpart to
8 * Property sections (ADR-0005): same one-function-wrapped-per-builder pattern,
9 * but driven by their own args (a query/config), never the single-property loop.
10 *
11 * One registry maps a block slug to its metadata (label, render callback, arg
12 * schema, required assets). Every builder adapter (Shortcode, Gutenberg,
13 * Elementor, future builders) loops this manifest, so adding a block is one
14 * register call + one render fn and all builders pick it up. The dispatcher is the
15 * single place that resolves the render fn, merges arg defaults, enqueues the
16 * block's assets and fires the WooCommerce-style hooks. See docs/adr/0007 and
17 * CONTEXT.md (Page block).
18 *
19 * @package Mlsimport
20 */
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit;
24 }
25
26 require_once __DIR__ . '/page-blocks.php';
27 require_once __DIR__ . '/page-blocks-categories.php';
28 require_once __DIR__ . '/page-blocks-agents.php';
29 require_once __DIR__ . '/class-mlsimport-property-section-assets.php';
30
31 /**
32 * Register a page block in the manifest.
33 *
34 * @param string $slug Block slug (e.g. 'item_list').
35 * @param array $def {
36 * @type string $label Editor-facing label.
37 * @type callable $render Render fn: ( array $args ) => string.
38 * @type array $args Arg schema: key => { type, label, default, options }.
39 * @type array $assets Optional asset handles to enqueue when rendered.
40 * }
41 * @return void
42 */
43 function mlsimport_register_page_block( string $slug, array $def ): void {
44 // Grab the shared registry by reference so the write persists.
45 $registry =& mlsimport_page_block_registry();
46
47 // Store the block under its slug, filling each key with a safe default.
48 $registry[ $slug ] = array(
49 'label' => isset( $def['label'] ) ? (string) $def['label'] : $slug,
50 'render' => isset( $def['render'] ) ? $def['render'] : '',
51 'args' => isset( $def['args'] ) ? (array) $def['args'] : array(),
52 'assets' => isset( $def['assets'] ) ? (array) $def['assets'] : array(),
53 );
54 }
55
56 /**
57 * The registered page blocks, slug => definition.
58 *
59 * @return array
60 */
61 function mlsimport_get_page_blocks(): array {
62 // Ensure the built-ins are registered before exposing the manifest.
63 mlsimport_register_builtin_page_blocks();
64 /** Filter the page-block manifest: add/remove/reorder blocks globally. @since 6.4 */
65 return (array) apply_filters( 'mlsimport_page_blocks', mlsimport_page_block_registry() );
66 }
67
68 /**
69 * The default args for a block, taken from its schema.
70 *
71 * @param string $slug Block slug.
72 * @return array key => default.
73 */
74 function mlsimport_page_block_defaults( string $slug ): array {
75 // Unknown slug → no defaults.
76 $registry = mlsimport_page_block_registry();
77 if ( ! isset( $registry[ $slug ] ) ) {
78 return array();
79 }
80 // Collapse the arg schema to a flat key => default map.
81 $defaults = array();
82 foreach ( $registry[ $slug ]['args'] as $key => $schema ) {
83 $defaults[ $key ] = isset( $schema['default'] ) ? $schema['default'] : '';
84 }
85 return $defaults;
86 }
87
88 /**
89 * Render a registered page block by slug (the one dispatch point for all builders).
90 *
91 * @param string $slug Block slug.
92 * @param array $args Caller args (override the schema defaults).
93 * @return string HTML, or '' for an unknown slug / uncallable render fn.
94 */
95 function mlsimport_render_page_block( string $slug, array $args = array() ): string {
96 // Ensure built-ins exist, then resolve the block's definition.
97 mlsimport_register_builtin_page_blocks();
98 $registry = mlsimport_page_block_registry();
99 // Unknown slug or non-callable render fn → nothing to render.
100 if ( ! isset( $registry[ $slug ] ) || ! is_callable( $registry[ $slug ]['render'] ) ) {
101 return '';
102 }
103
104 // Schema defaults first; caller args win.
105 $args = array_merge( mlsimport_page_block_defaults( $slug ), $args );
106 /** Filter the resolved block args before render. @since 6.4 */
107 $args = (array) apply_filters( 'mlsimport_page_block_args', $args, $slug );
108
109 // Action slot before the block (buffered so the function returns one string).
110 ob_start();
111 /** Inject markup before any page block; $slug identifies which. @since 6.4 */
112 do_action( 'mlsimport_before_page_block', $slug, $args );
113 /** Inject markup before a page block. Dynamic by slug. @since 6.4 */
114 do_action( "mlsimport_before_page_block_{$slug}", $args );
115 $before = (string) ob_get_clean();
116
117 // Call the block's render fn to produce its core markup.
118 $html = (string) call_user_func( $registry[ $slug ]['render'], $args );
119
120 // Data-driven: a block's assets load only when it actually renders output.
121 if ( '' !== $html ) {
122 Mlsimport_Property_Section_Assets::enqueue( $registry[ $slug ]['assets'] );
123 }
124
125 /** Filter a single block's markup. Dynamic by slug. @since 6.4 */
126 $html = apply_filters( "mlsimport_page_block_{$slug}_html", $html, $args );
127 /** Filter any block's markup; $slug identifies which. @since 6.4 */
128 $html = apply_filters( 'mlsimport_page_block_html', $html, $slug, $args );
129
130 // Action slot after the block.
131 ob_start();
132 /** Inject markup after a page block. Dynamic by slug. @since 6.4 */
133 do_action( "mlsimport_after_page_block_{$slug}", $args );
134 /** Inject markup after any page block; $slug identifies which. @since 6.4 */
135 do_action( 'mlsimport_after_page_block', $slug, $args );
136 $after = (string) ob_get_clean();
137
138 // Concatenate the buffered before/after slots around the block markup.
139 return $before . $html . $after;
140 }
141
142 /**
143 * Register the plugin's built-in page blocks. Idempotent.
144 *
145 * @return void
146 */
147 function mlsimport_register_builtin_page_blocks(): void {
148 // Idempotence: register the built-ins at most once per request.
149 static $done = false;
150 if ( $done ) {
151 return;
152 }
153 $done = true;
154
155 // Asset-handle bundles reused across several block definitions below.
156 $slider = array( 'mlsimport-splide', 'mlsimport-property-slider', 'mlsimport-glightbox', 'mlsimport-property-lightbox' );
157 $lead = array( 'mlsimport-property-lead' );
158
159 // Search field catalog (key => label) for the search-form repeater's field
160 // picker: every taxonomy, then every fast-table column except the geo coords.
161 require_once __DIR__ . '/class-mlsimport-page-block-search-fields.php';
162 $catalog = Mlsimport_Page_Block_Search_Fields::labels();
163 // The range-slider fields, which are the only rows whose slider bounds can be
164 // overridden — used as the editor condition on the min/max row controls.
165 $range_fields = Mlsimport_Page_Block_Search_Fields::range_fields();
166
167 // A field's width is its share of a 12-column row, shared verbatim by the search
168 // and contact form repeaters so a site builder learns one vocabulary.
169 $width_field = array(
170 'type' => 'select',
171 'label' => __( 'Width', 'mlsimport' ),
172 'default' => '',
173 // Labelled as fractions of the row — shorter to scan in the inspector than
174 // words, and it reads as a proportion, which is what the value actually is.
175 // The stored values are unchanged, so existing pages keep their widths.
176 'options' => array(
177 '' => __( '1/1 (full row)', 'mlsimport' ),
178 'two_thirds' => __( '2/3', 'mlsimport' ),
179 'half' => __( '1/2', 'mlsimport' ),
180 'third' => __( '1/3', 'mlsimport' ),
181 'quarter' => __( '1/4', 'mlsimport' ),
182 ),
183 );
184
185 // The size steps a form's buttons and fields step through, shared by the search and
186 // contact forms so "medium" means the same thing on both.
187 $size_options = array(
188 'small' => __( 'Small', 'mlsimport' ),
189 'medium' => __( 'Medium', 'mlsimport' ),
190 'large' => __( 'Large', 'mlsimport' ),
191 );
192
193 // Repeater row schema for the search form (field key, optional label/placeholder,
194 // and the row's width).
195 $search_rows = array(
196 'type' => 'repeater',
197 'label' => __( 'Search fields', 'mlsimport' ),
198 'default' => array(
199 array( 'field' => 'location', 'label' => __( 'Location', 'mlsimport' ), 'placeholder' => __( 'City, area, county or ZIP', 'mlsimport' ), 'width' => 'half' ),
200 array( 'field' => 'property_type', 'label' => __( 'Type', 'mlsimport' ), 'placeholder' => '', 'width' => 'half' ),
201 array( 'field' => 'price', 'label' => __( 'Price', 'mlsimport' ), 'placeholder' => '', 'width' => 'third' ),
202 array( 'field' => 'beds_baths', 'label' => __( 'Beds & Baths', 'mlsimport' ), 'placeholder' => '', 'width' => 'third' ),
203 ),
204 'fields' => array(
205 'field' => array( 'type' => 'select', 'label' => __( 'Field', 'mlsimport' ), 'default' => 'city', 'options' => $catalog ),
206 'label' => array( 'type' => 'text', 'label' => __( 'Label', 'mlsimport' ), 'default' => '' ),
207 'placeholder' => array( 'type' => 'text', 'label' => __( 'Placeholder (shown when labels are hidden)', 'mlsimport' ), 'default' => '' ),
208 // Slider bounds, for the range fields only (price, living area, lot, year).
209 // Left empty each field keeps the bounds computed from the live data; set,
210 // they pin the slider to a chosen window. One pair covers every range field
211 // rather than a price-only special case.
212 // Text, not number: the editors coerce an emptied number control to 0, which
213 // would read as a real bound of zero instead of "unset".
214 'min_value' => array(
215 'type' => 'text',
216 'label' => __( 'Slider minimum', 'mlsimport' ),
217 'default' => '',
218 'condition' => array( 'field' => $range_fields ),
219 ),
220 'max_value' => array(
221 'type' => 'text',
222 'label' => __( 'Slider maximum', 'mlsimport' ),
223 'default' => '',
224 'condition' => array( 'field' => $range_fields ),
225 ),
226 'width' => $width_field,
227 ),
228 );
229
230 // Repeater row schema for the contact form (per-field label/name/type/etc.).
231 // A field's width is its share of the row: two halves sit side by side, three
232 // thirds make a three-up row, and the default (full) keeps a field on its own.
233 $contact_rows = array(
234 'type' => 'repeater',
235 'label' => __( 'Form fields', 'mlsimport' ),
236 'default' => array(
237 array( 'name' => 'name', 'type' => 'text', 'label' => __( 'Name', 'mlsimport' ), 'placeholder' => '', 'options' => '', 'required' => 'yes', 'width' => 'half' ),
238 array( 'name' => 'email', 'type' => 'email', 'label' => __( 'Email', 'mlsimport' ), 'placeholder' => '', 'options' => '', 'required' => 'yes', 'width' => 'half' ),
239 array( 'name' => 'phone', 'type' => 'tel', 'label' => __( 'Phone', 'mlsimport' ), 'placeholder' => '', 'options' => '', 'required' => '', 'width' => '' ),
240 array( 'name' => 'message', 'type' => 'textarea', 'label' => __( 'Message', 'mlsimport' ), 'placeholder' => '', 'options' => '', 'required' => '', 'width' => '' ),
241 ),
242 'fields' => array(
243 'label' => array( 'type' => 'text', 'label' => __( 'Label', 'mlsimport' ), 'default' => '' ),
244 'name' => array( 'type' => 'text', 'label' => __( 'Field name', 'mlsimport' ), 'default' => '' ),
245 'type' => array(
246 'type' => 'select',
247 'label' => __( 'Type', 'mlsimport' ),
248 'default' => 'text',
249 'options' => array(
250 'text' => __( 'Text', 'mlsimport' ),
251 'email' => __( 'Email', 'mlsimport' ),
252 'tel' => __( 'Phone', 'mlsimport' ),
253 'textarea' => __( 'Message', 'mlsimport' ),
254 'select' => __( 'Dropdown', 'mlsimport' ),
255 'radio' => __( 'Radio buttons', 'mlsimport' ),
256 'checkbox' => __( 'Checkbox', 'mlsimport' ),
257 ),
258 ),
259 'options' => array( 'type' => 'text', 'label' => __( 'Choices, comma separated (dropdown and radio only)', 'mlsimport' ), 'default' => '' ),
260 'placeholder' => array( 'type' => 'text', 'label' => __( 'Placeholder', 'mlsimport' ), 'default' => '' ),
261 'required' => array( 'type' => 'toggle', 'label' => __( 'Required', 'mlsimport' ), 'default' => '' ),
262 'width' => $width_field,
263 ),
264 );
265
266 // Shared selection controls (taxonomy + sort + count, or explicit ids).
267 $sort_options = array(
268 '' => __( 'Default', 'mlsimport' ),
269 'newest' => __( 'Newest', 'mlsimport' ),
270 'price_low' => __( 'Price (low to high)', 'mlsimport' ),
271 'price_high' => __( 'Price (high to low)', 'mlsimport' ),
272 );
273 $selection = array(
274 'city' => array( 'type' => 'text', 'label' => __( 'City', 'mlsimport' ), 'default' => '' ),
275 'property_type' => array( 'type' => 'text', 'label' => __( 'Property type', 'mlsimport' ), 'default' => '' ),
276 'status' => array( 'type' => 'text', 'label' => __( 'Status', 'mlsimport' ), 'default' => '' ),
277 'features' => array( 'type' => 'text', 'label' => __( 'Features (comma list)', 'mlsimport' ), 'default' => '' ),
278 'sort' => array( 'type' => 'select', 'label' => __( 'Sort', 'mlsimport' ), 'default' => 'newest', 'options' => $sort_options ),
279 'count' => array( 'type' => 'number', 'label' => __( 'How many', 'mlsimport' ), 'default' => 6 ),
280 'ids' => array( 'type' => 'text', 'label' => __( 'Explicit IDs (comma list)', 'mlsimport' ), 'default' => '' ),
281 );
282 $ids_only = array(
283 'ids' => array( 'type' => 'textarea', 'label' => __( 'Property WordPress Post ID (comma list)', 'mlsimport' ), 'default' => '' ),
284 'count' => array( 'type' => 'number', 'label' => __( 'Per page', 'mlsimport' ), 'default' => 12 ),
285 );
286
287 // Half Map initial filter: every listings filter key as an optional text
288 // preset — the exact set the standalone listings block exposes — so a builder
289 // can pre-constrain the block (a "Miami condos" landing page). 'page' is
290 // runtime paging, not a preset, so it is skipped.
291 $listings_presets = array();
292 foreach ( Mlsimport_Standalone_Shortcodes::filter_keys() as $fkey ) {
293 if ( 'page' === $fkey ) {
294 continue;
295 }
296 $listings_presets[ $fkey ] = array(
297 'type' => 'text',
298 'label' => ucwords( str_replace( '_', ' ', $fkey ) ),
299 'default' => '',
300 );
301 }
302 // Agent is an agent post ID, not free text — the same control the List Items per
303 // Agent block exposes, so a list/slider can be narrowed to one agent's listings.
304 $listings_presets['agent'] = array( 'type' => 'number', 'label' => __( 'Agent', 'mlsimport' ), 'default' => 0 );
305 // Search fields per row — the search bar lays its fields out as this many columns
306 // (shared by the Search Form, MLS Listings and Half Map widgets). 3–6 only.
307 $fields_per_row_options = array( '3' => '3', '4' => '4', '5' => '5', '6' => '6' );
308
309 // Half Map = the shared initial-filter presets + search-bar display config + map layout.
310 $half_map_args = $listings_presets;
311 $half_map_args['search_fields'] = array( 'type' => 'text', 'label' => __( 'Search fields (comma list; empty = all)', 'mlsimport' ), 'default' => '' );
312 $half_map_args['fields_per_row'] = array( 'type' => 'select', 'label' => __( 'Search fields per row', 'mlsimport' ), 'default' => '3', 'options' => $fields_per_row_options );
313 $half_map_args['map_side'] = array(
314 'type' => 'select',
315 'label' => __( 'Map side', 'mlsimport' ),
316 'default' => 'right',
317 'options' => array( 'right' => __( 'Right', 'mlsimport' ), 'left' => __( 'Left', 'mlsimport' ) ),
318 );
319 $half_map_args['height'] = array( 'type' => 'text', 'label' => __( 'Height (CSS, e.g. 100vh)', 'mlsimport' ), 'default' => '100vh' );
320
321 // Property List = the same listings + initial filter + search bar as the Half
322 // Map, shown above the grid and filtering it via AJAX, but no map pane. Same
323 // manifest controls half_map exposes (presets + search_fields + fields_per_row)
324 // plus a per-page size and a show/hide toggle for the bar (mirrors Search Results).
325 $item_list_args = $listings_presets;
326 // Sizing on this block is PAGINATION: "Per page" is the page size and the pager
327 // governs the rest. The raw `limit` preset is dropped because render_grid lets a
328 // preset limit win over count (see mlsimport_page_block_item_list), which would be
329 // a second, hidden sizing control that silently defeats the pager. The slider drops
330 // the same key for the mirror-image reason: it caps, and has no pager at all.
331 unset( $item_list_args['limit'] );
332 // Geo bounds and freehand polygon are outputs of the map's draw tools, not values a
333 // builder sets by hand — and this block has no map pane — so drop them from the inspector.
334 unset( $item_list_args['lat_min'], $item_list_args['lat_max'], $item_list_args['lng_min'], $item_list_args['lng_max'], $item_list_args['polygon'] );
335 $item_list_args['count'] = array( 'type' => 'number', 'label' => __( 'Per page', 'mlsimport' ), 'default' => 12 );
336 $item_list_args['show_filter_bar'] = array( 'type' => 'toggle', 'label' => __( 'Show filter bar', 'mlsimport' ), 'default' => '1' );
337 $item_list_args['fields_per_row'] = array( 'type' => 'select', 'label' => __( 'Search fields per row', 'mlsimport' ), 'default' => '4', 'options' => $fields_per_row_options );
338 $item_list_args['search_fields'] = array( 'type' => 'text', 'label' => __( 'Search fields (comma list; empty = all)', 'mlsimport' ), 'default' => '' );
339
340 // Content Slider = friendly How-many + Order + explicit IDs, then the SAME initial-
341 // filter presets the Half Map exposes (so city/type/status/features/price/beds/…
342 // live in one place). The raw orderby/order/limit preset keys are dropped: the
343 // slider's own Sort + How-many controls replace them (Selection maps them back).
344 $slider_presets = $listings_presets;
345 unset( $slider_presets['orderby'], $slider_presets['order'], $slider_presets['limit'] );
346 // Geo bounds and freehand polygon are outputs of the map's draw tools, not values a
347 // builder sets by hand — and the slider has no map pane — so drop them from the
348 // inspector, exactly as the Property List block does.
349 unset( $slider_presets['lat_min'], $slider_presets['lat_max'], $slider_presets['lng_min'], $slider_presets['lng_max'], $slider_presets['polygon'] );
350 $slider_args = array(
351 'count' => $selection['count'],
352 'sort' => $selection['sort'],
353 'ids' => $selection['ids'],
354 ) + $slider_presets;
355
356 // Map with Listings = ONLY the shared initial-filter presets (city/type/status/price/
357 // beds/…) — the SAME rich pickers the Content Slider and Gutenberg map expose. A map
358 // plots every match at its coordinates, so it carries no page size (the query map never
359 // honours limit), no order (pins have no sequence) and no explicit-ID surface. This is
360 // the exact key set the Elementor widget builds, so render_settings forwards the SELECT2
361 // term-picker (autocomplete) values and nothing else. Was a flat $selection (raw text
362 // City/Type/Status/Features), which is why the Elementor widget showed plain text boxes.
363 $map_args = $slider_presets;
364
365 // Category widgets (Category Slider + Display Categories): one term multi-select
366 // PER plugin taxonomy (Cities, Property Types, Areas, County, …). The operator
367 // picks the exact terms to show as image tiles; picks from several taxonomies
368 // combine, and the widget shows EXACTLY those terms (pick 2 → 2 tiles). Nothing
369 // picked → nothing rendered. Each picker's option list — the taxonomy's terms —
370 // is resolved lazily by the block-editor / Elementor adapters, never on the front
371 // end (see mlsimport_category_term_options()). show-count is display-only (the
372 // per-tile "N listings" tagline), not a tile count.
373 $cat_taxonomies = class_exists( 'Mlsimport_Standalone_Cpt' ) ? Mlsimport_Standalone_Cpt::taxonomy_labels() : array( 'mlsimport_city' => __( 'Cities', 'mlsimport' ) );
374 $per_row_1_6 = array( '1' => '1', '2' => '2', '3' => '3', '4' => '4', '6' => '6' );
375
376 $cat_selection = array();
377 foreach ( $cat_taxonomies as $tax_slug => $tax_label ) {
378 $cat_selection[ $tax_slug ] = array(
379 'type' => 'multiselect',
380 'label' => $tax_label,
381 'default' => '',
382 'taxonomy' => $tax_slug,
383 );
384 }
385 $cat_selection += array(
386 'show_count' => array( 'type' => 'toggle', 'label' => __( 'Show listing count', 'mlsimport' ), 'default' => '1' ),
387 );
388
389 // The built-in manifest: slug => { label, render fn, arg schema, assets }.
390 $blocks = array(
391 'category_slider' => array(
392 'label' => __( 'Category Slider', 'mlsimport' ),
393 'render' => 'mlsimport_page_block_category_slider',
394 'args' => $cat_selection + array(
395 'design' => array( 'type' => 'select', 'label' => __( 'Design type', 'mlsimport' ), 'default' => '1', 'options' => array( '1' => __( 'Design 1', 'mlsimport' ), '2' => __( 'Design 2', 'mlsimport' ), '3' => __( 'Design 3', 'mlsimport' ) ) ),
396 'per_row' => array( 'type' => 'select', 'label' => __( 'Items per row', 'mlsimport' ), 'default' => '3', 'options' => $per_row_1_6 ),
397 'item_height' => array( 'type' => 'text', 'label' => __( 'Item height (CSS, e.g. 260px)', 'mlsimport' ), 'default' => '260px' ),
398 'gap' => array( 'type' => 'number', 'label' => __( 'Gap (px)', 'mlsimport' ), 'default' => 16 ),
399 'border_radius' => array( 'type' => 'number', 'label' => __( 'Border radius (px)', 'mlsimport' ), 'default' => 8 ),
400 'text_padding' => array( 'type' => 'number', 'label' => __( 'Interior text padding (px)', 'mlsimport' ), 'default' => 16 ),
401 'title_margin' => array( 'type' => 'number', 'label' => __( 'Title bottom margin (px)', 'mlsimport' ), 'default' => 4 ),
402 'tagline_margin' => array( 'type' => 'number', 'label' => __( 'Tagline bottom margin (px)', 'mlsimport' ), 'default' => 0 ),
403 'title_size' => array( 'type' => 'number', 'label' => __( 'Title font size (px)', 'mlsimport' ), 'default' => 18 ),
404 'title_color' => array( 'type' => 'text', 'label' => __( 'Title color (CSS)', 'mlsimport' ), 'default' => '#ffffff' ),
405 ),
406 'assets' => $slider,
407 ),
408 'category_list' => array(
409 'label' => __( 'Display Categories', 'mlsimport' ),
410 'render' => 'mlsimport_page_block_category_list',
411 'args' => $cat_selection + array(
412 'design' => array( 'type' => 'select', 'label' => __( 'Design type', 'mlsimport' ), 'default' => '1', 'options' => array( '1' => __( 'Design 1', 'mlsimport' ), '2' => __( 'Design 2', 'mlsimport' ), '3' => __( 'Design 3', 'mlsimport' ) ) ),
413 'per_row' => array( 'type' => 'select', 'label' => __( 'Items per row', 'mlsimport' ), 'default' => '4', 'options' => $per_row_1_6 ),
414 'display_grid' => array( 'type' => 'toggle', 'label' => __( 'Display as auto grid', 'mlsimport' ), 'default' => '' ),
415 'min_width' => array( 'type' => 'number', 'label' => __( 'Unit minimum width (px, auto grid)', 'mlsimport' ), 'default' => 220 ),
416 'gap' => array( 'type' => 'number', 'label' => __( 'Gap (px)', 'mlsimport' ), 'default' => 16 ),
417 'item_height' => array( 'type' => 'text', 'label' => __( 'Item height (CSS, e.g. 220px)', 'mlsimport' ), 'default' => '220px' ),
418 'text_padding' => array( 'type' => 'number', 'label' => __( 'Interior text padding (px)', 'mlsimport' ), 'default' => 14 ),
419 'title_size' => array( 'type' => 'number', 'label' => __( 'Title font size (px)', 'mlsimport' ), 'default' => 16 ),
420 'title_color' => array( 'type' => 'text', 'label' => __( 'Title color (CSS)', 'mlsimport' ), 'default' => '#ffffff' ),
421 'border_radius' => array( 'type' => 'number', 'label' => __( 'Border radius (px)', 'mlsimport' ), 'default' => 8 ),
422 'border_width' => array( 'type' => 'number', 'label' => __( 'Border width (px, design 3)', 'mlsimport' ), 'default' => 0 ),
423 'border_color' => array( 'type' => 'text', 'label' => __( 'Border color (CSS, design 3)', 'mlsimport' ), 'default' => '#e0e0e0' ),
424 ),
425 ),
426 'item_list' => array(
427 'label' => __( 'Property List', 'mlsimport' ),
428 'render' => 'mlsimport_page_block_item_list',
429 'args' => $item_list_args,
430 ),
431 'saved' => array(
432 'label' => __( 'Saved Properties', 'mlsimport' ),
433 'render' => 'mlsimport_page_block_saved',
434 'args' => array(
435 'count' => array( 'type' => 'number', 'label' => __( 'Per page', 'mlsimport' ), 'default' => 12 ),
436 ),
437 ),
438 'property_list_filters' => array(
439 'label' => __( 'Search Results', 'mlsimport' ),
440 'render' => 'mlsimport_page_block_results',
441 'args' => array(
442 'count' => array( 'type' => 'number', 'label' => __( 'Per page', 'mlsimport' ), 'default' => 12 ),
443 'show_filter_bar' => array( 'type' => 'toggle', 'label' => __( 'Show filter bar', 'mlsimport' ), 'default' => '1' ),
444 'fields_per_row' => array( 'type' => 'select', 'label' => __( 'Search fields per row', 'mlsimport' ), 'default' => '4', 'options' => $fields_per_row_options ),
445 'search_fields' => array( 'type' => 'text', 'label' => __( 'Search fields (comma list; empty = all)', 'mlsimport' ), 'default' => '' ),
446 ),
447 ),
448 'list_by_id' => array(
449 'label' => __( 'List Properties by ID', 'mlsimport' ),
450 'render' => 'mlsimport_page_block_list_by_id',
451 'args' => $ids_only,
452 ),
453 'content_slider' => array(
454 'label' => __( 'Property Slider', 'mlsimport' ),
455 'render' => 'mlsimport_page_block_slider',
456 'args' => $slider_args,
457 'assets' => $slider,
458 ),
459 'agents_directory' => array(
460 'label' => __( 'Team Directory', 'mlsimport' ),
461 'render' => 'mlsimport_page_block_agents_directory',
462 'args' => array(
463 'agents' => array( 'type' => 'multiselect', 'label' => __( 'Specific agents (empty = all)', 'mlsimport' ), 'default' => '', 'post_type' => 'mlsimport_agent' ),
464 'count' => array( 'type' => 'number', 'label' => __( 'How many (0 = all)', 'mlsimport' ), 'default' => 8 ),
465 'featured_only' => array( 'type' => 'toggle', 'label' => __( 'Verified agents only', 'mlsimport' ), 'default' => '' ),
466 'sort' => array(
467 'type' => 'select',
468 'label' => __( 'Order', 'mlsimport' ),
469 'default' => 'featured',
470 'options' => array(
471 'featured' => __( 'Verified first', 'mlsimport' ),
472 'name_asc' => __( 'Alphabetical (A–Z)', 'mlsimport' ),
473 'name_desc' => __( 'Alphabetical (Z–A)', 'mlsimport' ),
474 ),
475 ),
476 'show_title' => array( 'type' => 'toggle', 'label' => __( 'Show job title', 'mlsimport' ), 'default' => '1' ),
477 'show_office' => array( 'type' => 'toggle', 'label' => __( 'Show office name', 'mlsimport' ), 'default' => '1' ),
478 'per_row' => array( 'type' => 'select', 'label' => __( 'Items per row', 'mlsimport' ), 'default' => '4', 'options' => array( '2' => '2', '3' => '3', '4' => '4', '5' => '5', '6' => '6' ) ),
479 'gap' => array( 'type' => 'number', 'label' => __( 'Gap (px)', 'mlsimport' ), 'default' => 16 ),
480 'border_radius' => array( 'type' => 'number', 'label' => __( 'Border radius (px)', 'mlsimport' ), 'default' => 8 ),
481 ),
482 ),
483 'featured_property' => array(
484 'label' => __( 'Featured Property', 'mlsimport' ),
485 'render' => 'mlsimport_page_block_featured',
486 'args' => array(
487 // Text, not number: live mode addresses the listing by ListingKey,
488 // which can be alphanumeric; stored mode (int)-casts it anyway.
489 'id' => array( 'type' => 'text', 'label' => __( 'Property ID', 'mlsimport' ), 'default' => '' ),
490 'design' => array(
491 'type' => 'select',
492 'label' => __( 'Design', 'mlsimport' ),
493 'default' => '1',
494 'options' => array( '1' => '1', '2' => '2', '3' => '3', '4' => '4', '5' => '5', '6' => '6' ),
495 ),
496 ),
497 ),
498 'map_listings' => array(
499 'label' => __( 'Map with Listings', 'mlsimport' ),
500 'render' => 'mlsimport_page_block_map',
501 'args' => $map_args,
502 ),
503 'search_form' => array(
504 'label' => __( 'Search Form', 'mlsimport' ),
505 'render' => 'mlsimport_page_block_search_form',
506 // No "fields per row" here: each row carries its own Width on the shared
507 // 12-column grid, which is strictly more expressive. The listings grid and
508 // half-map forms still use fields_per_row — they have no per-field settings.
509 'args' => array(
510 // A heading above the fields. Empty means no heading at all, so one
511 // control does the work of a show/hide switcher plus a text box.
512 'title' => array( 'type' => 'text', 'label' => __( 'Title (leave empty for none)', 'mlsimport' ), 'default' => '' ),
513 'results_url' => array( 'type' => 'text', 'label' => __( 'Results page URL', 'mlsimport' ), 'default' => '' ),
514 'hide_labels' => array( 'type' => 'toggle', 'label' => __( 'Hide labels (use placeholders)', 'mlsimport' ), 'default' => '' ),
515 'fields' => $search_rows,
516 'button_text' => array( 'type' => 'text', 'label' => __( 'Button text', 'mlsimport' ), 'default' => '' ),
517 'button_color' => array( 'type' => 'color', 'label' => __( 'Button color', 'mlsimport' ), 'default' => '' ),
518 'button_size' => array(
519 'type' => 'select',
520 'label' => __( 'Button size', 'mlsimport' ),
521 'default' => 'medium',
522 'options' => $size_options,
523 ),
524 'button_icon' => array( 'type' => 'media', 'label' => __( 'Button icon', 'mlsimport' ), 'default' => '' ),
525 // Defaults to a third, so out of the box the button sits INLINE as the
526 // last cell of a row rather than claiming a full row of its own.
527 'button_width' => array_merge( $width_field, array( 'label' => __( 'Button width', 'mlsimport' ), 'default' => 'third' ) ),
528 ),
529 ),
530 'contact_form' => array(
531 'label' => __( 'Contact Form', 'mlsimport' ),
532 'render' => 'mlsimport_page_block_contact_form',
533 // Same control vocabulary as the Search Form above — title, hide_labels and
534 // the button_* set mean the same thing on both, so a site builder learns one
535 // form and knows the other.
536 'args' => array(
537 'title' => array( 'type' => 'text', 'label' => __( 'Title (leave empty for none)', 'mlsimport' ), 'default' => '' ),
538 'hide_labels' => array( 'type' => 'toggle', 'label' => __( 'Hide labels (use placeholders)', 'mlsimport' ), 'default' => '' ),
539 'fields' => $contact_rows,
540 // Field height/typography preset. Three steps rather than WpResidence's
541 // five: the Style tab's Padding and Typography controls cover anything
542 // between, so extra presets would just be two ways to say one thing.
543 'input_size' => array(
544 'type' => 'select',
545 'label' => __( 'Field size', 'mlsimport' ),
546 'default' => 'medium',
547 'options' => $size_options,
548 ),
549 // The GDPR/consent checkbox. Its wording is NOT repeated here: it comes
550 // from the site-wide Consent label / Terms link text settings, the same
551 // source the property lead forms use, so one edit changes every form.
552 'show_consent' => array( 'type' => 'toggle', 'label' => __( 'Show consent checkbox', 'mlsimport' ), 'default' => '' ),
553 'button_text' => array( 'type' => 'text', 'label' => __( 'Button text', 'mlsimport' ), 'default' => '' ),
554 'button_color' => array( 'type' => 'color', 'label' => __( 'Button color', 'mlsimport' ), 'default' => '' ),
555 'button_size' => array(
556 'type' => 'select',
557 'label' => __( 'Button size', 'mlsimport' ),
558 'default' => 'medium',
559 'options' => $size_options,
560 ),
561 'button_width' => array_merge( $width_field, array( 'label' => __( 'Button width', 'mlsimport' ), 'default' => '' ) ),
562 // Defaults to Left, not WpResidence's Justified: the button has always sat
563 // left-aligned at its natural width here, and defaulting to Justified
564 // would silently turn it into a full-width bar on every existing page.
565 'button_align' => array(
566 'type' => 'select',
567 'label' => __( 'Button alignment', 'mlsimport' ),
568 'default' => 'start',
569 'options' => array(
570 'start' => __( 'Left', 'mlsimport' ),
571 'center' => __( 'Center', 'mlsimport' ),
572 'end' => __( 'Right', 'mlsimport' ),
573 'stretch' => __( 'Justified', 'mlsimport' ),
574 ),
575 ),
576 ),
577 'assets' => $lead,
578 ),
579 'half_map' => array(
580 'label' => __( 'Half Map', 'mlsimport' ),
581 'render' => 'mlsimport_page_block_half_map',
582 'args' => $half_map_args,
583 'assets' => array( 'mlsimport-half-map' ),
584 ),
585 );
586
587 // Push each built-in definition into the shared registry.
588 foreach ( $blocks as $slug => $def ) {
589 mlsimport_register_page_block( $slug, $def );
590 }
591 }
592
593 /**
594 * Internal: the by-reference registry store.
595 *
596 * @return array
597 */
598 function &mlsimport_page_block_registry(): array {
599 // One process-wide store, returned by reference so callers can mutate it.
600 static $registry = array();
601 return $registry;
602 }
603