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 / admin / settings-app / src / index.js

index.js in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.1.1, at admin/settings-app/src/index.js

1,016 lines 32.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Standalone Design — settings app (theme_id 990).
3 *
4 * Renders on the dedicated "Standalone Design" admin page. Reads/writes the
5 * mlsimport_standalone_options WordPress option through the core settings REST
6 * endpoint (/wp/v2/settings) — no custom save handler. Built with
7 * @wordpress/components so every control is WP-native and accessible.
8 * Compile with: npm run build:settings.
9 *
10 * The layout is a vertical menu on the left and the selected screen's fields on
11 * the right. Each menu item and its fields are declared in the TABS config below;
12 * an item with `subtabs` lists them as indented children in that same left menu.
13 * A generic renderer turns each field into the right control.
14 * Field KEYS must match the PHP registry in
15 * includes/standalone/class-mlsimport-standalone-settings.php — that registry
16 * owns defaults, the REST schema and sanitization.
17 */
18
19 import { createRoot, useState, useEffect, useRef } from '@wordpress/element';
20 import apiFetch from '@wordpress/api-fetch';
21 import {
22 Card,
23 CardBody,
24 TextControl,
25 TextareaControl,
26 SelectControl,
27 ToggleControl,
28 Button,
29 Spinner,
30 Notice,
31 __experimentalHeading as Heading,
32 } from '@wordpress/components';
33 import { __ } from '@wordpress/i18n';
34
35 // wp_options key the whole settings tree reads from and writes back to.
36 const OPTION_KEY = 'mlsimport_standalone_options';
37
38 // Reusable Yes/No option set for `yesno` fields.
39 const YES_NO = [
40 { label: __( 'Yes', 'mlsimport' ), value: 'yes' },
41 { label: __( 'No', 'mlsimport' ), value: 'no' },
42 ];
43
44 /**
45 * Tab + field definitions. type: text | number | email | textarea | select |
46 * yesno | color. Empty `fields` renders a placeholder (scaffolded for later).
47 *
48 * This inline copy is now only a FALLBACK. The live tree comes from PHP as
49 * window.mlsimportFields (mlsimport_standalone_settings_app_config), generated from
50 * the one field registry — so adding a field to the registry surfaces it here (and
51 * in the Customizer) with no JS change. See TABS below.
52 */
53 const TABS_FALLBACK = [
54 {
55 name: 'general',
56 title: __( 'General', 'mlsimport' ),
57 fields: [
58 {
59 key: 'properties_per_page',
60 label: __( 'No. of Properties per Page', 'mlsimport' ),
61 type: 'number',
62 },
63 {
64 key: 'order_by',
65 label: __( 'Order by', 'mlsimport' ),
66 type: 'select',
67 options: [
68 { label: __( 'Default', 'mlsimport' ), value: 'default' },
69 { label: __( 'Price High to Low', 'mlsimport' ), value: 'price_high' },
70 { label: __( 'Price Low to High', 'mlsimport' ), value: 'price_low' },
71 { label: __( 'Newest first', 'mlsimport' ), value: 'newest' },
72 { label: __( 'Oldest first', 'mlsimport' ), value: 'oldest' },
73 { label: __( 'Newest Edited', 'mlsimport' ), value: 'newest_edited' },
74 { label: __( 'Oldest Edited', 'mlsimport' ), value: 'oldest_edited' },
75 { label: __( 'Bedrooms High to Low', 'mlsimport' ), value: 'beds_high' },
76 { label: __( 'Bedrooms Low to High', 'mlsimport' ), value: 'beds_low' },
77 { label: __( 'Bathrooms High to Low', 'mlsimport' ), value: 'baths_high' },
78 { label: __( 'Bathrooms Low to High', 'mlsimport' ), value: 'baths_low' },
79 ],
80 },
81 ],
82 },
83 {
84 name: 'taxonomy_filters',
85 title: __( 'Category page filters', 'mlsimport' ),
86 fields: [
87 {
88 key: 'archive_search_fields',
89 label: __( 'Archive search filters', 'mlsimport' ),
90 help: __( 'Toggle which filters appear in the search bar on the taxonomy and property archive pages. Status, City and Type are on by default.', 'mlsimport' ),
91 type: 'toggles',
92 catalog: 'archive_filters',
93 defaultActive: [ 'status', 'city', 'property_type' ],
94 },
95 ],
96 },
97 {
98 name: 'social',
99 title: __( 'Social & Contact', 'mlsimport' ),
100 fields: [
101 {
102 key: 'lead_recipient',
103 label: __( 'Email', 'mlsimport' ),
104 type: 'email',
105 help: __( 'Company email — e.g. office@domain.com. Also the fallback lead recipient when a listing has no agent email.', 'mlsimport' ),
106 },
107 {
108 key: 'contact_form_recipients',
109 label: __( 'Contact form recipients', 'mlsimport' ),
110 type: 'text',
111 help: __( 'Where the page-builder Contact Form block sends submissions. One or more emails, comma-separated. Leave empty to fall back to the company email above.', 'mlsimport' ),
112 },
113 {
114 key: 'consent_label',
115 label: __( 'Text for the checkbox label', 'mlsimport' ),
116 type: 'textarea',
117 help: __( 'Shown next to the marketing-consent checkbox on contact forms.', 'mlsimport' ),
118 },
119 {
120 key: 'terms_link_text',
121 label: __( 'Text for terms link', 'mlsimport' ),
122 type: 'text',
123 help: __( 'e.g. Privacy Policy.', 'mlsimport' ),
124 },
125 {
126 key: 'show_looking_dropdown',
127 label: __( "Show 'What are you looking to do?' dropdown on contact forms?", 'mlsimport' ),
128 type: 'yesno',
129 help: __( 'Displays an optional dropdown field on Agent, Agency, Developer, and Property contact forms.', 'mlsimport' ),
130 },
131 {
132 key: 'looking_options',
133 label: __( 'Dropdown options (comma-separated)', 'mlsimport' ),
134 type: 'text',
135 },
136 ],
137 },
138 {
139 name: 'maps',
140 title: __( 'Maps', 'mlsimport' ),
141 fields: [
142 {
143 key: 'mapbox_api_key',
144 label: __( 'MapBox API KEY', 'mlsimport' ),
145 type: 'text',
146 help: __( 'Used for tiles when Open Street Maps is enabled. Get a key at https://www.mapbox.com/. If blank, the default OpenStreet server is used (can be slow).', 'mlsimport' ),
147 },
148 {
149 key: 'map_start_lat',
150 label: __( 'Starting Point Latitude', 'mlsimport' ),
151 type: 'number',
152 help: __( 'Numbers only (ex: 40.577906).', 'mlsimport' ),
153 },
154 {
155 key: 'map_start_lng',
156 label: __( 'Starting Point Longitude', 'mlsimport' ),
157 type: 'number',
158 help: __( 'Numbers only (ex: -74.155058).', 'mlsimport' ),
159 },
160 {
161 key: 'map_zoom',
162 label: __( 'Default Maps zoom (1 to 20)', 'mlsimport' ),
163 type: 'number',
164 },
165 {
166 key: 'map_pin_cluster',
167 label: __( 'Use the Pin Cluster on the maps', 'mlsimport' ),
168 type: 'yesno',
169 help: __( 'If yes, nearby pins are grouped in a cluster.', 'mlsimport' ),
170 },
171 {
172 key: 'map_cluster_max_zoom',
173 label: __( 'Maximum zoom level for cluster to appear', 'mlsimport' ),
174 type: 'number',
175 help: __( 'Pin cluster disappears when the map zoom is less than this value.', 'mlsimport' ),
176 },
177 {
178 key: 'map_geolocation_circle',
179 label: __( 'Geolocation Circle over maps (in meters)', 'mlsimport' ),
180 type: 'number',
181 help: __( 'Circle radius for the user geolocation pin. Numbers only (ex: 400).', 'mlsimport' ),
182 },
183 ],
184 },
185 {
186 name: 'property_page',
187 title: __( 'Property Page', 'mlsimport' ),
188 subtabs: [
189 {
190 name: 'pp_general',
191 title: __( 'General', 'mlsimport' ),
192 fields: [
193 {
194 key: 'media_section_type',
195 label: __( 'Media Section Type (property images & video)', 'mlsimport' ),
196 type: 'buttons',
197 help: __( 'Choose how to display the listing images or video.', 'mlsimport' ),
198 options: [
199 { label: __( 'Classic Slider', 'mlsimport' ), value: 'classic' },
200 { label: __( 'Vertical Slider', 'mlsimport' ), value: 'vertical' },
201 { label: __( 'Slider v4', 'mlsimport' ), value: 'v4' },
202 { label: __( 'Multi Image Slider', 'mlsimport' ), value: 'multi' },
203 { label: __( 'Masonry Gallery v1', 'mlsimport' ), value: 'masonry1' },
204 { label: __( 'Masonry Gallery v2', 'mlsimport' ), value: 'masonry2' },
205 ],
206 },
207 ],
208 },
209 {
210 name: 'pp_layout',
211 title: __( 'Property Page Layout', 'mlsimport' ),
212 fields: [
213 {
214 key: 'details_columns',
215 label: __( 'Details Columns', 'mlsimport' ),
216 type: 'buttons',
217 help: __( 'How many columns each details section (Interior, Exterior, Financial…) runs. Collapses automatically on narrow screens.', 'mlsimport' ),
218 options: [
219 { label: __( '3 Columns', 'mlsimport' ), value: '3' },
220 { label: __( '2 Columns', 'mlsimport' ), value: '2' },
221 ],
222 },
223 {
224 key: 'property_sections',
225 label: __( 'Arrange Sections', 'mlsimport' ),
226 help: __( 'Drag sections between Enabled and Disabled to choose which appear, and reorder within a list.', 'mlsimport' ),
227 type: 'sections',
228 },
229 ],
230 },
231 {
232 name: 'pp_attribution',
233 title: __( 'MLS Attribution', 'mlsimport' ),
234 fields: [
235 {
236 key: 'mls_logo_id',
237 label: __( 'MLS logo', 'mlsimport' ),
238 type: 'media',
239 help: __( "Your MLS's required attribution logo. Shown in the MLS Attribution section and on listing cards.", 'mlsimport' ),
240 },
241 {
242 key: 'attribution_text',
243 label: __( 'Disclaimer', 'mlsimport' ),
244 type: 'textarea',
245 rows: 10,
246 help: __(
247 'The disclaimer your MLS requires, shown on every property. Use %mls_id% for the listing\'s MLS number and %year% for the current year. Basic HTML (links, bold, paragraphs) is allowed.',
248 'mlsimport'
249 ),
250 },
251 ],
252 },
253 {
254 name: 'pp_tour',
255 title: __( 'Tour Details', 'mlsimport' ),
256 fields: [
257 {
258 key: 'tour_times',
259 label: __( 'Preferred tour times', 'mlsimport' ),
260 type: 'text',
261 help: __( 'Time slots offered in the "Schedule a Tour" picker on the property page. Comma-separated, e.g. 9:00 AM, 11:30 AM, 2:00 PM, 4:30 PM.', 'mlsimport' ),
262 },
263 ],
264 },
265 {
266 name: 'pp_overview',
267 title: __( 'Overview', 'mlsimport' ),
268 fields: [
269 {
270 key: 'overview_fields',
271 label: __( 'Arrange Fields', 'mlsimport' ),
272 help: __( 'Drag fields between Enabled and Disabled to choose which appear in the Overview section of the property page, and reorder within a list.', 'mlsimport' ),
273 type: 'sections',
274 catalog: 'overview',
275 },
276 ],
277 },
278 ],
279 },
280 {
281 name: 'property_card',
282 title: __( 'Property Card', 'mlsimport' ),
283 fields: [
284 {
285 key: 'card_style',
286 label: __( 'Property card style', 'mlsimport' ),
287 type: 'select',
288 help: __( 'The card design used in every listing grid (search results, lists, sliders, similar listings).', 'mlsimport' ),
289 options: [
290 { label: __( 'V1 — Standard', 'mlsimport' ), value: 'v1' },
291 { label: __( 'V2 — Horizontal', 'mlsimport' ), value: 'v2' },
292 { label: __( 'V3 — Photo overlay', 'mlsimport' ), value: 'v3' },
293 ],
294 },
295 ],
296 },
297 {
298 name: 'agent',
299 title: __( 'Agent', 'mlsimport' ),
300 fields: [
301 {
302 key: 'agent_listings_per_page',
303 label: __( 'No. of listings per page', 'mlsimport' ),
304 type: 'number',
305 default: '12',
306 help: __( "Listings shown per page on a single agent's profile, with pagination. Default 12.", 'mlsimport' ),
307 },
308 {
309 key: 'agent_sections',
310 label: __( 'Arrange Sections', 'mlsimport' ),
311 help: __( 'Drag sections between Enabled and Disabled to choose which appear on the agent profile, and reorder within a list.', 'mlsimport' ),
312 type: 'sections',
313 catalog: 'agent',
314 },
315 ],
316 },
317 {
318 name: 'colors',
319 title: __( 'Colors', 'mlsimport' ),
320 fields: [
321 {
322 key: 'brand_color',
323 label: __( 'Main Color', 'mlsimport' ),
324 help: __( 'Main accent / brand colour for the front end.', 'mlsimport' ),
325 type: 'color',
326 },
327 ],
328 },
329 ];
330
331 /**
332 * The native WordPress color picker (wp-color-picker / Iris) — the same widget
333 * the theme options use: swatch + Select Color + hex input + Clear, with the
334 * saturation square, hue bar and preset palette.
335 *
336 * Iris is a jQuery widget that rewrites the DOM around its input, which fights
337 * React's reconciliation. To avoid that, JSX renders only an empty ref'd <div>
338 * (no children React tracks); we create the input by hand inside it, init Iris,
339 * and wipe the div on unmount. Initialised once — continuous onChange updates
340 * never re-init the widget.
341 */
342 function IrisColorField( { value, onChange } ) {
343 // Ref to the empty div React owns; Iris' input is created inside it by hand.
344 const holder = useRef();
345 // Latest value kept in a ref so the init effect can read it without re-running.
346 const valueRef = useRef( value );
347 valueRef.current = value;
348
349 useEffect( () => {
350 // Bail unless jQuery and the wpColorPicker widget are available.
351 const $ = window.jQuery;
352 if ( ! $ || ! holder.current || ! $.fn.wpColorPicker ) {
353 return undefined;
354 }
355 // Build the text input Iris upgrades, seeded with the current value.
356 const input = document.createElement( 'input' );
357 input.type = 'text';
358 input.value = valueRef.current || '';
359 holder.current.appendChild( input );
360
361 // Initialise Iris; forward its change/clear events to onChange.
362 const $input = $( input );
363 $input.wpColorPicker( {
364 defaultColor: valueRef.current || '',
365 change: ( event, ui ) => onChange( ui.color.toString() ),
366 clear: () => onChange( '' ),
367 } );
368
369 // Cleanup: close the widget and wipe the DOM Iris built on unmount.
370 const node = holder.current;
371 return () => {
372 try {
373 $input.wpColorPicker( 'close' );
374 } catch ( e ) {} // eslint-disable-line no-empty
375 if ( node ) {
376 node.innerHTML = '';
377 }
378 };
379 }, [] ); // eslint-disable-line react-hooks/exhaustive-deps -- init once.
380
381 // React renders only this empty div; Iris populates it imperatively.
382 return <div ref={ holder } />;
383 }
384
385 /**
386 * "Arrange Sections" — two drag-and-drop lists (Enabled / Disabled). Items can be
387 * reordered within a list and dragged across lists. Value is { active, inactive }
388 * slug arrays; the catalog (slug + label) comes from window.mlsimportSections,
389 * localized by PHP from the property section registry.
390 *
391 * Uses native HTML5 drag-and-drop (no extra deps). The dragged item's origin is
392 * held in a ref; on drop we splice it out and insert at the target position.
393 */
394 // The first four single-property sections are mandatory and fixed at the top:
395 // the breadcrumbs, the gallery, the title bar, and the in-page navigation. They
396 // render with a distinct background and cannot be dragged or used as a drop
397 // target, so users can neither move them nor insert other sections above them.
398 const LOCKED_SECTIONS = [ 'breadcrumbs', 'property_gallery', 'title_bar', 'subnav' ];
399
400 /**
401 * The Enabled/Disabled catalog (slug + label list) for a `sections` field,
402 * chosen by the field's `catalog` id. Each list is localized by PHP on the
403 * settings page. Defaults to the property section catalog when unset.
404 */
405 function sectionsCatalog( id ) {
406 if ( 'agent' === id ) {
407 return window.mlsimportAgentSections || [];
408 }
409 if ( 'archive_filters' === id ) {
410 return window.mlsimportArchiveFilters || [];
411 }
412 if ( 'overview' === id ) {
413 return window.mlsimportOverviewFields || [];
414 }
415 return window.mlsimportSections || [];
416 }
417
418 /**
419 * MLS logo picker — the native WordPress media modal (wp.media). Stores an
420 * attachment ID (0 when none). The initial preview URL for an already-saved
421 * logo is localized by PHP as window.mlsimportLogoUrl; once the user picks a
422 * new image we read the fresh URL straight off the selected attachment.
423 */
424 function MediaField( { value, onChange } ) {
425 // Cache the wp.media frame so re-opening reuses the same modal instance.
426 const frameRef = useRef( null );
427 // Preview URL: use the PHP-localized URL for an already-saved logo, else none.
428 const [ previewUrl, setPreviewUrl ] = useState(
429 value ? window.mlsimportLogoUrl || '' : ''
430 );
431
432 // Open (or lazily create) the WordPress media modal.
433 const openFrame = () => {
434 // wp.media must be present to open the picker.
435 if ( ! window.wp || ! window.wp.media ) {
436 return;
437 }
438 // Reuse an already-created frame.
439 if ( frameRef.current ) {
440 frameRef.current.open();
441 return;
442 }
443 // First open: build an image-only, single-select media frame.
444 const frame = window.wp.media( {
445 title: __( 'Select the MLS logo', 'mlsimport' ),
446 button: { text: __( 'Use this logo', 'mlsimport' ) },
447 library: { type: 'image' },
448 multiple: false,
449 } );
450 // On selection, store the attachment ID and derive a fresh preview URL.
451 frame.on( 'select', () => {
452 const att = frame.state().get( 'selection' ).first().toJSON();
453 onChange( att.id );
454 setPreviewUrl( att.sizes && att.sizes.medium ? att.sizes.medium.url : att.url );
455 } );
456 frameRef.current = frame;
457 frame.open();
458 };
459
460 // Clear the selection (ID 0) and its preview.
461 const remove = () => {
462 onChange( 0 );
463 setPreviewUrl( '' );
464 };
465
466 return (
467 <div>
468 { previewUrl && (
469 <div style={ { marginBottom: '10px' } }>
470 <img
471 src={ previewUrl }
472 alt=""
473 style={ {
474 maxHeight: '48px',
475 width: 'auto',
476 background: '#fff',
477 padding: '6px',
478 border: '1px solid #dcdcde',
479 borderRadius: '6px',
480 } }
481 />
482 </div>
483 ) }
484 <div style={ { display: 'flex', gap: '8px' } }>
485 <Button variant="secondary" onClick={ openFrame }>
486 { __( 'Select logo', 'mlsimport' ) }
487 </Button>
488 { !! value && (
489 <Button variant="tertiary" isDestructive onClick={ remove }>
490 { __( 'Remove', 'mlsimport' ) }
491 </Button>
492 ) }
493 </div>
494 </div>
495 );
496 }
497
498 /**
499 * The Enabled/Disabled drag-and-drop control described in the block above.
500 *
501 * @param {Object} props.value Current { active, inactive } slug arrays.
502 * @param {Function} props.onChange Called with the next { active, inactive }.
503 * @param {Array} props.catalog Catalog rows ({ slug, label }) for this field.
504 * @param {Array} props.locked Slugs pinned Enabled-and-first, undraggable.
505 * @return {Element} Two rendered drag-and-drop columns.
506 */
507 function SectionsArrange( { value, onChange, catalog = [], locked = [] } ) {
508 // Holds the { list, index } origin of the item currently being dragged.
509 const drag = useRef( null );
510
511 // Resolve a slug's human label from the catalog (falls back to the slug).
512 const labelFor = ( slug ) => {
513 const found = catalog.find( ( c ) => c.slug === slug );
514 return found ? found.label : slug;
515 };
516
517 let active = value && Array.isArray( value.active ) ? value.active : [];
518 let inactive = value && Array.isArray( value.inactive ) ? value.inactive : [];
519 // No saved arrangement yet (nothing in either list) → seed every catalog
520 // section as Enabled, in catalog order, so the control isn't empty on first use.
521 if ( ! active.length && ! inactive.length ) {
522 active = catalog.map( ( c ) => c.slug );
523 } else {
524 // A section the catalog gained AFTER the user last saved is in neither list.
525 // Show it as Enabled — the same rule the PHP sanitizer applies on save — so
526 // a new section isn't missing from both columns until they re-save.
527 active = active.concat(
528 catalog
529 .map( ( c ) => c.slug )
530 .filter( ( s ) => ! active.includes( s ) && ! inactive.includes( s ) )
531 );
532 }
533 // Locked sections are always Enabled and always first, in their fixed order —
534 // exactly where the front end renders them, no matter what a stale saved
535 // layout says. The next save persists this healed order.
536 if ( locked.length ) {
537 active = locked.concat( active.filter( ( s ) => ! locked.includes( s ) ) );
538 inactive = inactive.filter( ( s ) => ! locked.includes( s ) );
539 }
540
541 // Move the dragged item into toList at toIndex, then emit the new value.
542 const apply = ( toList, toIndex ) => {
543 // Read and clear the drag origin.
544 const from = drag.current;
545 drag.current = null;
546 if ( ! from ) {
547 return;
548 }
549 // Work on copies so we don't mutate the current value.
550 const next = { active: [ ...active ], inactive: [ ...inactive ] };
551 // Pull the item out of its source list.
552 const [ item ] = next[ from.list ].splice( from.index, 1 );
553 let idx = toIndex;
554 // Same-list move past the removed slot shifts the target index down by one.
555 if ( from.list === toList && from.index < toIndex ) {
556 idx -= 1;
557 }
558 // Clamp out-of-range targets to the end of the destination list.
559 if ( idx < 0 || idx > next[ toList ].length ) {
560 idx = next[ toList ].length;
561 }
562 // Insert at the resolved position and notify the parent.
563 next[ toList ].splice( idx, 0, item );
564 onChange( next );
565 };
566
567 const columnStyle = {
568 flex: 1,
569 minWidth: 0,
570 border: '1px solid #dcdcde',
571 borderRadius: '6px',
572 padding: '12px',
573 minHeight: '220px',
574 background: '#fbfbfc',
575 };
576 const itemStyle = {
577 padding: '10px 12px',
578 marginBottom: '8px',
579 border: '1px solid #dcdcde',
580 borderRadius: '6px',
581 background: 'linear-gradient(#ffffff, #f3f4f5)',
582 textAlign: 'center',
583 fontWeight: 600,
584 color: '#1d2327',
585 cursor: 'grab',
586 };
587 const lockedItemStyle = {
588 ...itemStyle,
589 background: '#e7edf5',
590 borderColor: '#c5d2e3',
591 color: '#50575e',
592 cursor: 'not-allowed',
593 };
594
595 const renderColumn = ( list, items, title ) => (
596 <div
597 className={ `mlsimport-arrange__col mlsimport-arrange__col--${ list }` }
598 style={ columnStyle }
599 onDragOver={ ( e ) => e.preventDefault() }
600 onDrop={ ( e ) => {
601 e.preventDefault();
602 apply( list, items.length );
603 } }
604 >
605 <div
606 style={ {
607 fontWeight: 600,
608 borderBottom: '1px solid #dcdcde',
609 paddingBottom: '8px',
610 marginBottom: '12px',
611 } }
612 >
613 { title }
614 </div>
615 { items.length === 0 && (
616 <div style={ { color: '#a7aaad', textAlign: 'center', padding: '16px 0' } }>
617 { __( 'Drop sections here', 'mlsimport' ) }
618 </div>
619 ) }
620 { items.map( ( slug, i ) => {
621 const isLocked = locked.includes( slug );
622 return (
623 <div
624 key={ slug }
625 className="mlsimport-arrange__item"
626 draggable={ ! isLocked }
627 style={ isLocked ? lockedItemStyle : itemStyle }
628 onDragStart={
629 isLocked
630 ? undefined
631 : () => {
632 drag.current = { list, index: i };
633 }
634 }
635 onDragOver={ ( e ) => e.preventDefault() }
636 onDrop={ ( e ) => {
637 e.preventDefault();
638 e.stopPropagation();
639 if ( ! isLocked ) {
640 apply( list, i );
641 }
642 } }
643 >
644 { labelFor( slug ) }
645 </div>
646 );
647 } ) }
648 </div>
649 );
650
651 return (
652 <div style={ { display: 'flex', gap: '16px', alignItems: 'flex-start' } }>
653 { renderColumn( 'active', active, __( 'Enabled', 'mlsimport' ) ) }
654 { renderColumn( 'inactive', inactive, __( 'Disabled', 'mlsimport' ) ) }
655 </div>
656 );
657 }
658
659 /**
660 * A per-filter on/off toggle list backed by the same { active, inactive } value
661 * shape as SectionsArrange (so it shares the PHP catalog + sanitizer). Every
662 * catalog filter renders one ToggleControl, in catalog order; a filter is on
663 * unless it's explicitly in `inactive` — matching the PHP sanitizer, which
664 * enables any catalog key not present in either list. When there's no saved value
665 * yet, `defaultActive` seeds which filters start on.
666 */
667 function FilterToggles( { value, onChange, catalog = [], defaultActive = [] } ) {
668 // All catalog slugs, in catalog order.
669 const slugs = catalog.map( ( c ) => c.slug );
670 // Whether a saved value exists (either list is a non-empty array).
671 const hasValue =
672 value &&
673 ( ( Array.isArray( value.active ) && value.active.length ) ||
674 ( Array.isArray( value.inactive ) && value.inactive.length ) );
675 // The off-set: saved inactive list, or everything not in defaultActive on first use.
676 const inactive =
677 hasValue && Array.isArray( value.inactive )
678 ? value.inactive
679 : slugs.filter( ( s ) => ! defaultActive.includes( s ) );
680
681 // Toggle one filter, then recompute both lists and emit them.
682 const setOn = ( slug, on ) => {
683 // Rebuild inactive: flip this slug, keep others as they were.
684 const nextInactive = slugs.filter( ( s ) =>
685 s === slug ? ! on : inactive.includes( s )
686 );
687 // Active is every slug not in the new inactive list.
688 const active = slugs.filter( ( s ) => ! nextInactive.includes( s ) );
689 onChange( { active, inactive: nextInactive } );
690 };
691
692 return (
693 <div>
694 { catalog.map( ( c ) => (
695 <div key={ c.slug } style={ { marginBottom: '4px' } }>
696 <ToggleControl
697 label={ c.label }
698 checked={ ! inactive.includes( c.slug ) }
699 onChange={ ( on ) => setOn( c.slug, on ) }
700 __nextHasNoMarginBottom
701 />
702 </div>
703 ) ) }
704 </div>
705 );
706 }
707
708 /**
709 * Generic field renderer — maps a field definition's `type` to the right control.
710 *
711 * @param {Object} props.field Field def ({ type, label, help, options, … }).
712 * @param {*} props.value Current value for this field.
713 * @param {Function} props.onChange Called with the field's next value.
714 * @return {Element} The control for this field type (text/number/email by default).
715 */
716 function Field( { field, value, onChange } ) {
717 // Pick the control by declared field type.
718 switch ( field.type ) {
719 case 'select':
720 return (
721 <SelectControl
722 label={ field.label }
723 help={ field.help }
724 value={ value || '' }
725 options={ field.options }
726 onChange={ onChange }
727 __nextHasNoMarginBottom
728 />
729 );
730 case 'yesno':
731 return (
732 <SelectControl
733 label={ field.label }
734 help={ field.help }
735 value={ value || 'no' }
736 options={ YES_NO }
737 onChange={ onChange }
738 __nextHasNoMarginBottom
739 />
740 );
741 case 'toggles':
742 return (
743 <>
744 <p style={ { margin: '0 0 2px', fontWeight: 600 } }>{ field.label }</p>
745 { field.help && (
746 <p style={ { margin: '0 0 12px', color: '#787c82', fontSize: '12px' } }>
747 { field.help }
748 </p>
749 ) }
750 <FilterToggles
751 value={ value }
752 onChange={ onChange }
753 catalog={ sectionsCatalog( field.catalog ) }
754 defaultActive={ field.defaultActive || [] }
755 />
756 </>
757 );
758 case 'buttons':
759 return (
760 <>
761 <p style={ { margin: '0 0 2px', fontWeight: 600 } }>{ field.label }</p>
762 { field.help && (
763 <p style={ { margin: '0 0 8px', color: '#787c82', fontSize: '12px' } }>
764 { field.help }
765 </p>
766 ) }
767 <div style={ { display: 'flex', flexWrap: 'wrap', gap: '8px' } }>
768 { field.options.map( ( opt ) => (
769 <Button
770 key={ opt.value }
771 variant={ value === opt.value ? 'primary' : 'secondary' }
772 onClick={ () => onChange( opt.value ) }
773 >
774 { opt.label }
775 </Button>
776 ) ) }
777 </div>
778 </>
779 );
780 case 'sections':
781 return (
782 <>
783 <p style={ { margin: '0 0 2px', fontWeight: 600 } }>{ field.label }</p>
784 { field.help && (
785 <p style={ { margin: '0 0 12px', color: '#787c82', fontSize: '12px' } }>
786 { field.help }
787 </p>
788 ) }
789 <SectionsArrange
790 value={ value }
791 onChange={ onChange }
792 catalog={ sectionsCatalog( field.catalog ) }
793 locked={ field.catalog === 'property' || ! field.catalog ? LOCKED_SECTIONS : [] }
794 />
795 </>
796 );
797 case 'textarea':
798 return (
799 <TextareaControl
800 label={ field.label }
801 help={ field.help }
802 value={ value || '' }
803 onChange={ onChange }
804 rows={ field.rows || 5 }
805 __nextHasNoMarginBottom
806 />
807 );
808 case 'media':
809 return (
810 <>
811 <p style={ { margin: '0 0 2px', fontWeight: 600 } }>{ field.label }</p>
812 { field.help && (
813 <p style={ { margin: '0 0 8px', color: '#787c82', fontSize: '12px' } }>
814 { field.help }
815 </p>
816 ) }
817 <MediaField value={ value } onChange={ onChange } />
818 </>
819 );
820 case 'color':
821 return (
822 <>
823 <p style={ { margin: '0 0 2px', fontWeight: 600 } }>{ field.label }</p>
824 { field.help && (
825 <p style={ { margin: '0 0 8px', color: '#787c82', fontSize: '12px' } }>
826 { field.help }
827 </p>
828 ) }
829 <IrisColorField value={ value } onChange={ onChange } />
830 </>
831 );
832 default: // text | number | email.
833 return (
834 <TextControl
835 label={ field.label }
836 help={ field.help }
837 type={ field.type === 'number' ? 'number' : field.type === 'email' ? 'email' : 'text' }
838 value={ value !== undefined && value !== '' ? value : field.default || '' }
839 onChange={ onChange }
840 __nextHasNoMarginBottom
841 />
842 );
843 }
844 }
845
846 /**
847 * The live field tree — generated by PHP from the one registry and localized as
848 * window.mlsimportFields. Falls back to the inline copy only if that is absent
849 * (e.g. the script somehow loaded without its inline data).
850 */
851 const TABS =
852 typeof window !== 'undefined' && Array.isArray( window.mlsimportFields ) && window.mlsimportFields.length
853 ? window.mlsimportFields
854 : TABS_FALLBACK;
855
856 /**
857 * Every selectable screen, in menu order: a top-level tab without subtabs is one
858 * screen; a tab with subtabs contributes one screen per subtab (each keeping a
859 * reference to its parent, so the panel can title itself "Parent — Child").
860 */
861 const SCREENS = TABS.flatMap( ( t ) =>
862 t.subtabs
863 ? t.subtabs.map( ( s ) => ( { ...s, parent: t } ) )
864 : [ t ]
865 );
866
867 /**
868 * Root component: left menu of screens + the selected screen's fields, with a
869 * Save button. Loads/saves the whole option via the core /wp/v2/settings REST
870 * endpoint and shows a success/error Notice.
871 *
872 * @return {Element} The settings app UI (or a loading spinner until settings load).
873 */
874 function SettingsApp() {
875 // The full option object (null until the initial REST load resolves).
876 const [ settings, setSettings ] = useState( null );
877 // In-flight flag for the Save request.
878 const [ saving, setSaving ] = useState( false );
879 // Success/error banner state.
880 const [ notice, setNotice ] = useState( null );
881 // Currently selected screen (defaults to the first).
882 const [ screen, setScreen ] = useState( SCREENS[ 0 ].name );
883
884 // On mount: fetch all settings and pull out our option (or a friendly error).
885 useEffect( () => {
886 apiFetch( { path: '/wp/v2/settings' } )
887 .then( ( all ) => setSettings( all[ OPTION_KEY ] || {} ) )
888 .catch( ( err ) =>
889 setNotice( { status: 'error', text: err.message || __( 'Could not load settings.', 'mlsimport' ) } )
890 );
891 }, [] );
892
893 // Immutably set one field's value in the settings object.
894 const update = ( key, value ) => setSettings( ( prev ) => ( { ...prev, [ key ]: value } ) );
895
896 // Render a screen's fields, or a placeholder when the screen has none.
897 const renderFields = ( fields ) =>
898 fields.length === 0 ? (
899 <p style={ { color: '#787c82' } }>{ __( 'No settings here yet.', 'mlsimport' ) }</p>
900 ) : (
901 fields.map( ( field ) => (
902 <div key={ field.key } data-field={ field.key } style={ { marginBottom: '22px' } }>
903 <Field
904 field={ field }
905 value={ settings[ field.key ] }
906 onChange={ ( v ) => update( field.key, v ) }
907 />
908 </div>
909 ) )
910 );
911
912 // POST the whole option back, then reflect the saved value and show a notice.
913 const save = () => {
914 setSaving( true );
915 setNotice( null );
916 apiFetch( { path: '/wp/v2/settings', method: 'POST', data: { [ OPTION_KEY ]: settings } } )
917 .then( ( all ) => {
918 // Adopt the server-sanitized value returned by the REST endpoint.
919 setSettings( all[ OPTION_KEY ] || {} );
920 setNotice( { status: 'success', text: __( 'Settings saved.', 'mlsimport' ) } );
921 } )
922 .catch( ( err ) =>
923 setNotice( { status: 'error', text: err.message || __( 'Save failed.', 'mlsimport' ) } )
924 )
925 .finally( () => setSaving( false ) );
926 };
927
928 // Show a spinner until the initial settings load completes.
929 if ( ! settings ) {
930 return (
931 <div style={ { display: 'flex', alignItems: 'center', gap: '10px', padding: '24px' } }>
932 <Spinner />
933 <span>{ __( 'Loading your settings — please wait…', 'mlsimport' ) }</span>
934 </div>
935 );
936 }
937
938 // The screen object for the active tab (falls back to the first screen).
939 const current = SCREENS.find( ( s ) => s.name === screen ) || SCREENS[ 0 ];
940
941 // Render one left-menu button (child items get an is-child modifier).
942 const menuItem = ( item, isChild ) => (
943 <button
944 key={ item.name }
945 type="button"
946 className={ [
947 'mlsimport-settings-tabs__item',
948 isChild ? 'is-child' : '',
949 current.name === item.name ? 'is-active' : '',
950 ]
951 .filter( Boolean )
952 .join( ' ' ) }
953 aria-current={ current.name === item.name }
954 onClick={ () => setScreen( item.name ) }
955 >
956 { item.title }
957 </button>
958 );
959
960 return (
961 <div style={ { maxWidth: '900px', marginTop: '20px' } }>
962 { notice && (
963 <Notice status={ notice.status } onRemove={ () => setNotice( null ) } isDismissible>
964 { notice.text }
965 </Notice>
966 ) }
967
968 <Card>
969 <CardBody>
970 <div className="mlsimport-settings-tabs">
971 <nav className="mlsimport-settings-tabs__menu">
972 { TABS.map( ( t ) =>
973 t.subtabs ? (
974 <div key={ t.name } className="mlsimport-settings-tabs__group">
975 <button
976 type="button"
977 className="mlsimport-settings-tabs__item is-parent"
978 onClick={ () => setScreen( t.subtabs[ 0 ].name ) }
979 >
980 { t.title }
981 </button>
982 { t.subtabs.map( ( s ) => menuItem( s, true ) ) }
983 </div>
984 ) : (
985 menuItem( t, false )
986 )
987 ) }
988 </nav>
989
990 <div className="mlsimport-settings-tabs__panel">
991 <Heading level={ 3 } style={ { marginTop: 0 } }>
992 { current.parent
993 ? `${ current.parent.title }${ current.title }`
994 : current.title }
995 </Heading>
996 { renderFields( current.fields ) }
997 </div>
998 </div>
999 </CardBody>
1000 </Card>
1001
1002 <div style={ { marginTop: '20px' } }>
1003 <Button className="mlsimport-save-button" variant="primary" onClick={ save } isBusy={ saving } disabled={ saving }>
1004 { saving ? __( 'Saving…', 'mlsimport' ) : __( 'Save changes', 'mlsimport' ) }
1005 </Button>
1006 </div>
1007 </div>
1008 );
1009 }
1010
1011 // Mount point printed by the settings page; render the app only when present.
1012 const mount = document.getElementById( 'mlsimport-standalone-app' );
1013 if ( mount ) {
1014 createRoot( mount ).render( <SettingsApp /> );
1015 }
1016