/** * Standalone Design — settings app (theme_id 990). * * Renders on the dedicated "Standalone Design" admin page. Reads/writes the * mlsimport_standalone_options WordPress option through the core settings REST * endpoint (/wp/v2/settings) — no custom save handler. Built with * @wordpress/components so every control is WP-native and accessible. * Compile with: npm run build:settings. * * The layout is a vertical menu on the left and the selected screen's fields on * the right. Each menu item and its fields are declared in the TABS config below; * an item with `subtabs` lists them as indented children in that same left menu. * A generic renderer turns each field into the right control. * Field KEYS must match the PHP registry in * includes/standalone/class-mlsimport-standalone-settings.php — that registry * owns defaults, the REST schema and sanitization. */ import { createRoot, useState, useEffect, useRef } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { Card, CardBody, TextControl, TextareaControl, SelectControl, ToggleControl, Button, Spinner, Notice, __experimentalHeading as Heading, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; // wp_options key the whole settings tree reads from and writes back to. const OPTION_KEY = 'mlsimport_standalone_options'; // Reusable Yes/No option set for `yesno` fields. const YES_NO = [ { label: __( 'Yes', 'mlsimport' ), value: 'yes' }, { label: __( 'No', 'mlsimport' ), value: 'no' }, ]; /** * Tab + field definitions. type: text | number | email | textarea | select | * yesno | color. Empty `fields` renders a placeholder (scaffolded for later). * * This inline copy is now only a FALLBACK. The live tree comes from PHP as * window.mlsimportFields (mlsimport_standalone_settings_app_config), generated from * the one field registry — so adding a field to the registry surfaces it here (and * in the Customizer) with no JS change. See TABS below. */ const TABS_FALLBACK = [ { name: 'general', title: __( 'General', 'mlsimport' ), fields: [ { key: 'properties_per_page', label: __( 'No. of Properties per Page', 'mlsimport' ), type: 'number', }, { key: 'order_by', label: __( 'Order by', 'mlsimport' ), type: 'select', options: [ { label: __( 'Default', 'mlsimport' ), value: 'default' }, { label: __( 'Price High to Low', 'mlsimport' ), value: 'price_high' }, { label: __( 'Price Low to High', 'mlsimport' ), value: 'price_low' }, { label: __( 'Newest first', 'mlsimport' ), value: 'newest' }, { label: __( 'Oldest first', 'mlsimport' ), value: 'oldest' }, { label: __( 'Newest Edited', 'mlsimport' ), value: 'newest_edited' }, { label: __( 'Oldest Edited', 'mlsimport' ), value: 'oldest_edited' }, { label: __( 'Bedrooms High to Low', 'mlsimport' ), value: 'beds_high' }, { label: __( 'Bedrooms Low to High', 'mlsimport' ), value: 'beds_low' }, { label: __( 'Bathrooms High to Low', 'mlsimport' ), value: 'baths_high' }, { label: __( 'Bathrooms Low to High', 'mlsimport' ), value: 'baths_low' }, ], }, ], }, { name: 'taxonomy_filters', title: __( 'Category page filters', 'mlsimport' ), fields: [ { key: 'archive_search_fields', label: __( 'Archive search filters', 'mlsimport' ), 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' ), type: 'toggles', catalog: 'archive_filters', defaultActive: [ 'status', 'city', 'property_type' ], }, ], }, { name: 'social', title: __( 'Social & Contact', 'mlsimport' ), fields: [ { key: 'lead_recipient', label: __( 'Email', 'mlsimport' ), type: 'email', help: __( 'Company email — e.g. office@domain.com. Also the fallback lead recipient when a listing has no agent email.', 'mlsimport' ), }, { key: 'contact_form_recipients', label: __( 'Contact form recipients', 'mlsimport' ), type: 'text', 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' ), }, { key: 'consent_label', label: __( 'Text for the checkbox label', 'mlsimport' ), type: 'textarea', help: __( 'Shown next to the marketing-consent checkbox on contact forms.', 'mlsimport' ), }, { key: 'terms_link_text', label: __( 'Text for terms link', 'mlsimport' ), type: 'text', help: __( 'e.g. Privacy Policy.', 'mlsimport' ), }, { key: 'show_looking_dropdown', label: __( "Show 'What are you looking to do?' dropdown on contact forms?", 'mlsimport' ), type: 'yesno', help: __( 'Displays an optional dropdown field on Agent, Agency, Developer, and Property contact forms.', 'mlsimport' ), }, { key: 'looking_options', label: __( 'Dropdown options (comma-separated)', 'mlsimport' ), type: 'text', }, ], }, { name: 'maps', title: __( 'Maps', 'mlsimport' ), fields: [ { key: 'mapbox_api_key', label: __( 'MapBox API KEY', 'mlsimport' ), type: 'text', 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' ), }, { key: 'map_start_lat', label: __( 'Starting Point Latitude', 'mlsimport' ), type: 'number', help: __( 'Numbers only (ex: 40.577906).', 'mlsimport' ), }, { key: 'map_start_lng', label: __( 'Starting Point Longitude', 'mlsimport' ), type: 'number', help: __( 'Numbers only (ex: -74.155058).', 'mlsimport' ), }, { key: 'map_zoom', label: __( 'Default Maps zoom (1 to 20)', 'mlsimport' ), type: 'number', }, { key: 'map_pin_cluster', label: __( 'Use the Pin Cluster on the maps', 'mlsimport' ), type: 'yesno', help: __( 'If yes, nearby pins are grouped in a cluster.', 'mlsimport' ), }, { key: 'map_cluster_max_zoom', label: __( 'Maximum zoom level for cluster to appear', 'mlsimport' ), type: 'number', help: __( 'Pin cluster disappears when the map zoom is less than this value.', 'mlsimport' ), }, { key: 'map_geolocation_circle', label: __( 'Geolocation Circle over maps (in meters)', 'mlsimport' ), type: 'number', help: __( 'Circle radius for the user geolocation pin. Numbers only (ex: 400).', 'mlsimport' ), }, ], }, { name: 'property_page', title: __( 'Property Page', 'mlsimport' ), subtabs: [ { name: 'pp_general', title: __( 'General', 'mlsimport' ), fields: [ { key: 'media_section_type', label: __( 'Media Section Type (property images & video)', 'mlsimport' ), type: 'buttons', help: __( 'Choose how to display the listing images or video.', 'mlsimport' ), options: [ { label: __( 'Classic Slider', 'mlsimport' ), value: 'classic' }, { label: __( 'Vertical Slider', 'mlsimport' ), value: 'vertical' }, { label: __( 'Slider v4', 'mlsimport' ), value: 'v4' }, { label: __( 'Multi Image Slider', 'mlsimport' ), value: 'multi' }, { label: __( 'Masonry Gallery v1', 'mlsimport' ), value: 'masonry1' }, { label: __( 'Masonry Gallery v2', 'mlsimport' ), value: 'masonry2' }, ], }, ], }, { name: 'pp_layout', title: __( 'Property Page Layout', 'mlsimport' ), fields: [ { key: 'details_columns', label: __( 'Details Columns', 'mlsimport' ), type: 'buttons', help: __( 'How many columns each details section (Interior, Exterior, Financial…) runs. Collapses automatically on narrow screens.', 'mlsimport' ), options: [ { label: __( '3 Columns', 'mlsimport' ), value: '3' }, { label: __( '2 Columns', 'mlsimport' ), value: '2' }, ], }, { key: 'property_sections', label: __( 'Arrange Sections', 'mlsimport' ), help: __( 'Drag sections between Enabled and Disabled to choose which appear, and reorder within a list.', 'mlsimport' ), type: 'sections', }, ], }, { name: 'pp_attribution', title: __( 'MLS Attribution', 'mlsimport' ), fields: [ { key: 'mls_logo_id', label: __( 'MLS logo', 'mlsimport' ), type: 'media', help: __( "Your MLS's required attribution logo. Shown in the MLS Attribution section and on listing cards.", 'mlsimport' ), }, { key: 'attribution_text', label: __( 'Disclaimer', 'mlsimport' ), type: 'textarea', rows: 10, help: __( '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.', 'mlsimport' ), }, ], }, { name: 'pp_tour', title: __( 'Tour Details', 'mlsimport' ), fields: [ { key: 'tour_times', label: __( 'Preferred tour times', 'mlsimport' ), type: 'text', 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' ), }, ], }, { name: 'pp_overview', title: __( 'Overview', 'mlsimport' ), fields: [ { key: 'overview_fields', label: __( 'Arrange Fields', 'mlsimport' ), 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' ), type: 'sections', catalog: 'overview', }, ], }, ], }, { name: 'property_card', title: __( 'Property Card', 'mlsimport' ), fields: [ { key: 'card_style', label: __( 'Property card style', 'mlsimport' ), type: 'select', help: __( 'The card design used in every listing grid (search results, lists, sliders, similar listings).', 'mlsimport' ), options: [ { label: __( 'V1 — Standard', 'mlsimport' ), value: 'v1' }, { label: __( 'V2 — Horizontal', 'mlsimport' ), value: 'v2' }, { label: __( 'V3 — Photo overlay', 'mlsimport' ), value: 'v3' }, ], }, ], }, { name: 'agent', title: __( 'Agent', 'mlsimport' ), fields: [ { key: 'agent_listings_per_page', label: __( 'No. of listings per page', 'mlsimport' ), type: 'number', default: '12', help: __( "Listings shown per page on a single agent's profile, with pagination. Default 12.", 'mlsimport' ), }, { key: 'agent_sections', label: __( 'Arrange Sections', 'mlsimport' ), help: __( 'Drag sections between Enabled and Disabled to choose which appear on the agent profile, and reorder within a list.', 'mlsimport' ), type: 'sections', catalog: 'agent', }, ], }, { name: 'colors', title: __( 'Colors', 'mlsimport' ), fields: [ { key: 'brand_color', label: __( 'Main Color', 'mlsimport' ), help: __( 'Main accent / brand colour for the front end.', 'mlsimport' ), type: 'color', }, ], }, ]; /** * The native WordPress color picker (wp-color-picker / Iris) — the same widget * the theme options use: swatch + Select Color + hex input + Clear, with the * saturation square, hue bar and preset palette. * * Iris is a jQuery widget that rewrites the DOM around its input, which fights * React's reconciliation. To avoid that, JSX renders only an empty ref'd
* (no children React tracks); we create the input by hand inside it, init Iris, * and wipe the div on unmount. Initialised once — continuous onChange updates * never re-init the widget. */ function IrisColorField( { value, onChange } ) { // Ref to the empty div React owns; Iris' input is created inside it by hand. const holder = useRef(); // Latest value kept in a ref so the init effect can read it without re-running. const valueRef = useRef( value ); valueRef.current = value; useEffect( () => { // Bail unless jQuery and the wpColorPicker widget are available. const $ = window.jQuery; if ( ! $ || ! holder.current || ! $.fn.wpColorPicker ) { return undefined; } // Build the text input Iris upgrades, seeded with the current value. const input = document.createElement( 'input' ); input.type = 'text'; input.value = valueRef.current || ''; holder.current.appendChild( input ); // Initialise Iris; forward its change/clear events to onChange. const $input = $( input ); $input.wpColorPicker( { defaultColor: valueRef.current || '', change: ( event, ui ) => onChange( ui.color.toString() ), clear: () => onChange( '' ), } ); // Cleanup: close the widget and wipe the DOM Iris built on unmount. const node = holder.current; return () => { try { $input.wpColorPicker( 'close' ); } catch ( e ) {} // eslint-disable-line no-empty if ( node ) { node.innerHTML = ''; } }; }, [] ); // eslint-disable-line react-hooks/exhaustive-deps -- init once. // React renders only this empty div; Iris populates it imperatively. return
; } /** * "Arrange Sections" — two drag-and-drop lists (Enabled / Disabled). Items can be * reordered within a list and dragged across lists. Value is { active, inactive } * slug arrays; the catalog (slug + label) comes from window.mlsimportSections, * localized by PHP from the property section registry. * * Uses native HTML5 drag-and-drop (no extra deps). The dragged item's origin is * held in a ref; on drop we splice it out and insert at the target position. */ // The first four single-property sections are mandatory and fixed at the top: // the breadcrumbs, the gallery, the title bar, and the in-page navigation. They // render with a distinct background and cannot be dragged or used as a drop // target, so users can neither move them nor insert other sections above them. const LOCKED_SECTIONS = [ 'breadcrumbs', 'property_gallery', 'title_bar', 'subnav' ]; /** * The Enabled/Disabled catalog (slug + label list) for a `sections` field, * chosen by the field's `catalog` id. Each list is localized by PHP on the * settings page. Defaults to the property section catalog when unset. */ function sectionsCatalog( id ) { if ( 'agent' === id ) { return window.mlsimportAgentSections || []; } if ( 'archive_filters' === id ) { return window.mlsimportArchiveFilters || []; } if ( 'overview' === id ) { return window.mlsimportOverviewFields || []; } return window.mlsimportSections || []; } /** * MLS logo picker — the native WordPress media modal (wp.media). Stores an * attachment ID (0 when none). The initial preview URL for an already-saved * logo is localized by PHP as window.mlsimportLogoUrl; once the user picks a * new image we read the fresh URL straight off the selected attachment. */ function MediaField( { value, onChange } ) { // Cache the wp.media frame so re-opening reuses the same modal instance. const frameRef = useRef( null ); // Preview URL: use the PHP-localized URL for an already-saved logo, else none. const [ previewUrl, setPreviewUrl ] = useState( value ? window.mlsimportLogoUrl || '' : '' ); // Open (or lazily create) the WordPress media modal. const openFrame = () => { // wp.media must be present to open the picker. if ( ! window.wp || ! window.wp.media ) { return; } // Reuse an already-created frame. if ( frameRef.current ) { frameRef.current.open(); return; } // First open: build an image-only, single-select media frame. const frame = window.wp.media( { title: __( 'Select the MLS logo', 'mlsimport' ), button: { text: __( 'Use this logo', 'mlsimport' ) }, library: { type: 'image' }, multiple: false, } ); // On selection, store the attachment ID and derive a fresh preview URL. frame.on( 'select', () => { const att = frame.state().get( 'selection' ).first().toJSON(); onChange( att.id ); setPreviewUrl( att.sizes && att.sizes.medium ? att.sizes.medium.url : att.url ); } ); frameRef.current = frame; frame.open(); }; // Clear the selection (ID 0) and its preview. const remove = () => { onChange( 0 ); setPreviewUrl( '' ); }; return (
{ previewUrl && (
) }
{ !! value && ( ) }
); } /** * The Enabled/Disabled drag-and-drop control described in the block above. * * @param {Object} props.value Current { active, inactive } slug arrays. * @param {Function} props.onChange Called with the next { active, inactive }. * @param {Array} props.catalog Catalog rows ({ slug, label }) for this field. * @param {Array} props.locked Slugs pinned Enabled-and-first, undraggable. * @return {Element} Two rendered drag-and-drop columns. */ function SectionsArrange( { value, onChange, catalog = [], locked = [] } ) { // Holds the { list, index } origin of the item currently being dragged. const drag = useRef( null ); // Resolve a slug's human label from the catalog (falls back to the slug). const labelFor = ( slug ) => { const found = catalog.find( ( c ) => c.slug === slug ); return found ? found.label : slug; }; let active = value && Array.isArray( value.active ) ? value.active : []; let inactive = value && Array.isArray( value.inactive ) ? value.inactive : []; // No saved arrangement yet (nothing in either list) → seed every catalog // section as Enabled, in catalog order, so the control isn't empty on first use. if ( ! active.length && ! inactive.length ) { active = catalog.map( ( c ) => c.slug ); } else { // A section the catalog gained AFTER the user last saved is in neither list. // Show it as Enabled — the same rule the PHP sanitizer applies on save — so // a new section isn't missing from both columns until they re-save. active = active.concat( catalog .map( ( c ) => c.slug ) .filter( ( s ) => ! active.includes( s ) && ! inactive.includes( s ) ) ); } // Locked sections are always Enabled and always first, in their fixed order — // exactly where the front end renders them, no matter what a stale saved // layout says. The next save persists this healed order. if ( locked.length ) { active = locked.concat( active.filter( ( s ) => ! locked.includes( s ) ) ); inactive = inactive.filter( ( s ) => ! locked.includes( s ) ); } // Move the dragged item into toList at toIndex, then emit the new value. const apply = ( toList, toIndex ) => { // Read and clear the drag origin. const from = drag.current; drag.current = null; if ( ! from ) { return; } // Work on copies so we don't mutate the current value. const next = { active: [ ...active ], inactive: [ ...inactive ] }; // Pull the item out of its source list. const [ item ] = next[ from.list ].splice( from.index, 1 ); let idx = toIndex; // Same-list move past the removed slot shifts the target index down by one. if ( from.list === toList && from.index < toIndex ) { idx -= 1; } // Clamp out-of-range targets to the end of the destination list. if ( idx < 0 || idx > next[ toList ].length ) { idx = next[ toList ].length; } // Insert at the resolved position and notify the parent. next[ toList ].splice( idx, 0, item ); onChange( next ); }; const columnStyle = { flex: 1, minWidth: 0, border: '1px solid #dcdcde', borderRadius: '6px', padding: '12px', minHeight: '220px', background: '#fbfbfc', }; const itemStyle = { padding: '10px 12px', marginBottom: '8px', border: '1px solid #dcdcde', borderRadius: '6px', background: 'linear-gradient(#ffffff, #f3f4f5)', textAlign: 'center', fontWeight: 600, color: '#1d2327', cursor: 'grab', }; const lockedItemStyle = { ...itemStyle, background: '#e7edf5', borderColor: '#c5d2e3', color: '#50575e', cursor: 'not-allowed', }; const renderColumn = ( list, items, title ) => (
e.preventDefault() } onDrop={ ( e ) => { e.preventDefault(); apply( list, items.length ); } } >
{ title }
{ items.length === 0 && (
{ __( 'Drop sections here', 'mlsimport' ) }
) } { items.map( ( slug, i ) => { const isLocked = locked.includes( slug ); return (
{ drag.current = { list, index: i }; } } onDragOver={ ( e ) => e.preventDefault() } onDrop={ ( e ) => { e.preventDefault(); e.stopPropagation(); if ( ! isLocked ) { apply( list, i ); } } } > { labelFor( slug ) }
); } ) }
); return (
{ renderColumn( 'active', active, __( 'Enabled', 'mlsimport' ) ) } { renderColumn( 'inactive', inactive, __( 'Disabled', 'mlsimport' ) ) }
); } /** * A per-filter on/off toggle list backed by the same { active, inactive } value * shape as SectionsArrange (so it shares the PHP catalog + sanitizer). Every * catalog filter renders one ToggleControl, in catalog order; a filter is on * unless it's explicitly in `inactive` — matching the PHP sanitizer, which * enables any catalog key not present in either list. When there's no saved value * yet, `defaultActive` seeds which filters start on. */ function FilterToggles( { value, onChange, catalog = [], defaultActive = [] } ) { // All catalog slugs, in catalog order. const slugs = catalog.map( ( c ) => c.slug ); // Whether a saved value exists (either list is a non-empty array). const hasValue = value && ( ( Array.isArray( value.active ) && value.active.length ) || ( Array.isArray( value.inactive ) && value.inactive.length ) ); // The off-set: saved inactive list, or everything not in defaultActive on first use. const inactive = hasValue && Array.isArray( value.inactive ) ? value.inactive : slugs.filter( ( s ) => ! defaultActive.includes( s ) ); // Toggle one filter, then recompute both lists and emit them. const setOn = ( slug, on ) => { // Rebuild inactive: flip this slug, keep others as they were. const nextInactive = slugs.filter( ( s ) => s === slug ? ! on : inactive.includes( s ) ); // Active is every slug not in the new inactive list. const active = slugs.filter( ( s ) => ! nextInactive.includes( s ) ); onChange( { active, inactive: nextInactive } ); }; return (
{ catalog.map( ( c ) => (
setOn( c.slug, on ) } __nextHasNoMarginBottom />
) ) }
); } /** * Generic field renderer — maps a field definition's `type` to the right control. * * @param {Object} props.field Field def ({ type, label, help, options, … }). * @param {*} props.value Current value for this field. * @param {Function} props.onChange Called with the field's next value. * @return {Element} The control for this field type (text/number/email by default). */ function Field( { field, value, onChange } ) { // Pick the control by declared field type. switch ( field.type ) { case 'select': return ( ); case 'yesno': return ( ); case 'toggles': return ( <>

{ field.label }

{ field.help && (

{ field.help }

) } ); case 'buttons': return ( <>

{ field.label }

{ field.help && (

{ field.help }

) }
{ field.options.map( ( opt ) => ( ) ) }
); case 'sections': return ( <>

{ field.label }

{ field.help && (

{ field.help }

) } ); case 'textarea': return ( ); case 'media': return ( <>

{ field.label }

{ field.help && (

{ field.help }

) } ); case 'color': return ( <>

{ field.label }

{ field.help && (

{ field.help }

) } ); default: // text | number | email. return ( ); } } /** * The live field tree — generated by PHP from the one registry and localized as * window.mlsimportFields. Falls back to the inline copy only if that is absent * (e.g. the script somehow loaded without its inline data). */ const TABS = typeof window !== 'undefined' && Array.isArray( window.mlsimportFields ) && window.mlsimportFields.length ? window.mlsimportFields : TABS_FALLBACK; /** * Every selectable screen, in menu order: a top-level tab without subtabs is one * screen; a tab with subtabs contributes one screen per subtab (each keeping a * reference to its parent, so the panel can title itself "Parent — Child"). */ const SCREENS = TABS.flatMap( ( t ) => t.subtabs ? t.subtabs.map( ( s ) => ( { ...s, parent: t } ) ) : [ t ] ); /** * Root component: left menu of screens + the selected screen's fields, with a * Save button. Loads/saves the whole option via the core /wp/v2/settings REST * endpoint and shows a success/error Notice. * * @return {Element} The settings app UI (or a loading spinner until settings load). */ function SettingsApp() { // The full option object (null until the initial REST load resolves). const [ settings, setSettings ] = useState( null ); // In-flight flag for the Save request. const [ saving, setSaving ] = useState( false ); // Success/error banner state. const [ notice, setNotice ] = useState( null ); // Currently selected screen (defaults to the first). const [ screen, setScreen ] = useState( SCREENS[ 0 ].name ); // On mount: fetch all settings and pull out our option (or a friendly error). useEffect( () => { apiFetch( { path: '/wp/v2/settings' } ) .then( ( all ) => setSettings( all[ OPTION_KEY ] || {} ) ) .catch( ( err ) => setNotice( { status: 'error', text: err.message || __( 'Could not load settings.', 'mlsimport' ) } ) ); }, [] ); // Immutably set one field's value in the settings object. const update = ( key, value ) => setSettings( ( prev ) => ( { ...prev, [ key ]: value } ) ); // Render a screen's fields, or a placeholder when the screen has none. const renderFields = ( fields ) => fields.length === 0 ? (

