| 1 |
<?php |
| 2 |
/** |
| 3 |
* Standalone (theme_id 990) settings store + accessor. |
| 4 |
* |
| 5 |
* One wp_option (mlsimport_standalone_options) holds the standalone front-end |
| 6 |
* choices (map settings, lead recipient). Read everywhere via |
| 7 |
* mlsimport_standalone_option(); the admin UI lives in the plugin settings page, |
| 8 |
* gated to mode 990. See docs/adr/0005 and the build plan, decision 10. |
| 9 |
* |
| 10 |
* @package Mlsimport |
| 11 |
*/ |
| 12 |
|
| 13 |
if ( ! defined( 'ABSPATH' ) ) { |
| 14 |
exit; |
| 15 |
} |
| 16 |
|
| 17 |
/** |
| 18 |
* The option key that stores standalone front-end settings. |
| 19 |
*/ |
| 20 |
const MLSIMPORT_STANDALONE_OPTION = 'mlsimport_standalone_options'; |
| 21 |
|
| 22 |
/** |
| 23 |
* The field registry — the single source of truth for every standalone setting. |
| 24 |
* |
| 25 |
* One entry per key: type (text|email|number|textarea|select|color|int|sections), |
| 26 |
* default, and for selects an ordered value => label options map. Presentation |
| 27 |
* lives here too — 'label', 'section' (the tab id, see |
| 28 |
* mlsimport_standalone_settings_sections()), and for selects the option labels — |
| 29 |
* so both the settings app and the Customizer render from this ONE definition via |
| 30 |
* mlsimport_standalone_settings_schema(). A field with no 'section' is not shown |
| 31 |
* on either surface; such fields are flagged 'hidden' => true so the |
| 32 |
* schema completeness guard treats their absence as deliberate. Defaults, the REST |
| 33 |
* schema and the save sanitizer are all derived from this, so a new field is added |
| 34 |
* in exactly one place. |
| 35 |
* |
| 36 |
* @return array<string,array<string,mixed>> |
| 37 |
*/ |
| 38 |
function mlsimport_standalone_field_registry(): array { |
| 39 |
return array( |
| 40 |
// General. |
| 41 |
// The URL base the property archive and single permalinks sit on. Default |
| 42 |
// 'listing' rather than 'properties', which collides with nearly every |
| 43 |
// real-estate theme's own CPT and with hand-made pages (#206); settable so |
| 44 |
// a site that collides with even that can move off it. Read (and |
| 45 |
// normalised) by Mlsimport_Standalone_Cpt::property_slug(). |
| 46 |
'property_url_slug' => array( 'type' => 'text', 'default' => 'listing', 'label' => __( 'Property URL base', 'mlsimport' ), 'section' => 'general' ), |
| 47 |
'properties_per_page' => array( 'type' => 'number', 'default' => '12', 'label' => __( 'Properties per page', 'mlsimport' ), 'section' => 'general' ), |
| 48 |
'cards_per_row' => array( 'type' => 'select', 'default' => '3', 'options' => array( '2' => __( '2 per row', 'mlsimport' ), '3' => __( '3 per row', 'mlsimport' ), '4' => __( '4 per row', 'mlsimport' ) ), 'label' => __( 'Cards per row', 'mlsimport' ), 'section' => 'general' ), |
| 49 |
'order_by' => array( |
| 50 |
'type' => 'select', |
| 51 |
'default' => 'default', |
| 52 |
'options' => array( |
| 53 |
'default' => __( 'Default', 'mlsimport' ), |
| 54 |
'price_high' => __( 'Price High to Low', 'mlsimport' ), |
| 55 |
'price_low' => __( 'Price Low to High', 'mlsimport' ), |
| 56 |
'newest' => __( 'Newest first', 'mlsimport' ), |
| 57 |
'oldest' => __( 'Oldest first', 'mlsimport' ), |
| 58 |
'newest_edited' => __( 'Newest Edited', 'mlsimport' ), |
| 59 |
'oldest_edited' => __( 'Oldest Edited', 'mlsimport' ), |
| 60 |
'beds_high' => __( 'Bedrooms High to Low', 'mlsimport' ), |
| 61 |
'beds_low' => __( 'Bedrooms Low to High', 'mlsimport' ), |
| 62 |
'baths_high' => __( 'Bathrooms High to Low', 'mlsimport' ), |
| 63 |
'baths_low' => __( 'Bathrooms Low to High', 'mlsimport' ), |
| 64 |
), |
| 65 |
'label' => __( 'Order by', 'mlsimport' ), |
| 66 |
'section' => 'general', |
| 67 |
), |
| 68 |
// Taxonomy archive search filters — a per-filter on/off toggle list for the |
| 69 |
// search bar on the taxonomy/CPT archive pages. Every searchable filter is |
| 70 |
// offered; only Status, City and Property Type are on by default. The active |
| 71 |
// list becomes render_grid's search_fields (templates/archive-mlsimport-property.php). |
| 72 |
'archive_search_fields' => array( 'type' => 'sections', 'catalog' => 'mlsimport_standalone_archive_filters_catalog', 'default' => mlsimport_standalone_archive_filters_default(), 'label' => __( 'Archive search filters', 'mlsimport' ), 'section' => 'taxonomy_filters' ), |
| 73 |
|
| 74 |
// Social & Contact. |
| 75 |
'company_name' => array( 'type' => 'text', 'default' => '', 'label' => __( 'Company name', 'mlsimport' ), 'section' => 'social' ), |
| 76 |
'company_phone' => array( 'type' => 'text', 'default' => '', 'label' => __( 'Company phone', 'mlsimport' ), 'section' => 'social' ), |
| 77 |
'lead_recipient' => array( 'type' => 'email', 'default' => '', 'label' => __( 'Email', 'mlsimport' ), 'section' => 'social' ), // Company / fallback lead email. |
| 78 |
'contact_form_recipients' => array( 'type' => 'text', 'default' => '', 'label' => __( 'Contact form recipients', 'mlsimport' ), 'section' => 'social' ), // Page-block contact form recipients (comma-separated emails). |
| 79 |
'consent_label' => array( 'type' => 'textarea', 'default' => '', 'label' => __( 'Text for the checkbox label', 'mlsimport' ), 'section' => 'social' ), |
| 80 |
'terms_link_text' => array( 'type' => 'text', 'default' => 'Privacy Policy', 'label' => __( 'Text for terms link', 'mlsimport' ), 'section' => 'social' ), |
| 81 |
'show_looking_dropdown' => array( 'type' => 'select', 'default' => 'yes', 'options' => array( 'yes' => __( 'Yes', 'mlsimport' ), 'no' => __( 'No', 'mlsimport' ) ), 'label' => __( "Show 'What are you looking to do?' dropdown on contact forms?", 'mlsimport' ), 'section' => 'social' ), |
| 82 |
'looking_options' => array( 'type' => 'text', 'default' => 'Buy, Rent, Sell', 'label' => __( 'Dropdown options (comma-separated)', 'mlsimport' ), 'section' => 'social' ), |
| 83 |
|
| 84 |
// Maps. |
| 85 |
'mapbox_api_key' => array( 'type' => 'text', 'default' => '', 'label' => __( 'MapBox API KEY', 'mlsimport' ), 'section' => 'maps' ), |
| 86 |
'map_start_lat' => array( 'type' => 'number', 'default' => '', 'label' => __( 'Starting Point Latitude', 'mlsimport' ), 'section' => 'maps' ), |
| 87 |
'map_start_lng' => array( 'type' => 'number', 'default' => '', 'label' => __( 'Starting Point Longitude', 'mlsimport' ), 'section' => 'maps' ), |
| 88 |
'map_zoom' => array( 'type' => 'number', 'default' => '11', 'label' => __( 'Default Maps zoom (1 to 20)', 'mlsimport' ), 'section' => 'maps' ), |
| 89 |
'map_pin_cluster' => array( 'type' => 'select', 'default' => 'yes', 'options' => array( 'yes' => __( 'Yes', 'mlsimport' ), 'no' => __( 'No', 'mlsimport' ) ), 'label' => __( 'Use the Pin Cluster on the maps', 'mlsimport' ), 'section' => 'maps' ), |
| 90 |
'map_cluster_max_zoom' => array( 'type' => 'number', 'default' => '11', 'label' => __( 'Maximum zoom level for cluster to appear', 'mlsimport' ), 'section' => 'maps' ), |
| 91 |
// Deferred (issue #185 follow-up): the geolocation circle isn't read back yet, so |
| 92 |
// it is hidden from both surfaces. No 'section' + 'hidden' keeps the schema guard |
| 93 |
// happy; a saved value is preserved untouched until the feature ships. |
| 94 |
'map_geolocation_circle' => array( 'type' => 'number', 'default' => '', 'hidden' => true ), |
| 95 |
|
| 96 |
// Property Page. |
| 97 |
'details_columns' => array( 'type' => 'select', 'default' => '3', 'options' => array( '2' => __( '2 Columns', 'mlsimport' ), '3' => __( '3 Columns', 'mlsimport' ) ), 'label' => __( 'Details Columns', 'mlsimport' ), 'section' => 'property_page' ), // Columns in every field section's details grid. |
| 98 |
'media_section_type' => array( 'type' => 'select', 'default' => 'classic', 'options' => array( 'classic' => __( 'Classic Slider', 'mlsimport' ), 'vertical' => __( 'Vertical Slider', 'mlsimport' ), 'v4' => __( 'Slider v4', 'mlsimport' ), 'multi' => __( 'Multi Image Slider', 'mlsimport' ), 'masonry1' => __( 'Masonry Gallery v1', 'mlsimport' ), 'masonry2' => __( 'Masonry Gallery v2', 'mlsimport' ) ), 'label' => __( 'Media Section Type (property images & video)', 'mlsimport' ), 'section' => 'property_page' ), |
| 99 |
'card_style' => array( 'type' => 'select', 'default' => 'v1', 'options' => array( 'v1' => __( 'V1 — Standard', 'mlsimport' ), 'v2' => __( 'V2 — Horizontal', 'mlsimport' ), 'v3' => __( 'V3 — Photo overlay', 'mlsimport' ) ), 'label' => __( 'Property card style', 'mlsimport' ), 'section' => 'property_card' ), // Property card design for every listing grid. |
| 100 |
'property_sections' => array( 'type' => 'sections', 'catalog' => 'mlsimport_standalone_section_catalog', 'default' => mlsimport_standalone_sections_default(), 'label' => __( 'Arrange Sections', 'mlsimport' ), 'section' => 'property_page' ), |
| 101 |
// Similar Listings section on the property page: how many siblings to pull, and |
| 102 |
// how many cards sit on one row (the grid drops to 2 then 1 on narrow screens). |
| 103 |
'similar_count' => array( 'type' => 'number', 'default' => '3', 'label' => __( 'Number of similar listings', 'mlsimport' ), 'section' => 'property_page' ), |
| 104 |
'similar_per_row' => array( 'type' => 'select', 'default' => '3', 'options' => array( '2' => __( '2 per row', 'mlsimport' ), '3' => __( '3 per row', 'mlsimport' ), '4' => __( '4 per row', 'mlsimport' ) ), 'label' => __( 'Similar listings per row', 'mlsimport' ), 'section' => 'property_page' ), |
| 105 |
'overview_fields' => array( 'type' => 'sections', 'catalog' => 'mlsimport_standalone_overview_fields_catalog', 'default' => mlsimport_standalone_overview_fields_default(), 'label' => __( 'Arrange Fields', 'mlsimport' ), 'section' => 'property_page' ), // Which tiles the Overview section shows, in order. |
| 106 |
// Comma-separated time slots for the "Schedule a Tour" picker. The default is |
| 107 |
// blank on purpose: mlsimport_standalone_option() cannot tell a cleared field |
| 108 |
// from an untouched one, so any non-blank default here would make "leave it |
| 109 |
// empty to hide the picker" impossible. |
| 110 |
'tour_times' => array( 'type' => 'text', 'default' => '', 'label' => __( 'Preferred tour times', 'mlsimport' ), 'section' => 'property_page' ), |
| 111 |
|
| 112 |
// MLS attribution — the logo + the disclaimer every listing must carry. The |
| 113 |
// wording is dictated by the MLS, so it is written once here rather than per |
| 114 |
// property; %mls_id%, %year% and the agent/office contact tokens resolve per listing |
| 115 |
// (mlsimport_property_attribution_text). mls_logo_id sits with these (the |
| 116 |
// settings page groups both under the "MLS Attribution" sub-tab, logo first). |
| 117 |
'mls_logo_id' => array( 'type' => 'int', 'default' => 0, 'label' => __( 'MLS logo', 'mlsimport' ), 'section' => 'property_page' ), |
| 118 |
// Blank by default for the same reason as tour_times: a non-blank default |
| 119 |
// would print on every property with no way for the admin to switch it off. |
| 120 |
'attribution_text' => array( 'type' => 'html', 'default' => '', 'label' => __( 'Extra Disclaimer', 'mlsimport' ), 'section' => 'property_page' ), |
| 121 |
|
| 122 |
// Agent. |
| 123 |
'agent_listings_per_page' => array( 'type' => 'number', 'default' => '12', 'label' => __( 'No. of listings per page', 'mlsimport' ), 'section' => 'agent' ), // Listings shown per page on a single agent profile. |
| 124 |
'agent_listings_per_row' => array( 'type' => 'select', 'default' => '3', 'options' => array( '2' => __( '2 per row', 'mlsimport' ), '3' => __( '3 per row', 'mlsimport' ), '4' => __( '4 per row', 'mlsimport' ) ), 'label' => __( 'No. of listings per row', 'mlsimport' ), 'section' => 'agent' ), // Cards per row in the agent profile's listings grid. |
| 125 |
'agent_sections' => array( 'type' => 'sections', 'catalog' => 'mlsimport_standalone_agent_section_catalog', 'default' => mlsimport_standalone_agent_sections_default(), 'label' => __( 'Arrange Sections', 'mlsimport' ), 'section' => 'agent' ), // Agent-profile content-column section order. |
| 126 |
|
| 127 |
// Colors (brand accent). |
| 128 |
'brand_color' => array( 'type' => 'color', 'default' => '', 'label' => __( 'Main Color', 'mlsimport' ), 'section' => 'colors' ), |
| 129 |
); |
| 130 |
} |
| 131 |
|
| 132 |
/** |
| 133 |
* The standalone settings SCHEMA — the registry regrouped into ordered sections, |
| 134 |
* the single definition both the React settings page and the Customizer render |
| 135 |
* from. Each field carries its live-preview transport (colour previews instantly |
| 136 |
* via postMessage; everything else reloads the preview). Adding a field to the |
| 137 |
* registry surfaces it here — and therefore on both surfaces — automatically. |
| 138 |
* |
| 139 |
* @return array<int,array{id:string,title:string,fields:array<int,array<string,mixed>>}> |
| 140 |
*/ |
| 141 |
function mlsimport_standalone_settings_schema(): array { |
| 142 |
$titles = mlsimport_standalone_settings_sections(); |
| 143 |
$sections = array(); |
| 144 |
// Seed in canonical order so both surfaces group the fields identically, |
| 145 |
// regardless of the order fields happen to sit in the registry. |
| 146 |
foreach ( $titles as $section_id => $title ) { |
| 147 |
$sections[ $section_id ] = array( |
| 148 |
'id' => $section_id, |
| 149 |
'title' => $title, |
| 150 |
'fields' => array(), |
| 151 |
); |
| 152 |
} |
| 153 |
|
| 154 |
// Place each registry field into its section, in registry order. |
| 155 |
foreach ( mlsimport_standalone_field_registry() as $key => $field ) { |
| 156 |
// A field with no section (the hidden map fields) never renders on a surface. |
| 157 |
if ( empty( $field['section'] ) ) { |
| 158 |
continue; |
| 159 |
} |
| 160 |
$section_id = (string) $field['section']; |
| 161 |
// A field referencing a section not in the title map still gets a bucket. |
| 162 |
if ( ! isset( $sections[ $section_id ] ) ) { |
| 163 |
$sections[ $section_id ] = array( |
| 164 |
'id' => $section_id, |
| 165 |
'title' => $section_id, |
| 166 |
'fields' => array(), |
| 167 |
); |
| 168 |
} |
| 169 |
// Flatten the registry entry into the schema field shape both surfaces read; |
| 170 |
// transport is derived from type (color previews live, everything else reloads). |
| 171 |
$sections[ $section_id ]['fields'][] = array( |
| 172 |
'key' => $key, |
| 173 |
'type' => $field['type'], |
| 174 |
'label' => isset( $field['label'] ) ? $field['label'] : $key, |
| 175 |
'section' => $section_id, |
| 176 |
'options' => isset( $field['options'] ) ? $field['options'] : null, |
| 177 |
'catalog' => isset( $field['catalog'] ) ? $field['catalog'] : null, |
| 178 |
'transport' => 'color' === $field['type'] ? 'postMessage' : 'refresh', |
| 179 |
); |
| 180 |
} |
| 181 |
|
| 182 |
// Drop any seeded section that ended up with no fields. |
| 183 |
return array_values( array_filter( $sections, function ( $section ) { |
| 184 |
return ! empty( $section['fields'] ); |
| 185 |
} ) ); |
| 186 |
} |
| 187 |
|
| 188 |
/** |
| 189 |
* The section id => human title map, in the order both the settings page and the |
| 190 |
* Customizer present them. This is the one place section titles/order are defined; |
| 191 |
* a field's 'section' key must reference an id listed here. |
| 192 |
* |
| 193 |
* @return array<string,string> |
| 194 |
*/ |
| 195 |
function mlsimport_standalone_settings_sections(): array { |
| 196 |
return array( |
| 197 |
'general' => __( 'General', 'mlsimport' ), |
| 198 |
'taxonomy_filters' => __( 'Category page filters', 'mlsimport' ), |
| 199 |
'social' => __( 'Social & Contact', 'mlsimport' ), |
| 200 |
'maps' => __( 'Maps', 'mlsimport' ), |
| 201 |
'property_page' => __( 'Property Page', 'mlsimport' ), |
| 202 |
'property_card' => __( 'Property Card', 'mlsimport' ), |
| 203 |
'agent' => __( 'Agent', 'mlsimport' ), |
| 204 |
'colors' => __( 'Colors', 'mlsimport' ), |
| 205 |
); |
| 206 |
} |
| 207 |
|
| 208 |
/** |
| 209 |
* The Property Page sub-tab id => title map (the settings page groups that tab's |
| 210 |
* fields into these sub-screens). Order here is the sub-tab order. |
| 211 |
* |
| 212 |
* @return array<string,string> |
| 213 |
*/ |
| 214 |
function mlsimport_standalone_settings_subtabs(): array { |
| 215 |
return array( |
| 216 |
'pp_general' => __( 'General', 'mlsimport' ), |
| 217 |
'pp_layout' => __( 'Property Page Layout', 'mlsimport' ), |
| 218 |
'pp_attribution' => __( 'MLS Attribution', 'mlsimport' ), |
| 219 |
'pp_tour' => __( 'Tour Details', 'mlsimport' ), |
| 220 |
'pp_overview' => __( 'Overview', 'mlsimport' ), |
| 221 |
'pp_similar' => __( 'Similar Listings', 'mlsimport' ), |
| 222 |
); |
| 223 |
} |
| 224 |
|
| 225 |
/** |
| 226 |
* Settings-page-only presentation, keyed by field key. The registry owns each |
| 227 |
* field's existence/type/default/label/section/options; this holds the extras the |
| 228 |
* React settings page needs that the Customizer does not: help text, a control |
| 229 |
* override (when the widget differs from the plain type — buttons/yesno/toggles/ |
| 230 |
* media), the Property Page sub-tab a field sits in, textarea rows, and a toggle |
| 231 |
* list's default-on set. A field absent here still renders (no help, control |
| 232 |
* derived from its type, no sub-tab) — so a simple new field needs only a registry |
| 233 |
* entry. Both the settings app and the Customizer are still generated from the one |
| 234 |
* registry; this is the settings app's view of it. |
| 235 |
* |
| 236 |
* @return array<string,array<string,mixed>> |
| 237 |
*/ |
| 238 |
function mlsimport_standalone_settings_ui(): array { |
| 239 |
return array( |
| 240 |
'property_url_slug' => array( 'help' => __( 'The URL segment your listings live under — e.g. listing gives yoursite.com/listing/. Change it only if another page or plugin already uses that word; existing listing links will break when you do.', 'mlsimport' ) ), |
| 241 |
'properties_per_page' => array( 'help' => __( 'How many listings show per page on the taxonomy and property archive pages. Does not affect page-builder blocks, which set their own per-page.', 'mlsimport' ) ), |
| 242 |
'cards_per_row' => array( 'control' => 'buttons', 'help' => __( 'How many property cards sit on one row of the taxonomy and property archive pages. Drops to 2 then 1 automatically on narrow screens.', 'mlsimport' ) ), |
| 243 |
'archive_search_fields' => array( 'control' => 'toggles', 'default_active' => array( 'status', 'city', 'property_type' ), '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' ) ), |
| 244 |
'company_name' => array( 'help' => __( 'Shown as the contact name on listings whose agent comes straight from the MLS feed — MLS rules forbid displaying the feed agent\'s own contact details.', 'mlsimport' ) ), |
| 245 |
'company_phone' => array( 'help' => __( 'Used as the contact phone on listings whose agent comes straight from the MLS feed.', 'mlsimport' ) ), |
| 246 |
'lead_recipient' => array( 'help' => __( 'Company email — e.g. office@domain.com. Also the fallback lead recipient when a listing has no agent email.', 'mlsimport' ) ), |
| 247 |
'contact_form_recipients' => array( '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' ) ), |
| 248 |
'consent_label' => array( 'help' => __( 'Shown next to the marketing-consent checkbox on contact forms.', 'mlsimport' ) ), |
| 249 |
'terms_link_text' => array( 'help' => __( 'e.g. Privacy Policy.', 'mlsimport' ) ), |
| 250 |
'show_looking_dropdown' => array( 'control' => 'yesno', 'help' => __( 'Displays an optional dropdown field on Agent, Agency, Developer, and Property contact forms.', 'mlsimport' ) ), |
| 251 |
'mapbox_api_key' => array( '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' ) ), |
| 252 |
'map_start_lat' => array( 'help' => __( 'Fallback map center — used only when a listings map has no results to show. When there are results the map zooms to fit them and this is ignored. Numbers only (ex: 40.577906).', 'mlsimport' ) ), |
| 253 |
'map_start_lng' => array( 'help' => __( 'Longitude of the fallback center above. Numbers only (ex: -74.155058).', 'mlsimport' ) ), |
| 254 |
'map_zoom' => array( 'help' => __( 'Default zoom (1 = world, 20 = street). Sets the single-property map and the fallback zoom for empty listings maps.', 'mlsimport' ) ), |
| 255 |
'map_pin_cluster' => array( 'control' => 'yesno', 'help' => __( 'If yes, nearby pins are grouped into a numbered cluster.', 'mlsimport' ) ), |
| 256 |
'map_cluster_max_zoom' => array( 'help' => __( 'Clusters show up to this zoom level; zoom in past it and the listings split into individual pins.', 'mlsimport' ) ), |
| 257 |
'media_section_type' => array( 'control' => 'buttons', 'subtab' => 'pp_general', 'help' => __( 'Choose how to display the listing images or video.', 'mlsimport' ) ), |
| 258 |
'details_columns' => array( 'control' => 'buttons', 'subtab' => 'pp_layout', 'help' => __( 'How many columns each details section (Interior, Exterior, Financial…) runs. Collapses automatically on narrow screens.', 'mlsimport' ) ), |
| 259 |
'property_sections' => array( 'subtab' => 'pp_layout', 'help' => __( 'Drag sections between Enabled and Disabled to choose which appear, and reorder within a list.', 'mlsimport' ) ), |
| 260 |
'mls_logo_id' => array( 'control' => 'media', 'subtab' => 'pp_attribution', 'help' => __( "Your MLS's required attribution logo. Shown in the MLS Attribution section and on listing cards.", 'mlsimport' ) ), |
| 261 |
'attribution_text' => array( 'subtab' => 'pp_attribution', '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. You can also use %agent_phone% and %agent_email% for the listing agent the MLS sent, and %office_phone%, %office_email% or %attribution_contact% for the listing office. Each stays empty unless that field is ticked under MLS Import Settings → Listing Details, so tick List Office Phone, List Office Email or Attribution Contact there before using them. Basic HTML (links, bold, paragraphs) is allowed.', 'mlsimport' ) ), |
| 262 |
'tour_times' => array( 'subtab' => 'pp_tour', '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. Leave blank to hide the time picker.', 'mlsimport' ) ), |
| 263 |
'overview_fields' => array( 'subtab' => 'pp_overview', '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' ) ), |
| 264 |
'similar_count' => array( 'subtab' => 'pp_similar', 'help' => __( 'How many similar listings the Similar Listings section pulls in. Default 3.', 'mlsimport' ) ), |
| 265 |
'similar_per_row' => array( 'control' => 'buttons', 'subtab' => 'pp_similar', 'help' => __( 'How many similar listing cards sit on one row. Drops to 2 then 1 automatically on narrow screens.', 'mlsimport' ) ), |
| 266 |
'card_style' => array( 'help' => __( 'The card design used in every listing grid (search results, lists, sliders, similar listings).', 'mlsimport' ) ), |
| 267 |
'agent_listings_per_page' => array( 'help' => __( "Listings shown per page on a single agent's profile, with pagination. Default 12.", 'mlsimport' ) ), |
| 268 |
'agent_listings_per_row' => array( 'control' => 'buttons', 'help' => __( "How many listing cards sit on one row of an agent's profile. Drops to 2 then 1 automatically on narrow screens.", 'mlsimport' ) ), |
| 269 |
'agent_sections' => array( 'help' => __( 'Drag sections between Enabled and Disabled to choose which appear on the agent profile, and reorder within a list.', 'mlsimport' ) ), |
| 270 |
'brand_color' => array( 'help' => __( 'Main accent / brand colour for the front end.', 'mlsimport' ) ), |
| 271 |
); |
| 272 |
} |
| 273 |
|
| 274 |
/** |
| 275 |
* The settings-page field tree (tabs → optional sub-tabs → fields), generated from |
| 276 |
* the ONE registry + the settings UI map. Localised to the React app as |
| 277 |
* window.mlsimportFields, which renders from it — so a field added to the registry |
| 278 |
* appears on the settings page (and, via the schema, the Customizer) automatically. |
| 279 |
* The shape matches what the React app's generic renderer expects: each field has |
| 280 |
* key, label, help, type (the control), options [{label,value}] for select/buttons, |
| 281 |
* catalog id + defaultActive for sections/toggles, and rows for a textarea. |
| 282 |
* |
| 283 |
* @return array<int,array<string,mixed>> |
| 284 |
*/ |
| 285 |
function mlsimport_standalone_settings_app_config(): array { |
| 286 |
$ui = mlsimport_standalone_settings_ui(); |
| 287 |
$sections = mlsimport_standalone_settings_sections(); |
| 288 |
$subtabs = mlsimport_standalone_settings_subtabs(); |
| 289 |
$catalog_map = array( |
| 290 |
'mlsimport_standalone_section_catalog' => '', |
| 291 |
'mlsimport_standalone_overview_fields_catalog' => 'overview', |
| 292 |
'mlsimport_standalone_agent_section_catalog' => 'agent', |
| 293 |
'mlsimport_standalone_archive_filters_catalog' => 'archive_filters', |
| 294 |
); |
| 295 |
|
| 296 |
// Group registry fields by section, in registry order, skipping hidden. |
| 297 |
$by_section = array(); |
| 298 |
foreach ( mlsimport_standalone_field_registry() as $key => $field ) { |
| 299 |
if ( ! empty( $field['hidden'] ) || empty( $field['section'] ) ) { |
| 300 |
continue; |
| 301 |
} |
| 302 |
$by_section[ $field['section'] ][ $key ] = $field; |
| 303 |
} |
| 304 |
|
| 305 |
// Build one tab per section, in section-map order. |
| 306 |
$tabs = array(); |
| 307 |
foreach ( $sections as $sec_id => $sec_title ) { |
| 308 |
// Skip a section with no visible fields. |
| 309 |
if ( empty( $by_section[ $sec_id ] ) ) { |
| 310 |
continue; |
| 311 |
} |
| 312 |
|
| 313 |
// Does any field in this section declare a Property Page sub-tab? |
| 314 |
$has_subtab = false; |
| 315 |
foreach ( $by_section[ $sec_id ] as $k => $f ) { |
| 316 |
if ( ! empty( $ui[ $k ]['subtab'] ) ) { |
| 317 |
$has_subtab = true; |
| 318 |
break; |
| 319 |
} |
| 320 |
} |
| 321 |
|
| 322 |
// Simple section: a flat field list, no sub-tabs. |
| 323 |
if ( ! $has_subtab ) { |
| 324 |
$fields = array(); |
| 325 |
foreach ( $by_section[ $sec_id ] as $k => $f ) { |
| 326 |
$fields[] = mlsimport_standalone_settings_field_json( $k, $f, $ui, $catalog_map ); |
| 327 |
} |
| 328 |
$tabs[] = array( 'name' => $sec_id, 'title' => $sec_title, 'fields' => $fields ); |
| 329 |
continue; |
| 330 |
} |
| 331 |
|
| 332 |
// Bucket the section's fields by sub-tab, then emit sub-tabs in map order. |
| 333 |
$buckets = array(); |
| 334 |
foreach ( $by_section[ $sec_id ] as $k => $f ) { |
| 335 |
// A field with no declared sub-tab falls into the first sub-tab. |
| 336 |
$st = ! empty( $ui[ $k ]['subtab'] ) ? $ui[ $k ]['subtab'] : array_key_first( $subtabs ); |
| 337 |
$buckets[ $st ][] = mlsimport_standalone_settings_field_json( $k, $f, $ui, $catalog_map ); |
| 338 |
} |
| 339 |
// Emit sub-tabs in the sub-tab map order, skipping empty ones. |
| 340 |
$sub = array(); |
| 341 |
foreach ( $subtabs as $st_id => $st_title ) { |
| 342 |
if ( empty( $buckets[ $st_id ] ) ) { |
| 343 |
continue; |
| 344 |
} |
| 345 |
$sub[] = array( 'name' => $sec_id . '__' . $st_id, 'title' => $st_title, 'fields' => $buckets[ $st_id ] ); |
| 346 |
} |
| 347 |
$tabs[] = array( 'name' => $sec_id, 'title' => $sec_title, 'subtabs' => $sub ); |
| 348 |
} |
| 349 |
|
| 350 |
return $tabs; |
| 351 |
} |
| 352 |
|
| 353 |
/** |
| 354 |
* Shape one registry field for the settings app (see the app-config builder). |
| 355 |
* |
| 356 |
* @param string $key Field key. |
| 357 |
* @param array $field Registry entry. |
| 358 |
* @param array $ui Settings UI map. |
| 359 |
* @param array $catalog_map PHP catalog callable => React catalog id. |
| 360 |
* @return array<string,mixed> |
| 361 |
*/ |
| 362 |
function mlsimport_standalone_settings_field_json( string $key, array $field, array $ui, array $catalog_map ): array { |
| 363 |
// The settings-page-only presentation for this key (help/control/subtab/rows). |
| 364 |
$u = isset( $ui[ $key ] ) ? $ui[ $key ] : array(); |
| 365 |
|
| 366 |
// Widget to render with: an explicit UI override, else derived from the type. |
| 367 |
$control = isset( $u['control'] ) ? $u['control'] : null; |
| 368 |
if ( ! $control ) { |
| 369 |
switch ( $field['type'] ) { |
| 370 |
case 'html': |
| 371 |
// Rich HTML fields render in a textarea control. |
| 372 |
$control = 'textarea'; |
| 373 |
break; |
| 374 |
case 'int': |
| 375 |
// The one int field (mls_logo_id) is an attachment picker. |
| 376 |
$control = 'media'; |
| 377 |
break; |
| 378 |
default: // text | number | email | textarea | select | color | sections. |
| 379 |
$control = $field['type']; |
| 380 |
break; |
| 381 |
} |
| 382 |
} |
| 383 |
|
| 384 |
// The base field shape the React renderer expects. |
| 385 |
$out = array( |
| 386 |
'key' => $key, |
| 387 |
'label' => isset( $field['label'] ) ? $field['label'] : $key, |
| 388 |
'type' => $control, |
| 389 |
); |
| 390 |
// Optional help text under the control. |
| 391 |
if ( ! empty( $u['help'] ) ) { |
| 392 |
$out['help'] = $u['help']; |
| 393 |
} |
| 394 |
// select/buttons carry a [{label,value}] list, rebuilt from the value=>label map. |
| 395 |
if ( in_array( $control, array( 'select', 'buttons' ), true ) && ! empty( $field['options'] ) && is_array( $field['options'] ) ) { |
| 396 |
$opts = array(); |
| 397 |
foreach ( $field['options'] as $value => $label ) { |
| 398 |
$opts[] = array( 'label' => $label, 'value' => (string) $value ); |
| 399 |
} |
| 400 |
$out['options'] = $opts; |
| 401 |
} |
| 402 |
// sections/toggles carry a React catalog id + the default-on set. |
| 403 |
if ( in_array( $control, array( 'sections', 'toggles' ), true ) ) { |
| 404 |
$out['catalog'] = ( isset( $field['catalog'] ) && isset( $catalog_map[ $field['catalog'] ] ) ) ? $catalog_map[ $field['catalog'] ] : ''; |
| 405 |
if ( ! empty( $u['default_active'] ) ) { |
| 406 |
$out['defaultActive'] = $u['default_active']; |
| 407 |
} |
| 408 |
} |
| 409 |
// A textarea can request a row count. |
| 410 |
if ( ! empty( $u['rows'] ) ) { |
| 411 |
$out['rows'] = $u['rows']; |
| 412 |
} |
| 413 |
|
| 414 |
return $out; |
| 415 |
} |
| 416 |
|
| 417 |
/** |
| 418 |
* The single-property section catalog (slug => label) used by the "Arrange |
| 419 |
* Sections" control. Sourced from the property section registry so it always |
| 420 |
* matches the sections the front end can actually render. |
| 421 |
* |
| 422 |
* @return array<string,string> |
| 423 |
*/ |
| 424 |
function mlsimport_standalone_section_catalog(): array { |
| 425 |
if ( function_exists( 'mlsimport_register_builtin_property_sections' ) ) { |
| 426 |
mlsimport_register_builtin_property_sections(); |
| 427 |
} |
| 428 |
// The sections offered in the "Arrange Sections" layout control — the prototype |
| 429 |
// single-page set. The gallery/slider/masonry variants are represented by the |
| 430 |
// single 'property_gallery' slot (the look is chosen in "Media Section Type"). |
| 431 |
$allowed = array( |
| 432 |
'breadcrumbs', 'property_gallery', 'title_bar', 'subnav', 'overview', 'description', |
| 433 |
'virtual_tour', 'map', |
| 434 |
'interior', 'exterior', 'structure', 'utilities', 'financial', |
| 435 |
'schools', 'location', 'listing_info', 'other', |
| 436 |
'features', 'calculator', 'agent_card', 'similar', 'attribution', |
| 437 |
'mobile_agent_bar', |
| 438 |
); |
| 439 |
$registry = function_exists( 'mlsimport_get_property_sections' ) ? mlsimport_get_property_sections() : array(); |
| 440 |
$out = array(); |
| 441 |
foreach ( $allowed as $slug ) { |
| 442 |
if ( isset( $registry[ $slug ] ) ) { |
| 443 |
$out[ $slug ] = isset( $registry[ $slug ]['label'] ) ? (string) $registry[ $slug ]['label'] : $slug; |
| 444 |
} |
| 445 |
} |
| 446 |
return $out; |
| 447 |
} |
| 448 |
|
| 449 |
/** |
| 450 |
* Default sections order: every known section enabled, none disabled. |
| 451 |
* |
| 452 |
* @return array{active:string[],inactive:string[]} |
| 453 |
*/ |
| 454 |
function mlsimport_standalone_sections_default(): array { |
| 455 |
return array( |
| 456 |
'active' => array_keys( mlsimport_standalone_section_catalog() ), |
| 457 |
'inactive' => array(), |
| 458 |
); |
| 459 |
} |
| 460 |
|
| 461 |
/** |
| 462 |
* The section slugs to render on the single property page, in order. |
| 463 |
* |
| 464 |
* Resolves the saved "Arrange Sections" layout, falling back to the default when |
| 465 |
* it is empty. A section added to the catalog AFTER the user last saved their |
| 466 |
* layout appears in neither their active nor their inactive list; it is enabled |
| 467 |
* by default and placed next to the catalog neighbour it follows, so a new |
| 468 |
* section reaches existing installs in the right place instead of staying |
| 469 |
* invisible until they happen to re-save their design settings. A section the |
| 470 |
* user has explicitly disabled stays disabled. |
| 471 |
* |
| 472 |
* @return string[] |
| 473 |
*/ |
| 474 |
function mlsimport_standalone_active_sections(): array { |
| 475 |
$layout = mlsimport_standalone_option( 'property_sections' ); |
| 476 |
if ( ! is_array( $layout ) || empty( $layout['active'] ) ) { |
| 477 |
return mlsimport_standalone_sections_default()['active']; |
| 478 |
} |
| 479 |
|
| 480 |
$active = array_values( (array) $layout['active'] ); |
| 481 |
$inactive = isset( $layout['inactive'] ) ? (array) $layout['inactive'] : array(); |
| 482 |
$catalog = array_keys( mlsimport_standalone_section_catalog() ); |
| 483 |
|
| 484 |
foreach ( $catalog as $i => $slug ) { |
| 485 |
if ( in_array( $slug, $active, true ) || in_array( $slug, $inactive, true ) ) { |
| 486 |
continue; |
| 487 |
} |
| 488 |
|
| 489 |
// Walk back through the catalog for the nearest predecessor the user still |
| 490 |
// has enabled, and slot the newcomer in right after it. |
| 491 |
$pos = null; |
| 492 |
for ( $j = $i - 1; $j >= 0; $j-- ) { |
| 493 |
$at = array_search( $catalog[ $j ], $active, true ); |
| 494 |
if ( false !== $at ) { |
| 495 |
$pos = $at + 1; |
| 496 |
break; |
| 497 |
} |
| 498 |
} |
| 499 |
|
| 500 |
if ( null === $pos ) { |
| 501 |
array_unshift( $active, $slug ); |
| 502 |
} else { |
| 503 |
array_splice( $active, $pos, 0, array( $slug ) ); |
| 504 |
} |
| 505 |
} |
| 506 |
|
| 507 |
return $active; |
| 508 |
} |
| 509 |
|
| 510 |
/** |
| 511 |
* The Overview tile catalog (slug => label) used by the Overview "Arrange Fields" |
| 512 |
* control. Sourced from the property Overview section itself, so the control |
| 513 |
* offers exactly the tiles the front end knows how to draw. |
| 514 |
* |
| 515 |
* @return array<string,string> |
| 516 |
*/ |
| 517 |
function mlsimport_standalone_overview_fields_catalog(): array { |
| 518 |
if ( ! function_exists( 'mlsimport_property_overview_fields' ) ) { |
| 519 |
return array(); |
| 520 |
} |
| 521 |
$out = array(); |
| 522 |
foreach ( mlsimport_property_overview_fields() as $slug => $field ) { |
| 523 |
$out[ $slug ] = $field[1]; |
| 524 |
} |
| 525 |
return $out; |
| 526 |
} |
| 527 |
|
| 528 |
/** |
| 529 |
* Default Overview tiles: every tile enabled, in catalog order, none disabled. |
| 530 |
* |
| 531 |
* @return array{active:string[],inactive:string[]} |
| 532 |
*/ |
| 533 |
function mlsimport_standalone_overview_fields_default(): array { |
| 534 |
return array( |
| 535 |
'active' => array_keys( mlsimport_standalone_overview_fields_catalog() ), |
| 536 |
'inactive' => array(), |
| 537 |
); |
| 538 |
} |
| 539 |
|
| 540 |
/** |
| 541 |
* The Overview tile slugs to render, in order. Resolves the saved arrangement, |
| 542 |
* falling back to every tile when the setting is empty. A tile added to the |
| 543 |
* catalog after the user last saved is in neither list; it is enabled and |
| 544 |
* appended, so a new tile reaches existing installs instead of staying invisible |
| 545 |
* until they happen to re-save. A tile the user disabled stays disabled. |
| 546 |
* |
| 547 |
* @return string[] |
| 548 |
*/ |
| 549 |
function mlsimport_standalone_active_overview_fields(): array { |
| 550 |
$layout = mlsimport_standalone_option( 'overview_fields' ); |
| 551 |
if ( ! is_array( $layout ) || empty( $layout['active'] ) ) { |
| 552 |
$active = mlsimport_standalone_overview_fields_default()['active']; |
| 553 |
} else { |
| 554 |
$active = array_values( (array) $layout['active'] ); |
| 555 |
$inactive = isset( $layout['inactive'] ) ? (array) $layout['inactive'] : array(); |
| 556 |
foreach ( array_keys( mlsimport_standalone_overview_fields_catalog() ) as $slug ) { |
| 557 |
if ( ! in_array( $slug, $active, true ) && ! in_array( $slug, $inactive, true ) ) { |
| 558 |
$active[] = $slug; |
| 559 |
} |
| 560 |
} |
| 561 |
} |
| 562 |
|
| 563 |
/** Filter the ordered Overview tile slugs. @since 6.4 */ |
| 564 |
return (array) apply_filters( 'mlsimport_property_overview_active_fields', $active ); |
| 565 |
} |
| 566 |
|
| 567 |
/** |
| 568 |
* The single-agent section catalog (slug => label) used by the agent "Arrange |
| 569 |
* Sections" control. These are the reorderable content-column sections; the hero |
| 570 |
* + sub-nav (locked at the top) and the contact rail (sidebar) are fixed and not |
| 571 |
* listed here. Mirrors mlsimport_render_agent_section(). |
| 572 |
* |
| 573 |
* @return array<string,string> |
| 574 |
*/ |
| 575 |
function mlsimport_standalone_agent_section_catalog(): array { |
| 576 |
return array( |
| 577 |
'about' => __( 'About', 'mlsimport' ), |
| 578 |
'listings' => __( 'Listings', 'mlsimport' ), |
| 579 |
'credentials' => __( 'Credentials', 'mlsimport' ), |
| 580 |
); |
| 581 |
} |
| 582 |
|
| 583 |
/** |
| 584 |
* Default agent section order: every agent section enabled, none disabled. |
| 585 |
* |
| 586 |
* @return array{active:string[],inactive:string[]} |
| 587 |
*/ |
| 588 |
function mlsimport_standalone_agent_sections_default(): array { |
| 589 |
return array( |
| 590 |
'active' => array_keys( mlsimport_standalone_agent_section_catalog() ), |
| 591 |
'inactive' => array(), |
| 592 |
); |
| 593 |
} |
| 594 |
|
| 595 |
/** |
| 596 |
* Resolve the ordered active agent content-column sections: the saved "Arrange |
| 597 |
* Sections" order when set, else the default. |
| 598 |
* |
| 599 |
* Catalog sections a saved layout predates — neither active nor explicitly |
| 600 |
* disabled (About was re-added to the catalog after some sites already saved an |
| 601 |
* order) — are inserted at their catalog position, so a newly added section |
| 602 |
* surfaces without the operator re-saving, while their custom order for the |
| 603 |
* sections they did arrange is preserved. |
| 604 |
* |
| 605 |
* @return string[] Active section slugs, in render order. |
| 606 |
*/ |
| 607 |
function mlsimport_standalone_agent_active_sections(): array { |
| 608 |
$layout = mlsimport_standalone_option( 'agent_sections' ); |
| 609 |
$active = ( is_array( $layout ) && ! empty( $layout['active'] ) ) ? array_values( (array) $layout['active'] ) : mlsimport_standalone_agent_sections_default()['active']; |
| 610 |
$inactive = ( is_array( $layout ) && ! empty( $layout['inactive'] ) ) ? (array) $layout['inactive'] : array(); |
| 611 |
$catalog = array_keys( mlsimport_standalone_agent_section_catalog() ); |
| 612 |
|
| 613 |
foreach ( $catalog as $ci => $slug ) { |
| 614 |
// Already placed or deliberately disabled — leave it be. |
| 615 |
if ( in_array( $slug, $active, true ) || in_array( $slug, $inactive, true ) ) { |
| 616 |
continue; |
| 617 |
} |
| 618 |
// Insert before the first already-active section that follows this one in |
| 619 |
// the catalog (so About lands ahead of Listings); else append. |
| 620 |
$pos = count( $active ); |
| 621 |
foreach ( array_slice( $catalog, $ci + 1 ) as $after ) { |
| 622 |
$idx = array_search( $after, $active, true ); |
| 623 |
if ( false !== $idx ) { |
| 624 |
$pos = (int) $idx; |
| 625 |
break; |
| 626 |
} |
| 627 |
} |
| 628 |
array_splice( $active, $pos, 0, array( $slug ) ); |
| 629 |
} |
| 630 |
return $active; |
| 631 |
} |
| 632 |
|
| 633 |
/** |
| 634 |
* The archive search-filter catalog (field key => label): the search form's |
| 635 |
* toggleable fields in render order — the keyword box, every searchable catalog |
| 636 |
* field, then the sort control. Mirrors search-form.php and the block's |
| 637 |
* per-field on/off list, so the settings control matches what the form renders. |
| 638 |
* |
| 639 |
* @return array<string,string> |
| 640 |
*/ |
| 641 |
function mlsimport_standalone_archive_filters_catalog(): array { |
| 642 |
$labels = array( 'keywords' => __( 'Keywords', 'mlsimport' ) ); |
| 643 |
if ( class_exists( 'Mlsimport_Page_Block_Search_Fields' ) ) { |
| 644 |
$labels = array_merge( $labels, Mlsimport_Page_Block_Search_Fields::labels() ); |
| 645 |
} |
| 646 |
$labels['sort'] = __( 'Sort by', 'mlsimport' ); |
| 647 |
return $labels; |
| 648 |
} |
| 649 |
|
| 650 |
/** |
| 651 |
* Default archive filters: Status, City and Property Type on, every other filter |
| 652 |
* off. Any filter the catalog gains later defaults on (the sanitizer enables |
| 653 |
* catalog keys not present in either list). |
| 654 |
* |
| 655 |
* @return array{active:string[],inactive:string[]} |
| 656 |
*/ |
| 657 |
function mlsimport_standalone_archive_filters_default(): array { |
| 658 |
$on = array( 'status', 'city', 'property_type' ); |
| 659 |
$all = array_keys( mlsimport_standalone_archive_filters_catalog() ); |
| 660 |
return array( |
| 661 |
'active' => array_values( array_intersect( $all, $on ) ), |
| 662 |
'inactive' => array_values( array_diff( $all, $on ) ), |
| 663 |
); |
| 664 |
} |
| 665 |
|
| 666 |
/** |
| 667 |
* Sanitize an "Arrange Sections" value into { active, inactive } slug lists. |
| 668 |
* Only known slugs survive, each appears once, a slug can't be in both lists, |
| 669 |
* and any known section missing from the input is appended to active so new |
| 670 |
* sections default to enabled. |
| 671 |
* |
| 672 |
* @param mixed $raw Posted value. |
| 673 |
* @param string[]|null $known Allowed slugs for this catalog (null = property catalog). |
| 674 |
* @return array{active:string[],inactive:string[]} |
| 675 |
*/ |
| 676 |
function mlsimport_sanitize_sections( $raw, ?array $known = null ): array { |
| 677 |
$raw = is_array( $raw ) ? $raw : array(); |
| 678 |
// Default to the property catalog when no explicit allow-list is given. |
| 679 |
$known = null !== $known ? $known : array_keys( mlsimport_standalone_section_catalog() ); |
| 680 |
|
| 681 |
// Keep only known slugs, sanitized and de-duplicated, preserving input order. |
| 682 |
$clean = static function ( $list ) use ( $known ) { |
| 683 |
$out = array(); |
| 684 |
foreach ( (array) $list as $slug ) { |
| 685 |
$slug = sanitize_key( $slug ); |
| 686 |
if ( in_array( $slug, $known, true ) && ! in_array( $slug, $out, true ) ) { |
| 687 |
$out[] = $slug; |
| 688 |
} |
| 689 |
} |
| 690 |
return $out; |
| 691 |
}; |
| 692 |
|
| 693 |
// active wins any tie: a slug in both lists is dropped from inactive. |
| 694 |
$active = isset( $raw['active'] ) ? $clean( $raw['active'] ) : array(); |
| 695 |
$inactive = array_values( array_diff( isset( $raw['inactive'] ) ? $clean( $raw['inactive'] ) : array(), $active ) ); |
| 696 |
|
| 697 |
// Any known slug the input never mentioned defaults to enabled. |
| 698 |
foreach ( $known as $slug ) { |
| 699 |
if ( ! in_array( $slug, $active, true ) && ! in_array( $slug, $inactive, true ) ) { |
| 700 |
$active[] = $slug; |
| 701 |
} |
| 702 |
} |
| 703 |
|
| 704 |
return array( 'active' => $active, 'inactive' => $inactive ); |
| 705 |
} |
| 706 |
|
| 707 |
/** |
| 708 |
* Default values for standalone settings, derived from the registry. |
| 709 |
* |
| 710 |
* @return array |
| 711 |
*/ |
| 712 |
function mlsimport_standalone_option_defaults(): array { |
| 713 |
$defaults = array(); |
| 714 |
foreach ( mlsimport_standalone_field_registry() as $key => $field ) { |
| 715 |
$defaults[ $key ] = $field['default']; |
| 716 |
} |
| 717 |
return $defaults; |
| 718 |
} |
| 719 |
|
| 720 |
/** |
| 721 |
* Register the standalone option for the WP Settings REST endpoint. |
| 722 |
* |
| 723 |
* The dedicated "Standalone Design" admin page is a React app that reads/writes |
| 724 |
* this option through /wp/v2/settings, so the option must be registered with a |
| 725 |
* show_in_rest schema (derived from the registry). Hooked on init (not |
| 726 |
* admin_init) so the registration is present during REST requests too. |
| 727 |
* |
| 728 |
* @return void |
| 729 |
*/ |
| 730 |
function mlsimport_register_standalone_setting(): void { |
| 731 |
// Build the REST schema's per-key property types from the registry. |
| 732 |
$properties = array(); |
| 733 |
foreach ( mlsimport_standalone_field_registry() as $key => $field ) { |
| 734 |
// A 'sections' field is an { active[], inactive[] } object. |
| 735 |
if ( 'sections' === $field['type'] ) { |
| 736 |
$properties[ $key ] = array( |
| 737 |
'type' => 'object', |
| 738 |
'properties' => array( |
| 739 |
'active' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ), |
| 740 |
'inactive' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ), |
| 741 |
), |
| 742 |
'additionalProperties' => false, |
| 743 |
); |
| 744 |
continue; |
| 745 |
} |
| 746 |
// Everything else is an integer (int fields) or a string. |
| 747 |
$properties[ $key ] = array( 'type' => 'int' === $field['type'] ? 'integer' : 'string' ); |
| 748 |
} |
| 749 |
|
| 750 |
register_setting( |
| 751 |
'mlsimport_standalone_options', |
| 752 |
MLSIMPORT_STANDALONE_OPTION, |
| 753 |
array( |
| 754 |
'type' => 'object', |
| 755 |
'default' => mlsimport_standalone_option_defaults(), |
| 756 |
'sanitize_callback' => 'mlsimport_sanitize_standalone_options', |
| 757 |
'show_in_rest' => array( |
| 758 |
'schema' => array( |
| 759 |
'type' => 'object', |
| 760 |
'properties' => $properties, |
| 761 |
'additionalProperties' => false, |
| 762 |
), |
| 763 |
), |
| 764 |
) |
| 765 |
); |
| 766 |
} |
| 767 |
// Guarded so this file can be required by pure unit tests (no WP runtime). |
| 768 |
if ( function_exists( 'add_action' ) ) { |
| 769 |
add_action( 'init', 'mlsimport_register_standalone_setting' ); |
| 770 |
// Feed the saved MapBox key into every map surface via the shared tile filter. |
| 771 |
add_filter( 'mlsimport_map_tile_url', 'mlsimport_standalone_map_tile_url' ); |
| 772 |
} |
| 773 |
|
| 774 |
/** |
| 775 |
* Sanitize the standalone settings on save (Settings API + REST share this). |
| 776 |
* Iterates the registry — not the input — so only known keys persist and each is |
| 777 |
* coerced by its declared type; a select outside its options falls back to the |
| 778 |
* field default. |
| 779 |
* |
| 780 |
* @param mixed $input Raw posted/REST values. |
| 781 |
* @return array Sanitized settings. |
| 782 |
*/ |
| 783 |
function mlsimport_sanitize_standalone_options( $input ): array { |
| 784 |
$input = is_array( $input ) ? $input : array(); |
| 785 |
$out = array(); |
| 786 |
|
| 787 |
foreach ( mlsimport_standalone_field_registry() as $key => $field ) { |
| 788 |
$raw = array_key_exists( $key, $input ) ? $input[ $key ] : $field['default']; |
| 789 |
|
| 790 |
switch ( $field['type'] ) { |
| 791 |
case 'sections': |
| 792 |
$catalog = isset( $field['catalog'] ) && is_callable( $field['catalog'] ) ? array_keys( call_user_func( $field['catalog'] ) ) : null; |
| 793 |
$out[ $key ] = mlsimport_sanitize_sections( $raw, $catalog ); |
| 794 |
break; |
| 795 |
case 'int': |
| 796 |
$out[ $key ] = absint( $raw ); |
| 797 |
break; |
| 798 |
case 'color': |
| 799 |
$color = sanitize_hex_color( is_string( $raw ) ? $raw : '' ); |
| 800 |
$out[ $key ] = is_string( $color ) ? $color : ''; |
| 801 |
break; |
| 802 |
case 'email': |
| 803 |
$out[ $key ] = sanitize_email( is_string( $raw ) ? $raw : '' ); |
| 804 |
break; |
| 805 |
case 'textarea': |
| 806 |
$out[ $key ] = sanitize_textarea_field( is_string( $raw ) ? $raw : '' ); |
| 807 |
break; |
| 808 |
case 'html': |
| 809 |
// Post-grade markup, not plain text: an MLS disclaimer routinely needs a |
| 810 |
// link back to the MLS or a bold line, which sanitize_textarea_field eats. |
| 811 |
$out[ $key ] = wp_kses_post( is_string( $raw ) ? $raw : '' ); |
| 812 |
break; |
| 813 |
case 'select': |
| 814 |
$val = sanitize_text_field( is_string( $raw ) ? $raw : '' ); |
| 815 |
// options is an ordered value => label map; allowed values are its keys. |
| 816 |
$out[ $key ] = array_key_exists( $val, $field['options'] ) ? $val : $field['default']; |
| 817 |
break; |
| 818 |
default: // text | number. |
| 819 |
$out[ $key ] = sanitize_text_field( is_string( $raw ) ? $raw : '' ); |
| 820 |
break; |
| 821 |
} |
| 822 |
} |
| 823 |
|
| 824 |
return $out; |
| 825 |
} |
| 826 |
|
| 827 |
/** |
| 828 |
* The configured MLS logo URL, or '' when none is set. Shown on listing cards |
| 829 |
* (agency row) and the IDX attribution block. |
| 830 |
* |
| 831 |
* @param string $size Image size (default 'medium'). |
| 832 |
* @return string |
| 833 |
*/ |
| 834 |
function mlsimport_standalone_mls_logo_url( string $size = 'medium' ): string { |
| 835 |
$id = (int) mlsimport_standalone_option( 'mls_logo_id', 0 ); |
| 836 |
if ( $id ) { |
| 837 |
$url = wp_get_attachment_image_url( $id, $size ); |
| 838 |
if ( $url ) { |
| 839 |
return (string) $url; |
| 840 |
} |
| 841 |
} |
| 842 |
/** Filter the MLS logo URL (e.g. to use a hard-coded asset). @since 6.4 */ |
| 843 |
return (string) apply_filters( 'mlsimport_mls_logo_url', '' ); |
| 844 |
} |
| 845 |
|
| 846 |
/** |
| 847 |
* Read one standalone setting, falling back to its default. |
| 848 |
* |
| 849 |
* @param string $key Setting key. |
| 850 |
* @param mixed $default Override default (optional). |
| 851 |
* @return mixed |
| 852 |
*/ |
| 853 |
function mlsimport_standalone_option( string $key, $default = null ) { |
| 854 |
$opts = get_option( MLSIMPORT_STANDALONE_OPTION, array() ); |
| 855 |
$defaults = mlsimport_standalone_option_defaults(); |
| 856 |
|
| 857 |
// A stored, non-empty value wins. |
| 858 |
if ( isset( $opts[ $key ] ) && '' !== $opts[ $key ] ) { |
| 859 |
return $opts[ $key ]; |
| 860 |
} |
| 861 |
// Otherwise the caller-supplied override, then the registry default. |
| 862 |
if ( null !== $default ) { |
| 863 |
return $default; |
| 864 |
} |
| 865 |
return isset( $defaults[ $key ] ) ? $defaults[ $key ] : ''; |
| 866 |
} |
| 867 |
|
| 868 |
/** |
| 869 |
* Swap the Leaflet tile source to MapBox when the admin has saved a MapBox API key. |
| 870 |
* |
| 871 |
* Hooked on mlsimport_map_tile_url — the single seam every standalone map surface |
| 872 |
* reads (single-property map, the Map-with-listings block, the Half Map). With no |
| 873 |
* key the OSM default passes straight through; with a key set, tiles come from |
| 874 |
* MapBox's raster Static Tiles API (faster, per the field's own help text). |
| 875 |
* |
| 876 |
* @param string $url The incoming tile URL (the OSM default, unless already filtered). |
| 877 |
* @return string The MapBox tile URL when a key is set, else $url unchanged. |
| 878 |
*/ |
| 879 |
function mlsimport_standalone_map_tile_url( string $url ): string { |
| 880 |
$key = trim( (string) mlsimport_standalone_option( 'mapbox_api_key', '' ) ); |
| 881 |
if ( '' === $key ) { |
| 882 |
return $url; |
| 883 |
} |
| 884 |
// MapBox raster Static Tiles API — Leaflet templates {z}/{x}/{y} directly. |
| 885 |
return 'https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=' . rawurlencode( $key ); |
| 886 |
} |
| 887 |
|
| 888 |
/** |
| 889 |
* The default map zoom (the "Default Maps zoom (1 to 20)" setting), clamped to the |
| 890 |
* range the field promises. Drives the single-property map and the listings map's |
| 891 |
* fallback view. |
| 892 |
* |
| 893 |
* @return int Zoom level in 1..20. |
| 894 |
*/ |
| 895 |
function mlsimport_standalone_map_zoom(): int { |
| 896 |
$zoom = (int) mlsimport_standalone_option( 'map_zoom', 11 ); |
| 897 |
return max( 1, min( 20, $zoom ) ); |
| 898 |
} |
| 899 |
|
| 900 |
/** |
| 901 |
* The map defaults the front-end JS reads: the starting point and default zoom. |
| 902 |
* Localised onto the listings-map script (MLSImportMap). Start lat/lng are floats |
| 903 |
* when set, else '' so the script can tell "configured" from "empty" and only then |
| 904 |
* use the starting point as the fallback view (otherwise the map fits to results). |
| 905 |
* |
| 906 |
* @return array{zoom:int,startLat:(float|string),startLng:(float|string)} |
| 907 |
*/ |
| 908 |
function mlsimport_standalone_map_js_config(): array { |
| 909 |
$lat = mlsimport_standalone_option( 'map_start_lat', '' ); |
| 910 |
$lng = mlsimport_standalone_option( 'map_start_lng', '' ); |
| 911 |
return array( |
| 912 |
'zoom' => mlsimport_standalone_map_zoom(), |
| 913 |
'startLat' => is_numeric( $lat ) ? (float) $lat : '', |
| 914 |
'startLng' => is_numeric( $lng ) ? (float) $lng : '', |
| 915 |
); |
| 916 |
} |
| 917 |
|
| 918 |
/** |
| 919 |
* Whether the viewport map should group nearby pins into clusters at this zoom. |
| 920 |
* |
| 921 |
* Two settings decide it: "Use the Pin Cluster on the maps" (map_pin_cluster) is |
| 922 |
* the master switch, and "Maximum zoom level for cluster to appear" |
| 923 |
* (map_cluster_max_zoom) is the cut-over — clusters appear up to and including that |
| 924 |
* zoom, and individual price pins take over beyond it. Used server-side by the |
| 925 |
* marker payload so the cluster/pin split honours the admin's choice. |
| 926 |
* |
| 927 |
* @param int $zoom The current Leaflet zoom of the request. |
| 928 |
* @return bool True to cluster dense cells; false to always emit individual pins. |
| 929 |
*/ |
| 930 |
function mlsimport_standalone_map_cluster_enabled( int $zoom ): bool { |
| 931 |
if ( 'no' === mlsimport_standalone_option( 'map_pin_cluster', 'yes' ) ) { |
| 932 |
return false; |
| 933 |
} |
| 934 |
return $zoom <= (int) mlsimport_standalone_option( 'map_cluster_max_zoom', 11 ); |
| 935 |
} |
| 936 |
|
| 937 |
/** |
| 938 |
* The configured brand colour as a :root CSS custom-property override, or '' when |
| 939 |
* no colour is set. |
| 940 |
* |
| 941 |
* The stylesheets hard-code their accent tokens (--mlsimport-accent for the |
| 942 |
* property/agent sections, --mli-accent for the listing grids). The "Main Color" |
| 943 |
* design setting (brand_color) is meant to re-theme the whole front end, so this |
| 944 |
* redefines those tokens from the saved colour. The hover/deep-accent variants |
| 945 |
* and the property-section washes/tints derive from the base accent via |
| 946 |
* color-mix, so a single picked colour re-tints everything. |
| 947 |
* |
| 948 |
* The value is re-validated as a hex colour, so the string is safe to emit inline. |
| 949 |
* |
| 950 |
* @return string CSS ':root{…}' rule, or '' when no brand colour is set. |
| 951 |
*/ |
| 952 |
function mlsimport_standalone_brand_color_css(): string { |
| 953 |
$brand = sanitize_hex_color( (string) mlsimport_standalone_option( 'brand_color', '' ) ); |
| 954 |
if ( ! $brand ) { |
| 955 |
return ''; |
| 956 |
} |
| 957 |
|
| 958 |
// Property/agent-section tokens live on :root, so a :root override wins there. |
| 959 |
// The listing-grid accent (--mli-accent) is defined ON .mlsimport-listings / |
| 960 |
// .mlsimport-page-block, not :root — a value set directly on an element beats |
| 961 |
// one inherited from :root regardless of source order, so --mli-accent MUST be |
| 962 |
// overridden on those same selectors (printed after listings.css to win). |
| 963 |
// :root is listed as WELL so the token also reaches listing cards rendered |
| 964 |
// OUTSIDE a grid wrapper — Similar Listings on the single-property page — whose |
| 965 |
// favorite hearts read --mli-accent and would otherwise keep the unbranded |
| 966 |
// default while the same card on a grid page shows the brand colour. The |
| 967 |
// element-level selectors still win inside the grids, so nothing there changes. |
| 968 |
return ':root{' |
| 969 |
. '--mlsimport-accent:' . $brand . ';' |
| 970 |
. '--mlsimport-accent-hover:color-mix(in srgb,' . $brand . ' 85%,#fff);' |
| 971 |
. '--mlsimport-accent2:color-mix(in srgb,' . $brand . ' 80%,#000);' |
| 972 |
. '--mlsimport-accent2-hover:color-mix(in srgb,' . $brand . ' 70%,#000);' |
| 973 |
. '--mlsimport-secondary:' . $brand . ';' |
| 974 |
. '--mlsimport-good:' . $brand . ';' |
| 975 |
. '}' |
| 976 |
. ':root,.mlsimport-listings,.mlsimport-page-block{--mli-accent:' . $brand . ';}'; |
| 977 |
} |
| 978 |
|
| 979 |
/** |
| 980 |
* Attach the brand-colour override to an enqueued stylesheet handle via |
| 981 |
* wp_add_inline_style, so it prints immediately AFTER that stylesheet and wins by |
| 982 |
* source order — the accent stylesheets are enqueued on demand during content |
| 983 |
* rendering (after wp_head), so a plain wp_head <style> would print BEFORE them |
| 984 |
* and lose the cascade. Idempotent per handle so repeated section enqueues don't |
| 985 |
* stack duplicate rules. |
| 986 |
* |
| 987 |
* @param string $handle Enqueued/registered stylesheet handle to append to. |
| 988 |
* @return void |
| 989 |
*/ |
| 990 |
function mlsimport_standalone_attach_brand_color( string $handle ): void { |
| 991 |
static $done = array(); |
| 992 |
if ( isset( $done[ $handle ] ) || ! function_exists( 'wp_add_inline_style' ) ) { |
| 993 |
return; |
| 994 |
} |
| 995 |
$done[ $handle ] = true; |
| 996 |
|
| 997 |
$css = mlsimport_standalone_brand_color_css(); |
| 998 |
if ( '' !== $css ) { |
| 999 |
wp_add_inline_style( $handle, $css ); |
| 1000 |
} |
| 1001 |
} |
| 1002 |
|
| 1003 |
/** |
| 1004 |
* Attach the brand-colour override to the admin edit-screen metabox stylesheet |
| 1005 |
* (mlsimport-property-metabox.css). Its accent tokens live on .mlsimport-mb (not |
| 1006 |
* :root) as a warm mirror of the front-end set, so the override targets that same |
| 1007 |
* selector and is printed after the stylesheet to win. The wash/tint tokens |
| 1008 |
* derive from --mlsmb-accent via color-mix, so overriding the base re-tints the |
| 1009 |
* whole metabox. Idempotent per handle. |
| 1010 |
* |
| 1011 |
* @param string $handle Enqueued metabox stylesheet handle. |
| 1012 |
* @return void |
| 1013 |
*/ |
| 1014 |
function mlsimport_standalone_attach_brand_color_metabox( string $handle ): void { |
| 1015 |
static $done = array(); |
| 1016 |
if ( isset( $done[ $handle ] ) || ! function_exists( 'wp_add_inline_style' ) ) { |
| 1017 |
return; |
| 1018 |
} |
| 1019 |
$done[ $handle ] = true; |
| 1020 |
|
| 1021 |
$brand = sanitize_hex_color( (string) mlsimport_standalone_option( 'brand_color', '' ) ); |
| 1022 |
if ( ! $brand ) { |
| 1023 |
return; |
| 1024 |
} |
| 1025 |
|
| 1026 |
$css = '.mlsimport-mb{' |
| 1027 |
. '--mlsmb-accent:' . $brand . ';' |
| 1028 |
. '--mlsmb-accent-hover:color-mix(in srgb,' . $brand . ' 85%,#fff);' |
| 1029 |
. '--mlsmb-accent2:color-mix(in srgb,' . $brand . ' 80%,#000);' |
| 1030 |
. '--mlsmb-good:' . $brand . ';' |
| 1031 |
. '}'; |
| 1032 |
wp_add_inline_style( $handle, $css ); |
| 1033 |
} |
| 1034 |
|
| 1035 |
/** |
| 1036 |
* The card template filename for the configured card style. Every listing grid |
| 1037 |
* (archive, page blocks, similar listings, agent listings) renders through this |
| 1038 |
* one resolver, so changing the setting restyles all of them. v1 is the default |
| 1039 |
* card (templates/card.php); v2/v3 are alternative designs. |
| 1040 |
* |
| 1041 |
* @return string Template filename (e.g. 'card.php', 'card-v2.php'). |
| 1042 |
*/ |
| 1043 |
function mlsimport_standalone_card_template(): string { |
| 1044 |
$style = (string) mlsimport_standalone_option( 'card_style', 'v1' ); |
| 1045 |
// v1 is the base card.php; other styles map to card-<style>.php (slug sanitized). |
| 1046 |
$name = 'v1' === $style ? 'card.php' : 'card-' . preg_replace( '/[^a-z0-9]/', '', strtolower( $style ) ) . '.php'; |
| 1047 |
/** Filter the resolved card template filename. @since 6.4 */ |
| 1048 |
return (string) apply_filters( 'mlsimport_card_template', $name, $style ); |
| 1049 |
} |
| 1050 |
|