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

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

1,014 lines 32.9 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 = [], defaultInactive = [] } ) {
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 // A catalog section the saved value never mentions lands where the field's
520 // default puts it — Disabled for the slugs in defaultInactive (the Tabs /
521 // Accordion containers), Enabled otherwise — which is exactly where the PHP
522 // sanitizer will put it on save. This covers both the first use (nothing saved
523 // yet: every section is unmentioned) and a section the catalog gained AFTER the
524 // user last saved, so it is never missing from both columns.
525 const unmentioned = catalog
526 .map( ( c ) => c.slug )
527 .filter( ( s ) => ! active.includes( s ) && ! inactive.includes( s ) );
528 active = active.concat( unmentioned.filter( ( s ) => ! defaultInactive.includes( s ) ) );
529 inactive = inactive.concat( unmentioned.filter( ( s ) => defaultInactive.includes( s ) ) );
530 // Locked sections are always Enabled and always first, in their fixed order —
531 // exactly where the front end renders them, no matter what a stale saved
532 // layout says. The next save persists this healed order.
533 if ( locked.length ) {
534 active = locked.concat( active.filter( ( s ) => ! locked.includes( s ) ) );
535 inactive = inactive.filter( ( s ) => ! locked.includes( s ) );
536 }
537
538 // Move the dragged item into toList at toIndex, then emit the new value.
539 const apply = ( toList, toIndex ) => {
540 // Read and clear the drag origin.
541 const from = drag.current;
542 drag.current = null;
543 if ( ! from ) {
544 return;
545 }
546 // Work on copies so we don't mutate the current value.
547 const next = { active: [ ...active ], inactive: [ ...inactive ] };
548 // Pull the item out of its source list.
549 const [ item ] = next[ from.list ].splice( from.index, 1 );
550 let idx = toIndex;
551 // Same-list move past the removed slot shifts the target index down by one.
552 if ( from.list === toList && from.index < toIndex ) {
553 idx -= 1;
554 }
555 // Clamp out-of-range targets to the end of the destination list.
556 if ( idx < 0 || idx > next[ toList ].length ) {
557 idx = next[ toList ].length;
558 }
559 // Insert at the resolved position and notify the parent.
560 next[ toList ].splice( idx, 0, item );
561 onChange( next );
562 };
563
564 const columnStyle = {
565 flex: 1,
566 minWidth: 0,
567 border: '1px solid #dcdcde',
568 borderRadius: '6px',
569 padding: '12px',
570 minHeight: '220px',
571 background: '#fbfbfc',
572 };
573 const itemStyle = {
574 padding: '10px 12px',
575 marginBottom: '8px',
576 border: '1px solid #dcdcde',
577 borderRadius: '6px',
578 background: 'linear-gradient(#ffffff, #f3f4f5)',
579 textAlign: 'center',
580 fontWeight: 600,
581 color: '#1d2327',
582 cursor: 'grab',
583 };
584 const lockedItemStyle = {
585 ...itemStyle,
586 background: '#e7edf5',
587 borderColor: '#c5d2e3',
588 color: '#50575e',
589 cursor: 'not-allowed',
590 };
591
592 const renderColumn = ( list, items, title ) => (
593 <div
594 className={ `mlsimport-arrange__col mlsimport-arrange__col--${ list }` }
595 style={ columnStyle }
596 onDragOver={ ( e ) => e.preventDefault() }
597 onDrop={ ( e ) => {
598 e.preventDefault();
599 apply( list, items.length );
600 } }
601 >
602 <div
603 style={ {
604 fontWeight: 600,
605 borderBottom: '1px solid #dcdcde',
606 paddingBottom: '8px',
607 marginBottom: '12px',
608 } }
609 >
610 { title }
611 </div>
612 { items.length === 0 && (
613 <div style={ { color: '#a7aaad', textAlign: 'center', padding: '16px 0' } }>
614 { __( 'Drop sections here', 'mlsimport' ) }
615 </div>
616 ) }
617 { items.map( ( slug, i ) => {
618 const isLocked = locked.includes( slug );
619 return (
620 <div
621 key={ slug }
622 className="mlsimport-arrange__item"
623 draggable={ ! isLocked }
624 style={ isLocked ? lockedItemStyle : itemStyle }
625 onDragStart={
626 isLocked
627 ? undefined
628 : () => {
629 drag.current = { list, index: i };
630 }
631 }
632 onDragOver={ ( e ) => e.preventDefault() }
633 onDrop={ ( e ) => {
634 e.preventDefault();
635 e.stopPropagation();
636 if ( ! isLocked ) {
637 apply( list, i );
638 }
639 } }
640 >
641 { labelFor( slug ) }
642 </div>
643 );
644 } ) }
645 </div>
646 );
647
648 return (
649 <div style={ { display: 'flex', gap: '16px', alignItems: 'flex-start' } }>
650 { renderColumn( 'active', active, __( 'Enabled', 'mlsimport' ) ) }
651 { renderColumn( 'inactive', inactive, __( 'Disabled', 'mlsimport' ) ) }
652 </div>
653 );
654 }
655
656 /**
657 * A per-filter on/off toggle list backed by the same { active, inactive } value
658 * shape as SectionsArrange (so it shares the PHP catalog + sanitizer). Every
659 * catalog filter renders one ToggleControl, in catalog order; a filter is on
660 * unless it's explicitly in `inactive` — matching the PHP sanitizer, which
661 * enables any catalog key not present in either list. When there's no saved value
662 * yet, `defaultActive` seeds which filters start on.
663 */
664 function FilterToggles( { value, onChange, catalog = [], defaultActive = [] } ) {
665 // All catalog slugs, in catalog order.
666 const slugs = catalog.map( ( c ) => c.slug );
667 // Whether a saved value exists (either list is a non-empty array).
668 const hasValue =
669 value &&
670 ( ( Array.isArray( value.active ) && value.active.length ) ||
671 ( Array.isArray( value.inactive ) && value.inactive.length ) );
672 // The off-set: saved inactive list, or everything not in defaultActive on first use.
673 const inactive =
674 hasValue && Array.isArray( value.inactive )
675 ? value.inactive
676 : slugs.filter( ( s ) => ! defaultActive.includes( s ) );
677
678 // Toggle one filter, then recompute both lists and emit them.
679 const setOn = ( slug, on ) => {
680 // Rebuild inactive: flip this slug, keep others as they were.
681 const nextInactive = slugs.filter( ( s ) =>
682 s === slug ? ! on : inactive.includes( s )
683 );
684 // Active is every slug not in the new inactive list.
685 const active = slugs.filter( ( s ) => ! nextInactive.includes( s ) );
686 onChange( { active, inactive: nextInactive } );
687 };
688
689 return (
690 <div>
691 { catalog.map( ( c ) => (
692 <div key={ c.slug } style={ { marginBottom: '4px' } }>
693 <ToggleControl
694 label={ c.label }
695 checked={ ! inactive.includes( c.slug ) }
696 onChange={ ( on ) => setOn( c.slug, on ) }
697 __nextHasNoMarginBottom
698 />
699 </div>
700 ) ) }
701 </div>
702 );
703 }
704
705 /**
706 * Generic field renderer — maps a field definition's `type` to the right control.
707 *
708 * @param {Object} props.field Field def ({ type, label, help, options, … }).
709 * @param {*} props.value Current value for this field.
710 * @param {Function} props.onChange Called with the field's next value.
711 * @return {Element} The control for this field type (text/number/email by default).
712 */
713 function Field( { field, value, onChange } ) {
714 // Pick the control by declared field type.
715 switch ( field.type ) {
716 case 'select':
717 return (
718 <SelectControl
719 label={ field.label }
720 help={ field.help }
721 value={ value || '' }
722 options={ field.options }
723 onChange={ onChange }
724 __nextHasNoMarginBottom
725 />
726 );
727 case 'yesno':
728 return (
729 <SelectControl
730 label={ field.label }
731 help={ field.help }
732 value={ value || 'no' }
733 options={ YES_NO }
734 onChange={ onChange }
735 __nextHasNoMarginBottom
736 />
737 );
738 case 'toggles':
739 return (
740 <>
741 <p style={ { margin: '0 0 2px', fontWeight: 600 } }>{ field.label }</p>
742 { field.help && (
743 <p style={ { margin: '0 0 12px', color: '#787c82', fontSize: '12px' } }>
744 { field.help }
745 </p>
746 ) }
747 <FilterToggles
748 value={ value }
749 onChange={ onChange }
750 catalog={ sectionsCatalog( field.catalog ) }
751 defaultActive={ field.defaultActive || [] }
752 />
753 </>
754 );
755 case 'buttons':
756 return (
757 <>
758 <p style={ { margin: '0 0 2px', fontWeight: 600 } }>{ field.label }</p>
759 { field.help && (
760 <p style={ { margin: '0 0 8px', color: '#787c82', fontSize: '12px' } }>
761 { field.help }
762 </p>
763 ) }
764 <div style={ { display: 'flex', flexWrap: 'wrap', gap: '8px' } }>
765 { field.options.map( ( opt ) => (
766 <Button
767 key={ opt.value }
768 variant={ value === opt.value ? 'primary' : 'secondary' }
769 onClick={ () => onChange( opt.value ) }
770 >
771 { opt.label }
772 </Button>
773 ) ) }
774 </div>
775 </>
776 );
777 case 'sections':
778 return (
779 <>
780 <p style={ { margin: '0 0 2px', fontWeight: 600 } }>{ field.label }</p>
781 { field.help && (
782 <p style={ { margin: '0 0 12px', color: '#787c82', fontSize: '12px' } }>
783 { field.help }
784 </p>
785 ) }
786 <SectionsArrange
787 value={ value }
788 onChange={ onChange }
789 catalog={ sectionsCatalog( field.catalog ) }
790 locked={ field.catalog === 'property' || ! field.catalog ? LOCKED_SECTIONS : [] }
791 defaultInactive={ field.defaultInactive || [] }
792 />
793 </>
794 );
795 case 'textarea':
796 return (
797 <TextareaControl
798 label={ field.label }
799 help={ field.help }
800 value={ value || '' }
801 onChange={ onChange }
802 rows={ field.rows || 5 }
803 __nextHasNoMarginBottom
804 />
805 );
806 case 'media':
807 return (
808 <>
809 <p style={ { margin: '0 0 2px', fontWeight: 600 } }>{ field.label }</p>
810 { field.help && (
811 <p style={ { margin: '0 0 8px', color: '#787c82', fontSize: '12px' } }>
812 { field.help }
813 </p>
814 ) }
815 <MediaField value={ value } onChange={ onChange } />
816 </>
817 );
818 case 'color':
819 return (
820 <>
821 <p style={ { margin: '0 0 2px', fontWeight: 600 } }>{ field.label }</p>
822 { field.help && (
823 <p style={ { margin: '0 0 8px', color: '#787c82', fontSize: '12px' } }>
824 { field.help }
825 </p>
826 ) }
827 <IrisColorField value={ value } onChange={ onChange } />
828 </>
829 );
830 default: // text | number | email.
831 return (
832 <TextControl
833 label={ field.label }
834 help={ field.help }
835 type={ field.type === 'number' ? 'number' : field.type === 'email' ? 'email' : 'text' }
836 value={ value !== undefined && value !== '' ? value : field.default || '' }
837 onChange={ onChange }
838 __nextHasNoMarginBottom
839 />
840 );
841 }
842 }
843
844 /**
845 * The live field tree — generated by PHP from the one registry and localized as
846 * window.mlsimportFields. Falls back to the inline copy only if that is absent
847 * (e.g. the script somehow loaded without its inline data).
848 */
849 const TABS =
850 typeof window !== 'undefined' && Array.isArray( window.mlsimportFields ) && window.mlsimportFields.length
851 ? window.mlsimportFields
852 : TABS_FALLBACK;
853
854 /**
855 * Every selectable screen, in menu order: a top-level tab without subtabs is one
856 * screen; a tab with subtabs contributes one screen per subtab (each keeping a
857 * reference to its parent, so the panel can title itself "Parent — Child").
858 */
859 const SCREENS = TABS.flatMap( ( t ) =>
860 t.subtabs
861 ? t.subtabs.map( ( s ) => ( { ...s, parent: t } ) )
862 : [ t ]
863 );
864
865 /**
866 * Root component: left menu of screens + the selected screen's fields, with a
867 * Save button. Loads/saves the whole option via the core /wp/v2/settings REST
868 * endpoint and shows a success/error Notice.
869 *
870 * @return {Element} The settings app UI (or a loading spinner until settings load).
871 */
872 function SettingsApp() {
873 // The full option object (null until the initial REST load resolves).
874 const [ settings, setSettings ] = useState( null );
875 // In-flight flag for the Save request.
876 const [ saving, setSaving ] = useState( false );
877 // Success/error banner state.
878 const [ notice, setNotice ] = useState( null );
879 // Currently selected screen (defaults to the first).
880 const [ screen, setScreen ] = useState( SCREENS[ 0 ].name );
881
882 // On mount: fetch all settings and pull out our option (or a friendly error).
883 useEffect( () => {
884 apiFetch( { path: '/wp/v2/settings' } )
885 .then( ( all ) => setSettings( all[ OPTION_KEY ] || {} ) )
886 .catch( ( err ) =>
887 setNotice( { status: 'error', text: err.message || __( 'Could not load settings.', 'mlsimport' ) } )
888 );
889 }, [] );
890
891 // Immutably set one field's value in the settings object.
892 const update = ( key, value ) => setSettings( ( prev ) => ( { ...prev, [ key ]: value } ) );
893
894 // Render a screen's fields, or a placeholder when the screen has none.
895 const renderFields = ( fields ) =>
896 fields.length === 0 ? (
897 <p style={ { color: '#787c82' } }>{ __( 'No settings here yet.', 'mlsimport' ) }</p>
898 ) : (
899 fields.map( ( field ) => (
900 <div key={ field.key } data-field={ field.key } style={ { marginBottom: '22px' } }>
901 <Field
902 field={ field }
903 value={ settings[ field.key ] }
904 onChange={ ( v ) => update( field.key, v ) }
905 />
906 </div>
907 ) )
908 );
909
910 // POST the whole option back, then reflect the saved value and show a notice.
911 const save = () => {
912 setSaving( true );
913 setNotice( null );
914 apiFetch( { path: '/wp/v2/settings', method: 'POST', data: { [ OPTION_KEY ]: settings } } )
915 .then( ( all ) => {
916 // Adopt the server-sanitized value returned by the REST endpoint.
917 setSettings( all[ OPTION_KEY ] || {} );
918 setNotice( { status: 'success', text: __( 'Settings saved.', 'mlsimport' ) } );
919 } )
920 .catch( ( err ) =>
921 setNotice( { status: 'error', text: err.message || __( 'Save failed.', 'mlsimport' ) } )
922 )
923 .finally( () => setSaving( false ) );
924 };
925
926 // Show a spinner until the initial settings load completes.
927 if ( ! settings ) {
928 return (
929 <div style={ { display: 'flex', alignItems: 'center', gap: '10px', padding: '24px' } }>
930 <Spinner />
931 <span>{ __( 'Loading your settings — please wait…', 'mlsimport' ) }</span>
932 </div>
933 );
934 }
935
936 // The screen object for the active tab (falls back to the first screen).
937 const current = SCREENS.find( ( s ) => s.name === screen ) || SCREENS[ 0 ];
938
939 // Render one left-menu button (child items get an is-child modifier).
940 const menuItem = ( item, isChild ) => (
941 <button
942 key={ item.name }
943 type="button"
944 className={ [
945 'mlsimport-settings-tabs__item',
946 isChild ? 'is-child' : '',
947 current.name === item.name ? 'is-active' : '',
948 ]
949 .filter( Boolean )
950 .join( ' ' ) }
951 aria-current={ current.name === item.name }
952 onClick={ () => setScreen( item.name ) }
953 >
954 { item.title }
955 </button>
956 );
957
958 return (
959 <div style={ { maxWidth: '900px', marginTop: '20px' } }>
960 { notice && (
961 <Notice status={ notice.status } onRemove={ () => setNotice( null ) } isDismissible>
962 { notice.text }
963 </Notice>
964 ) }
965
966 <Card>
967 <CardBody>
968 <div className="mlsimport-settings-tabs">
969 <nav className="mlsimport-settings-tabs__menu">
970 { TABS.map( ( t ) =>
971 t.subtabs ? (
972 <div key={ t.name } className="mlsimport-settings-tabs__group">
973 <button
974 type="button"
975 className="mlsimport-settings-tabs__item is-parent"
976 onClick={ () => setScreen( t.subtabs[ 0 ].name ) }
977 >
978 { t.title }
979 </button>
980 { t.subtabs.map( ( s ) => menuItem( s, true ) ) }
981 </div>
982 ) : (
983 menuItem( t, false )
984 )
985 ) }
986 </nav>
987
988 <div className="mlsimport-settings-tabs__panel">
989 <Heading level={ 3 } style={ { marginTop: 0 } }>
990 { current.parent
991 ? `${ current.parent.title }${ current.title }`
992 : current.title }
993 </Heading>
994 { renderFields( current.fields ) }
995 </div>
996 </div>
997 </CardBody>
998 </Card>
999
1000 <div style={ { marginTop: '20px' } }>
1001 <Button className="mlsimport-save-button" variant="primary" onClick={ save } isBusy={ saving } disabled={ saving }>
1002 { saving ? __( 'Saving…', 'mlsimport' ) : __( 'Save changes', 'mlsimport' ) }
1003 </Button>
1004 </div>
1005 </div>
1006 );
1007 }
1008
1009 // Mount point printed by the settings page; render the app only when present.
1010 const mount = document.getElementById( 'mlsimport-standalone-app' );
1011 if ( mount ) {
1012 createRoot( mount ).render( <SettingsApp /> );
1013 }
1014