{ __( 'No settings here yet.', 'mlsimport' ) }

) : ( fields.map( ( field ) => (
update( field.key, v ) } />
) ) ); // POST the whole option back, then reflect the saved value and show a notice. const save = () => { setSaving( true ); setNotice( null ); apiFetch( { path: '/wp/v2/settings', method: 'POST', data: { [ OPTION_KEY ]: settings } } ) .then( ( all ) => { // Adopt the server-sanitized value returned by the REST endpoint. setSettings( all[ OPTION_KEY ] || {} ); setNotice( { status: 'success', text: __( 'Settings saved.', 'mlsimport' ) } ); } ) .catch( ( err ) => setNotice( { status: 'error', text: err.message || __( 'Save failed.', 'mlsimport' ) } ) ) .finally( () => setSaving( false ) ); }; // Show a spinner until the initial settings load completes. if ( ! settings ) { return (
{ __( 'Loading your settings — please wait…', 'mlsimport' ) }
); } // The screen object for the active tab (falls back to the first screen). const current = SCREENS.find( ( s ) => s.name === screen ) || SCREENS[ 0 ]; // Render one left-menu button (child items get an is-child modifier). const menuItem = ( item, isChild ) => ( ); return (
{ notice && ( setNotice( null ) } isDismissible> { notice.text } ) }
{ current.parent ? `${ current.parent.title } — ${ current.title }` : current.title } { renderFields( current.fields ) }
); } // Mount point printed by the settings page; render the app only when present. const mount = document.getElementById( 'mlsimport-standalone-app' ); if ( mount ) { createRoot( mount ).render( ); }