PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / trunk
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings vtrunk
7.2.2 7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 All 37 releases
mlsimport / includes / standalone / class-mlsimport-standalone-settings.php

class-mlsimport-standalone-settings.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings trunk, at includes/standalone/class-mlsimport-standalone-settings.php

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