| 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 |
|