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

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

1,050 lines 51.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 // 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