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 / property-sections.php

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

2,998 lines 131.1 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) single-property sections.
4 *
5 * Each section is a global template-tag function that reads only from the
6 * Property view model (built once per request by mlsimport_property_data())
7 * and RETURNS an HTML string. One function backs every page builder
8 * (Shortcode, Gutenberg, Elementor) — the builders are thin wrappers, this is
9 * the single source of markup. See docs/adr/0005 and CONTEXT.md
10 * (Property section, Property view model).
11 *
12 * @package Mlsimport
13 */
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 require_once __DIR__ . '/class-mlsimport-standalone-settings.php';
20 require_once __DIR__ . '/class-mlsimport-property-field-sections.php';
21 require_once __DIR__ . '/class-mlsimport-standalone-derive.php';
22
23 /**
24 * Build the normalized view model for one property, memoized per request.
25 *
26 * Scalars come from the mlsimport_listings flat row (the canonical source for
27 * filterable/sortable fields). Sections read from this array only — never the
28 * DB directly — so there is one assembly per property per request.
29 *
30 * @param int $id Property post ID. 0 = current loop post.
31 * @return array View model (empty array when no property resolves).
32 */
33 function mlsimport_property_data( int $id = 0 ): array {
34 // Per-request memo keyed by post ID; one assembly per property per request.
35 static $cache = array();
36
37 /** Short-circuit the view model (live mode serves post-less listings here). @since 6.4 */
38 $pre = apply_filters( 'mlsimport_property_data_pre', null, $id );
39 // A filter that returned an array wins outright — no post/DB lookup happens.
40 if ( is_array( $pre ) ) {
41 return $pre;
42 }
43
44 // Fall back to the current loop post when no explicit ID is given.
45 $id = $id ? $id : (int) get_the_ID();
46 // No resolvable post: nothing to build.
47 if ( ! $id ) {
48 return array();
49 }
50 // Return the memoized view model on a repeat call for the same property.
51 if ( isset( $cache[ $id ] ) ) {
52 return $cache[ $id ];
53 }
54
55 global $wpdb;
56 // The flat search table: one canonical row of filterable/sortable scalars per post.
57 $table = $wpdb->prefix . 'mlsimport_listings';
58 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
59 $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE post_id = %d", $id ) );
60 // The WP post backs title/permalink/body/excerpt (may be null in live mode).
61 $post = get_post( $id );
62
63 // One primed read of all post meta; a closure pulls mlsimport_<key> scalars.
64 $all_meta = get_post_meta( $id );
65 $meta = static function ( $key ) use ( $all_meta ) {
66 return isset( $all_meta[ 'mlsimport_' . $key ][0] ) ? $all_meta[ 'mlsimport_' . $key ][0] : '';
67 };
68
69 // Flat-table column => RESO meta-key fallback. The search row is canonical for
70 // filtering/sorting, but listings imported before the row was populated keep
71 // their values only in post meta — so each scalar falls back to meta when the
72 // row value is absent (one rule that keeps display working whether or not the
73 // search row has been (re)built).
74 $col_meta = array(
75 'price' => 'ListPrice',
76 'bedrooms' => 'BedroomsTotal',
77 'bathrooms' => 'BathroomsTotalDecimal',
78 'living_area' => 'LivingArea',
79 'year_built' => 'YearBuilt',
80 'days_on_market' => 'DaysOnMarket',
81 'latitude' => 'Latitude',
82 'longitude' => 'Longitude',
83 'city' => 'City',
84 'state' => 'StateOrProvince',
85 'zip' => 'PostalCode',
86 'subdivision' => 'SubdivisionName',
87 'property_type' => 'PropertyType',
88 );
89
90 // Numeric scalar: flat row first, then the mapped meta key, else null.
91 $num = static function ( $col ) use ( $row, $meta, $col_meta ) {
92 if ( $row && isset( $row->$col ) && null !== $row->$col && '' !== $row->$col ) {
93 return (float) $row->$col;
94 }
95 if ( isset( $col_meta[ $col ] ) ) {
96 $m = $meta( $col_meta[ $col ] );
97 if ( '' !== $m ) {
98 return (float) $m;
99 }
100 }
101 return null;
102 };
103 // String scalar: flat row first, then the mapped meta key, else ''.
104 $str = static function ( $col ) use ( $row, $meta, $col_meta ) {
105 if ( $row && isset( $row->$col ) && '' !== (string) $row->$col ) {
106 return (string) $row->$col;
107 }
108 return isset( $col_meta[ $col ] ) ? (string) $meta( $col_meta[ $col ] ) : '';
109 };
110
111 // Assemble the normalized view model — the only shape any section reads.
112 $vm = array(
113 'id' => $id,
114 'title' => $post ? get_the_title( $id ) : '',
115 'permalink' => (string) get_permalink( $id ),
116 'content' => $post ? (string) $post->post_content : '',
117 'excerpt' => $post ? (string) $post->post_excerpt : '',
118
119 // Price + variants.
120 'price' => $num( 'price' ),
121 'price_per_sqft' => ( null !== $num( 'price' ) && $num( 'living_area' ) ) ? (int) round( $num( 'price' ) / $num( 'living_area' ) ) : null,
122 'original_price' => '' !== $meta( 'OriginalListPrice' ) ? (float) $meta( 'OriginalListPrice' ) : null,
123 'close_price' => '' !== $meta( 'ClosePrice' ) ? (float) $meta( 'ClosePrice' ) : null,
124 'previous_price' => '' !== $meta( 'PreviousListPrice' ) ? (float) $meta( 'PreviousListPrice' ) : null,
125 'hoa_fee' => $num( 'hoa_fee' ),
126 'hoa_frequency' => (string) $meta( 'AssociationFeeFrequency' ),
127
128 // Structure / facts.
129 'bedrooms' => $num( 'bedrooms' ),
130 'bathrooms' => $num( 'bathrooms' ),
131 'living_area' => $num( 'living_area' ),
132 'lot_size' => $num( 'lot_size' ),
133 'year_built' => null !== $num( 'year_built' ) ? (int) $num( 'year_built' ) : null,
134 'garage' => null !== $num( 'garage_spaces' ) ? (int) $num( 'garage_spaces' ) : null,
135 'stories' => null !== $num( 'stories' ) ? (int) $num( 'stories' ) : null,
136 'days_on_market' => null !== $num( 'days_on_market' ) ? (int) $num( 'days_on_market' ) : null,
137
138 // Location.
139 'street' => mlsimport_property_street_line( $meta ),
140 'city' => $str( 'city' ),
141 'state' => $str( 'state' ),
142 'zip' => $str( 'zip' ),
143 'subdivision' => $str( 'subdivision' ),
144 'county' => (string) $meta( 'CountyOrParish' ),
145 'country' => 'US' === (string) $meta( 'Country' ) ? __( 'United States', 'mlsimport' ) : (string) $meta( 'Country' ),
146 'latitude' => $num( 'latitude' ),
147 'longitude' => $num( 'longitude' ),
148 'address' => mlsimport_property_build_address( $meta, $str ),
149
150 // Type / status.
151 'property_type' => $str( 'property_type' ),
152 'property_sub_type' => (string) $meta( 'PropertySubType' ),
153 'listing_type' => $str( 'listing_type' ),
154 'status' => '' !== $str( 'status' ) ? $str( 'status' ) : (string) $meta( 'MlsStatus' ),
155
156 // Provenance / freshness (display-only).
157 'mls_id' => '' !== (string) $meta( 'ListingId' ) ? (string) $meta( 'ListingId' ) : (string) $meta( 'ListingKey' ),
158 'updated' => mlsimport_property_format_date( (string) $meta( 'ModificationTimestamp' ) ),
159 // Raw (unformatted) listing date for machine consumers such as JSON-LD
160 // datePosted. The ListingContractDate/OnMarketDate preference lives in
161 // Mlsimport_Standalone_Derive so there is one rule, not two.
162 'list_date' => (string) Mlsimport_Standalone_Derive::derive_list_date(
163 array(
164 'ListingContractDate' => $meta( 'ListingContractDate' ),
165 'OnMarketDate' => $meta( 'OnMarketDate' ),
166 )
167 ),
168
169 // Media.
170 'thumbnail_id' => (int) get_post_thumbnail_id( $id ),
171 'image_url' => (string) ( get_post_thumbnail_id( $id ) ? wp_get_attachment_image_url( get_post_thumbnail_id( $id ), 'large' ) : '' ),
172 'gallery_ids' => mlsimport_property_gallery_ids( $id ),
173 'virtual_tour' => (string) $meta( 'virtual_tour' ),
174 'video_url' => (string) $meta( 'VideoURL' ),
175
176 // Features (amenity terms).
177 'features' => mlsimport_property_feature_names( $id ),
178
179 // Resolved agent (linked post preferred, property meta fallback).
180 'agent' => mlsimport_property_agent( $id, $meta ),
181 );
182
183 /** Filter the property view model — the single value every section reads. @since 6.3 */
184 $vm = (array) apply_filters( 'mlsimport_property_data', $vm, $id );
185
186 // Memoize and hand back the assembled model.
187 $cache[ $id ] = $vm;
188 return $vm;
189 }
190
191 /**
192 * Assemble a one-line street address from RESO parts (UnparsedAddress wins).
193 *
194 * @param callable $meta Meta reader: ( string $key ) => string.
195 * @param callable $str Row string reader: ( string $col ) => string.
196 * @return string
197 */
198 function mlsimport_property_build_address( callable $meta, callable $str ): string {
199 // RESO's pre-composed UnparsedAddress wins outright when the feed carries it.
200 $unparsed = trim( (string) $meta( 'UnparsedAddress' ) );
201 if ( '' !== $unparsed ) {
202 return $unparsed;
203 }
204
205 // Otherwise stitch street + city + state + zip, dropping any empty part.
206 $tail = array_filter( array( mlsimport_property_street_line( $meta ), $str( 'city' ), $str( 'state' ), $str( 'zip' ) ), 'strlen' );
207 return implode( ', ', $tail );
208 }
209
210 /**
211 * The street line ("123 Main St #4B") from RESO street parts. Shared by the
212 * one-line address builder and the Address section's field grid.
213 *
214 * @param callable $meta Meta reader: ( string $key ) => string.
215 * @return string
216 */
217 function mlsimport_property_street_line( callable $meta ): string {
218 // Base line is number + name ("123 Main St").
219 $street = trim( $meta( 'StreetNumber' ) . ' ' . $meta( 'StreetName' ) );
220 // Append the unit as "#4B" only when the feed supplies one.
221 $unit = trim( (string) $meta( 'UnitNumber' ) );
222 if ( '' !== $unit ) {
223 $street = trim( $street . ' #' . $unit );
224 }
225 return $street;
226 }
227
228 /**
229 * Gallery attachment IDs for a property (mlsimport_gallery meta), capped by the
230 * editable Photos Count field.
231 *
232 * Photos Count (mlsimport_PhotosCount) arrives from the MLS as the feed's own photo
233 * count, but the editor may lower it to publish fewer images. It caps every gallery
234 * surface — metabox tiles, single-property gallery/slider, print — because this is
235 * the one function they all read. An empty or zero count means "no cap"; the stored
236 * attachments are never modified.
237 *
238 * @param int $id Property post ID.
239 * @return int[]
240 */
241 function mlsimport_property_gallery_ids( int $id ): array {
242 // The gallery meta stores an ordered array of attachment IDs.
243 $ids = get_post_meta( $id, 'mlsimport_gallery', true );
244 // Nothing usable when the meta is absent or not an array.
245 if ( ! is_array( $ids ) ) {
246 return array();
247 }
248 // Cast to ints, drop zeros/empties, and re-index.
249 $ids = array_values( array_filter( array_map( 'intval', $ids ) ) );
250
251 // A positive Photos Count trims the list; blank/0/negative leaves it whole.
252 $limit = (int) get_post_meta( $id, 'mlsimport_PhotosCount', true );
253 if ( $limit > 0 && count( $ids ) > $limit ) {
254 $ids = array_slice( $ids, 0, $limit );
255 }
256
257 return $ids;
258 }
259
260 /**
261 * Amenity feature term names for a property.
262 *
263 * @param int $id Property post ID.
264 * @return string[]
265 */
266 function mlsimport_property_feature_names( int $id ): array {
267 // Amenities live in the mlsimport_feature taxonomy.
268 $terms = get_the_terms( $id, 'mlsimport_feature' );
269 // No terms (or a WP_Error): no features.
270 if ( ! is_array( $terms ) ) {
271 return array();
272 }
273
274 // One chip per term — the importer writes one term per value (#290), so
275 // names arrive individual. De-dupe and re-index so each appears once.
276 return array_values( array_unique( array_filter( wp_list_pluck( $terms, 'name' ) ) ) );
277 }
278
279 /**
280 * Resolve the listing agent: linked mlsimport_agent post meta preferred, with a
281 * fallback to the property's own ListAgent* meta.
282 *
283 * @param int $id Property post ID.
284 * @param callable $meta Property meta reader.
285 * @return array|null { id, name, email, phone, office, feed_name, feed_office, … } or null when unknown.
286 */
287 function mlsimport_property_agent( int $id, callable $meta ): ?array {
288 // The agent post the import task linked (0 when none was picked).
289 $agent_id = (int) $meta( 'list_agent_id' );
290 // Whether the task opted to attribute the MLS feed's own listing agent instead.
291 $use_mls = (bool) intval( $meta( 'use_mls_agent' ) );
292
293 // The agent picked in the import task wins, unless that task opted to use the
294 // MLS feed's own listing agent (mlsimport_use_mls_agent). In feed mode the
295 // linked agent post is ignored entirely; otherwise it is the only source and
296 // the property's own ListAgent* feed meta is not consulted.
297 $use_selected = $agent_id > 0 && ! $use_mls;
298 $post_id = $use_selected ? $agent_id : 0;
299
300 // Reader that pulls each agent field from the linked post (selected mode) or
301 // from the property's own feed meta (MLS-agent mode).
302 $ameta = static function ( $key ) use ( $use_selected, $agent_id, $meta ) {
303 if ( $use_selected ) {
304 return (string) get_post_meta( $agent_id, 'mlsimport_' . $key, true );
305 }
306 return (string) $meta( $key );
307 };
308
309 // Core contact fields, resolved through the mode-aware reader.
310 $name = $ameta( 'ListAgentFullName' );
311 $email = $ameta( 'ListAgentEmail' );
312 $phone = $ameta( 'ListAgentPreferredPhone' );
313 $office = $ameta( 'ListOfficeName' );
314
315 // A linked agent post's title is its display name when no name meta is set.
316 if ( '' === $name && $post_id ) {
317 $name = (string) get_the_title( $post_id );
318 }
319
320 // No name, email or phone means there is no agent worth rendering.
321 if ( '' === $name && '' === $email && '' === $phone ) {
322 return null;
323 }
324
325 // A feed-sourced agent (no local agent post) may not have their personal
326 // contact channels displayed or used — MLS display rules (#181). The company
327 // contacts from the Social & Contact settings take their place.
328 $is_feed = ! $use_selected;
329 if ( $is_feed ) {
330 $email = (string) mlsimport_standalone_option( 'lead_recipient', '' );
331 $phone = (string) mlsimport_standalone_option( 'company_phone', '' );
332 }
333
334 // A linked agent post's body doubles as the bio when no explicit bio meta exists.
335 $bio = $ameta( 'ListAgentBio' );
336 if ( '' === $bio && $post_id ) {
337 $bio = (string) get_post_field( 'post_content', $post_id );
338 }
339
340 // Resolved agent shape consumed by the agent card, booking rail and attribution.
341 return array(
342 'id' => $post_id,
343 'is_feed' => $is_feed,
344 'name' => $name,
345 'email' => $email,
346 'phone' => $phone,
347 'office_phone' => $ameta( 'ListOfficePhone' ),
348 'office' => $office,
349 // The property's own feed values, untouched by the manual-agent override —
350 // the MLS attribution must always name the FEED listing agent/office (#169).
351 'feed_name' => (string) $meta( 'ListAgentFullName' ),
352 'feed_office' => (string) $meta( 'ListOfficeName' ),
353 // RESO ListAgentPreferredPhone: the phone the listing agent asks to be
354 // reached on. Printed after the agent name in the attribution line.
355 'feed_phone' => (string) $meta( 'ListAgentPreferredPhone' ),
356 'license' => $ameta( 'ListAgentStateLicense' ),
357 'agent_mls_id' => $ameta( 'ListAgentMlsId' ),
358 'office_mls_id' => $ameta( 'ListOfficeMlsId' ),
359 'bio' => trim( wp_strip_all_tags( $bio ) ),
360 'photo_id' => $post_id ? (int) get_post_thumbnail_id( $post_id ) : 0,
361 );
362 }
363
364 /**
365 * Format an ISO/MySQL timestamp to the site's date format plus hour:minute. '' when empty.
366 *
367 * Pure-ish (uses WP date settings); DB-free so the view model stays cheap.
368 * RESO ModificationTimestamp is UTC, so wp_date() converts it to the site's
369 * timezone before printing the hour (e.g. "November 4, 2025 1:45 pm").
370 *
371 * @param string $ts Timestamp string (e.g. RESO ModificationTimestamp).
372 * @return string
373 */
374 function mlsimport_property_format_date( string $ts ): string {
375 // Empty in, empty out.
376 $ts = trim( $ts );
377 if ( '' === $ts ) {
378 return '';
379 }
380 // Parse the timestamp to epoch seconds.
381 $time = strtotime( $ts );
382 if ( false === $time ) {
383 // Already a human display string (e.g. "June 5, 2026 at 02:10pm") — keep it.
384 return $ts;
385 }
386 // Site's date format, then hour and minute only (no seconds, whatever the site time format).
387 $format = ( function_exists( 'get_option' ) ? (string) get_option( 'date_format', 'F j, Y' ) : 'F j, Y' ) . ' g:i a';
388 // Localized, site-timezone date when available; plain UTC gmdate() as the DB-free fallback.
389 return function_exists( 'wp_date' ) ? (string) wp_date( $format, $time ) : gmdate( $format, $time );
390 }
391
392 /**
393 * Open a section: the single source of the section container + title markup.
394 *
395 * Emits a stable anchor id (mlsimport-section-<slug>) so the in-page sub-nav can
396 * jump to it, and an optional icon chip beside the title to match the design.
397 *
398 * @param string $slug Section slug (e.g. 'price'); used in the BEM class.
399 * @param string $title Optional heading.
400 * @param string $icon Optional icon name for mlsimport_property_icon().
401 * @return string
402 */
403 function mlsimport_property_section_open( string $slug, string $title = '', string $icon = '' ): string {
404 // Anchor id uses the first space-delimited token of the slug (drops modifiers).
405 $anchor = sanitize_html_class( 'mlsimport-section-' . strtok( $slug, ' ' ) );
406 // Open the section wrapper carrying the anchor and the slug-derived BEM class.
407 $html = '<section id="' . esc_attr( $anchor ) . '" class="mlsimport-property-section mlsimport-property-' . esc_attr( $slug ) . '">';
408 // Header (icon chip + heading) is emitted only when a title was passed.
409 if ( '' !== $title ) {
410 $html .= '<div class="mlsimport-property-section__header">';
411 // Optional leading icon chip.
412 if ( '' !== $icon ) {
413 $html .= '<span class="mlsimport-property-section__icon" aria-hidden="true">' . mlsimport_property_icon( $icon ) . '</span>';
414 }
415 $html .= '<h2 class="mlsimport-property-section__title">' . esc_html( $title ) . '</h2>';
416 $html .= '</div>';
417 }
418 // Open the body wrapper; the caller appends content, then section_close() shuts both.
419 $html .= '<div class="mlsimport-property-section__body">';
420 return $html;
421 }
422
423 /**
424 * Return an inline stroke SVG for a named icon, or '' for an unknown name.
425 *
426 * Self-contained (no icon-font dependency) so every section/block renders the
427 * same glyph wherever it is placed. currentColor is used so CSS theme tokens
428 * drive the colour. The SVG inherits sizing from .mlsimport-property-icon CSS.
429 *
430 * @param string $name Icon name.
431 * @return string
432 */
433 function mlsimport_property_icon( string $name ): string {
434 // name => inner SVG path/shape markup for a 24×24 stroke icon.
435 $paths = array(
436 'info' => '<circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/>',
437 'text' => '<path d="M4 6h16M4 12h16M4 18h10"/>',
438 'cube' => '<path d="M12 2 3 7v10l9 5 9-5V7zM3 7l9 5 9-5M12 12v10"/>',
439 'pin' => '<path d="M12 21s-7-6.3-7-11a7 7 0 0 1 14 0c0 4.7-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/>',
440 'list' => '<path d="M8 6h12M8 12h12M8 18h12M3.5 6h.01M3.5 12h.01M3.5 18h.01"/>',
441 'grid' => '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
442 'video' => '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3z"/>',
443 'calc' => '<rect x="5" y="3" width="14" height="18" rx="2"/><path d="M8 7h8M8 11h.01M12 11h.01M16 11h.01M8 15h.01M12 15h.01M16 15v4M8 19h4"/>',
444 'user' => '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
445 'bed' => '<path d="M3 7v12M3 13h18a0 0 0 0 1 0 0v6M21 19v-6a4 4 0 0 0-4-4H8M3 9a2 2 0 0 1 2-2"/>',
446 'bath' => '<path d="M4 12h16v3a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4zM6 12V6a2 2 0 0 1 2-2 2 2 0 0 1 2 2"/>',
447 'ruler' => '<path d="m3 17 4 4L21 7l-4-4zM7.5 12.5l2 2M11 9l2 2M14.5 5.5l2 2"/>',
448 'car' => '<path d="M5 17h14M3 17v-4l2-5a2 2 0 0 1 1.9-1.4h10.2A2 2 0 0 1 19 8l2 5v4M3 13h18"/><circle cx="7.5" cy="17" r="1.5"/><circle cx="16.5" cy="17" r="1.5"/>',
449 'calendar' => '<rect x="3" y="4" width="18" height="17" rx="2"/><path d="M3 9h18M8 2v4M16 2v4"/>',
450 'building' => '<rect x="4" y="3" width="16" height="18" rx="1"/><path d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M10 21v-4h4v4"/>',
451 'hash' => '<path d="M5 9h14M5 15h14M10 4 8 20M16 4l-2 16"/>',
452 'badge' => '<path d="M12 2 4 5v6c0 5 3.5 8 8 11 4.5-3 8-6 8-11V5z"/><path d="m9 12 2 2 4-4"/>',
453 'check' => '<path d="m5 12 5 5 9-10"/>',
454 'phone' => '<path d="M5 4h4l2 5-3 2a12 12 0 0 0 5 5l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2z"/>',
455 'mail' => '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
456 'whatsapp' => '<path d="M21 11.5a8.5 8.5 0 0 1-12.6 7.4L3 20.5l1.7-5.2A8.5 8.5 0 1 1 21 11.5z"/><path d="M8.8 8.4c.2-.5.4-.5.6-.5h.5c.2 0 .4 0 .6.5l.7 1.6c.1.3 0 .5-.1.7l-.4.5c-.1.2-.2.3 0 .6a6 6 0 0 0 2.7 2.3c.3.1.4 0 .6-.1l.5-.6c.2-.2.4-.2.6-.1l1.6.8c.2.1.4.2.4.4v.6c-.1.5-.6 1-1.1 1.2-.4.1-1 .2-2.7-.5a9.3 9.3 0 0 1-4.4-4c-.5-.9-.7-1.7-.7-2.3 0-.4.2-.9.6-1.1z"/>',
457 'globe' => '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>',
458 'message' => '<path d="M21 12a8 8 0 0 1-11.4 7.2L3 21l1.8-6.6A8 8 0 1 1 21 12z"/>',
459 'share' => '<circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="m8.2 10.8 7.6-3.6M8.2 13.2l7.6 3.6"/>',
460 'heart' => '<path d="M12 20s-7-4.6-9.3-9A4.7 4.7 0 0 1 12 6a4.7 4.7 0 0 1 9.3 5c-2.3 4.4-9.3 9-9.3 9z"/>',
461 'print' => '<path d="M7 8V3h10v5M7 18H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2M7 14h10v7H7z"/>',
462 'clock' => '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
463 'arrow-down' => '<path d="M12 5v14M5 12l7 7 7-7"/>',
464 'chevron-left' => '<path d="M15 18l-6-6 6-6"/>',
465 'chevron-right' => '<path d="M9 18l6-6-6-6"/>',
466 'tour' => '<rect x="3" y="6" width="18" height="13" rx="2"/><path d="m9 10 5 3-5 3z"/>',
467 );
468 // Unknown icon name renders nothing rather than a broken glyph.
469 if ( ! isset( $paths[ $name ] ) ) {
470 return '';
471 }
472 // Wrap the chosen shape in the shared SVG chrome (currentColor lets CSS tint it).
473 return '<svg class="mlsimport-property-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' . $paths[ $name ] . '</svg>';
474 }
475
476 /**
477 * Close a section opened with mlsimport_property_section_open().
478 *
479 * @return string
480 */
481 function mlsimport_property_section_close(): string {
482 return '</div></section>';
483 }
484
485 /**
486 * Format a numeric price as a US currency string (no decimals).
487 *
488 * Pure + DB-free so it can be unit-tested in isolation.
489 *
490 * @param float|int|string|null $value Raw price.
491 * @return string Formatted price, or '' when there is no usable value.
492 */
493 function mlsimport_format_price( $value ): string {
494 // No value → no price string (an empty tile/row is dropped upstream).
495 if ( null === $value || '' === $value ) {
496 return '';
497 }
498 /** Filter the formatted price string. @since 6.3 */
499 // Thousands-separated dollars with no decimals ("$ 1,250,000").
500 return (string) apply_filters( 'mlsimport_format_price', '$ ' . number_format( (float) $value ), $value );
501 }
502
503 /**
504 * Format a count/measure: whole numbers plain, fractions to one decimal
505 * (so 3 beds reads "3", 2.5 baths reads "2.5"). Pure + DB-free.
506 *
507 * @param float|int|string|null $value Raw amount.
508 * @return string
509 */
510 function mlsimport_format_amount( $value ): string {
511 // No value → empty string.
512 if ( null === $value || '' === $value ) {
513 return '';
514 }
515 $f = (float) $value;
516 // Whole numbers print plain; anything with a fraction prints to one decimal.
517 return ( $f === (float) (int) $f ) ? number_format( $f ) : number_format( $f, 1 );
518 }
519
520 /**
521 * Price section — the listing's list price.
522 *
523 * @param int $id Property post ID (0 = current loop post).
524 * @param array $args Reserved for behavioral options.
525 * @return string HTML, or '' when the property has no price.
526 */
527 function mlsimport_property_price( int $id = 0, array $args = array() ): string {
528 // Load the view model and bail when there is no price to show.
529 $data = mlsimport_property_data( $id );
530 if ( empty( $data ) || null === $data['price'] ) {
531 return '';
532 }
533
534 // Section wrapper (no heading) + the formatted price line.
535 $html = mlsimport_property_section_open( 'price' );
536 $html .= '<p class="mlsimport-property-price__amount">' . esc_html( mlsimport_format_price( $data['price'] ) ) . '</p>';
537 $html .= mlsimport_property_section_close();
538 return $html;
539 }
540
541 /**
542 * Title section — the listing title as a heading.
543 *
544 * @param int $id Property post ID.
545 * @param array $args Behavioral options.
546 * @return string
547 */
548 function mlsimport_property_title( int $id = 0, array $args = array() ): string {
549 // No title, no section.
550 $data = mlsimport_property_data( $id );
551 if ( empty( $data ) || '' === $data['title'] ) {
552 return '';
553 }
554 // Wrapper + the title as an <h1>.
555 $html = mlsimport_property_section_open( 'title' );
556 $html .= '<h1 class="mlsimport-property-title__heading">' . esc_html( $data['title'] ) . '</h1>';
557 $html .= mlsimport_property_section_close();
558 return $html;
559 }
560
561 /**
562 * Status section — the listing status as a badge.
563 *
564 * @param int $id Property post ID.
565 * @param array $args Behavioral options.
566 * @return string
567 */
568 function mlsimport_property_status( int $id = 0, array $args = array() ): string {
569 // No status, no section.
570 $data = mlsimport_property_data( $id );
571 if ( empty( $data ) || '' === $data['status'] ) {
572 return '';
573 }
574 // Wrapper + the status as a badge.
575 $html = mlsimport_property_section_open( 'status' );
576 $html .= '<span class="mlsimport-property-status__badge">' . esc_html( $data['status'] ) . '</span>';
577 $html .= mlsimport_property_section_close();
578 return $html;
579 }
580
581 /**
582 * Address section — the one-line street address.
583 *
584 * @param int $id Property post ID.
585 * @param array $args Behavioral options.
586 * @return string
587 */
588 function mlsimport_property_address( int $id = 0, array $args = array() ): string {
589 // No address, no section.
590 $data = mlsimport_property_data( $id );
591 if ( empty( $data ) || '' === $data['address'] ) {
592 return '';
593 }
594 // Wrapper + the one-line address.
595 $html = mlsimport_property_section_open( 'address' );
596 $html .= '<p class="mlsimport-property-address__line">' . esc_html( $data['address'] ) . '</p>';
597 $html .= mlsimport_property_section_close();
598 return $html;
599 }
600
601 /**
602 * The tiles the Overview section knows how to draw: slug => [ icon, label ]. This
603 * is the catalog behind the Overview "Arrange Fields" control in the design
604 * settings — which tiles show, and in what order, is the saved arrangement of
605 * these slugs. Values are resolved per property in mlsimport_property_overview_value().
606 *
607 * @return array<string,array{0:string,1:string}>
608 */
609 function mlsimport_property_overview_fields(): array {
610 return array(
611 'updated' => array( 'calendar', __( 'Updated', 'mlsimport' ) ),
612 'sub_type' => array( 'building', __( 'Sub type', 'mlsimport' ) ),
613 'mls_id' => array( 'hash', __( 'MLS #', 'mlsimport' ) ),
614 'bedrooms' => array( 'bed', __( 'Bedrooms', 'mlsimport' ) ),
615 'bathrooms' => array( 'bath', __( 'Bathrooms', 'mlsimport' ) ),
616 'size' => array( 'ruler', __( 'Size', 'mlsimport' ) ),
617 'year_built' => array( 'clock', __( 'Year Built', 'mlsimport' ) ),
618 'garage' => array( 'car', __( 'Garage', 'mlsimport' ) ),
619 );
620 }
621
622 /**
623 * One overview tile's display value for a property, or '' when it has none (an
624 * empty tile is skipped, so the grid never shows a blank cell).
625 *
626 * @param string $slug Overview field slug.
627 * @param array $data Property view model.
628 * @return string
629 */
630 function mlsimport_property_overview_value( string $slug, array $data ): string {
631 // Map each overview slug to its display value from the view model.
632 switch ( $slug ) {
633 case 'updated':
634 // Last-modified date, already formatted.
635 return (string) $data['updated'];
636 case 'sub_type':
637 // RESO PropertySubType.
638 return (string) $data['property_sub_type'];
639 case 'mls_id':
640 // Listing's MLS number.
641 return (string) $data['mls_id'];
642 case 'bedrooms':
643 // Bed count (null → no tile).
644 return null !== $data['bedrooms'] ? mlsimport_format_amount( $data['bedrooms'] ) : '';
645 case 'bathrooms':
646 // Bath count (fractions allowed, e.g. 2.5).
647 return null !== $data['bathrooms'] ? mlsimport_format_amount( $data['bathrooms'] ) : '';
648 case 'size':
649 // Living area with a ft² suffix.
650 return null !== $data['living_area'] ? mlsimport_format_amount( $data['living_area'] ) . ' ' . __( 'ft²', 'mlsimport' ) : '';
651 case 'year_built':
652 // A year is never thousands-separated, so it bypasses mlsimport_format_amount().
653 return null !== $data['year_built'] ? (string) $data['year_built'] : '';
654 case 'garage':
655 // Garage spaces (RESO GarageSpaces); 0 spaces is "no garage" — no tile.
656 return ! empty( $data['garage'] ) ? (string) $data['garage'] : '';
657 }
658 // Unknown slug carries no value.
659 return '';
660 }
661
662 /**
663 * Overview section — the headline stat grid (updated · sub type · MLS # · beds ·
664 * baths · size). Which tiles appear and their order come from the Overview
665 * "Arrange Fields" design setting; only tiles with a value render.
666 *
667 * @param int $id Property post ID.
668 * @param array $args Behavioral options.
669 * @return string
670 */
671 function mlsimport_property_overview( int $id = 0, array $args = array() ): string {
672 $data = mlsimport_property_data( $id );
673 if ( empty( $data ) ) {
674 return '';
675 }
676
677 // Tile catalog (slug => [icon, label]); the saved arrangement drives order.
678 $fields = mlsimport_property_overview_fields();
679 $cells = '';
680 // Walk the user's chosen tile order.
681 foreach ( mlsimport_standalone_active_overview_fields() as $slug ) {
682 // Skip a saved slug the catalog no longer knows.
683 if ( ! isset( $fields[ $slug ] ) ) {
684 continue;
685 }
686 // Resolve this tile's value; an empty value means no tile.
687 $value = mlsimport_property_overview_value( $slug, $data );
688 if ( '' === $value ) {
689 continue;
690 }
691 // Icon + label + value tile.
692 $cells .= '<div class="mlsimport-property-overview__tile">'
693 . '<span class="mlsimport-property-overview__tile-icon" aria-hidden="true">' . mlsimport_property_icon( $fields[ $slug ][0] ) . '</span>'
694 . '<span class="mlsimport-property-overview__tile-label">' . esc_html( $fields[ $slug ][1] ) . '</span>'
695 . '<span class="mlsimport-property-overview__tile-value">' . esc_html( $value ) . '</span>'
696 . '</div>';
697 }
698 // No populated tiles → skip the whole section.
699 if ( '' === $cells ) {
700 return '';
701 }
702
703 // Titled "Overview" section wrapping the tile grid.
704 $html = mlsimport_property_section_open( 'overview', __( 'Overview', 'mlsimport' ), 'info' );
705 $html .= '<div class="mlsimport-property-overview__grid">' . $cells . '</div>';
706 $html .= mlsimport_property_section_close();
707 return $html;
708 }
709
710 /**
711 * The facts grid as a markup string — shared by details, tabs and accordion.
712 *
713 * @param array $facts [label, value] pairs from mlsimport_property_facts().
714 * @return string
715 */
716 function mlsimport_property_facts_grid_html( array $facts, string $modifier = '', int $id = 0 ): string {
717 // Nothing to render when there are no fact rows.
718 if ( empty( $facts ) ) {
719 return '';
720 }
721 // Grid <ul>, plus the optional column-count/address modifier class.
722 $html = '<ul class="mlsimport-property-details__grid' . ( '' !== $modifier ? ' ' . esc_attr( $modifier ) : '' ) . '">';
723 // One label/value <li> per fact; a value naming one of this listing's terms links to it.
724 foreach ( $facts as $fact ) {
725 $html .= '<li class="mlsimport-property-details__item">'
726 . '<span class="mlsimport-property-details__label">' . esc_html( $fact[0] ) . '</span>'
727 . '<span class="mlsimport-property-details__value">' . mlsimport_property_link_term( $id, (string) $fact[1] ) . '</span>'
728 . '</li>';
729 }
730 $html .= '</ul>';
731 return $html;
732 }
733
734 /**
735 * The features chip list as a markup string — shared by features, tabs, accordion.
736 *
737 * @param string[] $names Feature term names.
738 * @return string
739 */
740 function mlsimport_property_features_list_html( array $names, int $id = 0 ): string {
741 // No amenity names → no chip list.
742 if ( empty( $names ) ) {
743 return '';
744 }
745 // A shared check glyph precedes every chip.
746 $check = '<span class="mlsimport-property-features__check" aria-hidden="true">' . mlsimport_property_icon( 'check' ) . '</span>';
747 // Chip list carries the page-wide column-count class.
748 $html = '<ul class="mlsimport-property-features__list ' . esc_attr( mlsimport_property_columns_class() ) . '">';
749 // One chip per amenity, linked to its feature archive.
750 foreach ( $names as $name ) {
751 $html .= '<li class="mlsimport-property-features__item">' . $check . '<span>' . mlsimport_property_link_term( $id, (string) $name ) . '</span></li>';
752 }
753 $html .= '</ul>';
754 return $html;
755 }
756
757 /**
758 * The panes behind the Tabs and Accordion containers: each configured section,
759 * rendered through the one dispatcher every builder already uses.
760 *
761 * A container accepts ANY registered section — so Map can sit as a tab next to
762 * Interior. A section that renders nothing is dropped rather than offered as a
763 * dead tab, the same "no data, no section" rule the sections themselves obey.
764 *
765 * Without a 'sections' list (the property template, the shortcode, the block and
766 * the Elementor widget all dispatch a section with only the post id) the
767 * container holds the nine field sections followed by Features — the "Details"
768 * a tabbed or accordion layout is expected to group (#311). Whatever the list,
769 * the two containers themselves are never panes, so a container cannot nest
770 * itself.
771 *
772 * The pane carries the heading, so the section inside it is asked to omit its own.
773 *
774 * @param int $id Property post ID.
775 * @param array $args Behavioral options; 'sections' is an ordered list of slugs.
776 * @return array<string,array{0:string,1:string}> slug => [ title, html ].
777 */
778 function mlsimport_property_container_panes( int $id, array $args ): array {
779 // Step 1: the ordered slug list the container was told to hold, or the default
780 // "Details" set when the caller passed none.
781 $slugs = ! empty( $args['sections'] )
782 ? (array) $args['sections']
783 : array_merge( array_keys( mlsimport_property_field_section_titles() ), array( 'features' ) );
784 // Step 2: a container never holds a container (no recursion).
785 $slugs = array_diff( $slugs, array( 'tabs', 'accordion' ) );
786
787 // The section registry maps each slug to its render fn + label.
788 $registry = mlsimport_get_property_sections();
789 $panes = array();
790
791 foreach ( $slugs as $slug ) {
792 $slug = (string) $slug;
793 // Skip a slug that isn't a registered section.
794 if ( ! isset( $registry[ $slug ] ) ) {
795 continue;
796 }
797
798 // Render through the shared dispatcher, asking the section to omit its heading.
799 $html = mlsimport_render_property_section( $slug, $id, array( 'hide_title' => true ) );
800 // A section that produced nothing is dropped, never offered as a dead tab.
801 if ( '' === trim( $html ) ) {
802 continue;
803 }
804
805 // Pane = [ registry label, rendered html ].
806 $panes[ $slug ] = array( (string) $registry[ $slug ]['label'], $html );
807 }
808
809 return $panes;
810 }
811
812 /**
813 * The nine field sections — Interior, Exterior, Structure, Utilities, Financial,
814 * Schools, Location, Listing Info, Other Details.
815 *
816 * One render fn backs all nine; the registry bakes the slug into each. The rows
817 * come from mlsimport_property_section_fields(), which owns the one rule that
818 * governs every section: a field shows when it is ticked for import, not marked
819 * admin-only, and has a value.
820 *
821 * A section with no populated field renders '' — never a bare heading.
822 *
823 * @param int $id Property post ID (0 = current loop post).
824 * @param array $args Behavioral options; 'section' is the section slug.
825 * @return string
826 */
827 function mlsimport_property_field_section( int $id = 0, array $args = array() ): string {
828 // Resolve the post and which of the nine sections this call renders.
829 $id = $id ? $id : (int) get_the_ID();
830 $section = isset( $args['section'] ) ? (string) $args['section'] : '';
831 if ( ! $id || '' === $section ) {
832 return '';
833 }
834
835 // Build the facts grid from the section's importable, populated fields.
836 $grid = mlsimport_property_facts_grid_html(
837 mlsimport_property_section_fields( $id, $section ),
838 mlsimport_property_columns_class(),
839 $id
840 );
841 // No populated field → render '' rather than a bare heading.
842 if ( '' === $grid ) {
843 return '';
844 }
845
846 // Inside a tab or an accordion panel the container already shows the heading.
847 $titles = mlsimport_property_field_section_titles();
848 $title = ( isset( $titles[ $section ] ) && empty( $args['hide_title'] ) ) ? $titles[ $section ] : '';
849
850 // Section wrapper + the facts grid.
851 $html = mlsimport_property_section_open( $section, $title, 'list' );
852 $html .= $grid;
853 $html .= mlsimport_property_section_close();
854 return $html;
855 }
856
857 /**
858 * The sub-nav jump links for the nine field sections: label => anchor id.
859 *
860 * A section earns a link only when it has a populated field — the same "no data,
861 * no section" rule the sections themselves obey — so the nav never points at an
862 * anchor that isn't on the page.
863 *
864 * @param int $id Property post ID.
865 * @return array<string,string>
866 */
867 function mlsimport_property_subnav_field_items( int $id ): array {
868 $items = array();
869 // Offer a jump link only for a section that has at least one populated field.
870 foreach ( mlsimport_property_field_section_titles() as $slug => $title ) {
871 if ( ! empty( mlsimport_property_section_fields( $id, $slug ) ) ) {
872 $items[ $title ] = 'mlsimport-section-' . $slug;
873 }
874 }
875 return $items;
876 }
877
878 /**
879 * How many columns every field section's details grid runs — the Property Page
880 * "Details Columns" setting. 2 or 3; anything else is 3.
881 *
882 * @return int
883 */
884 function mlsimport_property_details_columns(): int {
885 // Read the "Details Columns" setting; only 2 is honored, everything else is 3.
886 $cols = (int) mlsimport_standalone_option( 'details_columns', 3 );
887 return 2 === $cols ? 2 : 3;
888 }
889
890 /**
891 * The column-count class every grid inside a section carries — the details grids,
892 * the Address grid and the amenity list alike. One class rather than a per-block
893 * modifier, because "two columns" is a page-wide choice: a page set to two that
894 * printed its amenities three-up would just look broken.
895 *
896 * @return string
897 */
898 function mlsimport_property_columns_class(): string {
899 // e.g. "mlsimport-cols-3" — one page-wide column class for every grid.
900 return 'mlsimport-cols-' . mlsimport_property_details_columns();
901 }
902
903 /**
904 * The nine field sections, slug => public heading.
905 *
906 * @return array<string,string>
907 */
908 function mlsimport_property_field_section_titles(): array {
909 return array(
910 'interior' => __( 'Interior', 'mlsimport' ),
911 'exterior' => __( 'Exterior', 'mlsimport' ),
912 'structure' => __( 'Structure', 'mlsimport' ),
913 'utilities' => __( 'Utilities', 'mlsimport' ),
914 'financial' => __( 'Financial', 'mlsimport' ),
915 'schools' => __( 'Schools', 'mlsimport' ),
916 'location' => __( 'Location', 'mlsimport' ),
917 'listing_info' => __( 'Listing Info', 'mlsimport' ),
918 'other' => __( 'Other Details', 'mlsimport' ),
919 );
920 }
921
922 /**
923 * Features section — amenity feature terms as chips.
924 *
925 * @param int $id Property post ID.
926 * @param array $args Behavioral options.
927 * @return string
928 */
929 function mlsimport_property_features( int $id = 0, array $args = array() ): string {
930 // Build the chip list from the view model's feature names.
931 $data = mlsimport_property_data( $id );
932 $list = $data ? mlsimport_property_features_list_html( $data['features'], (int) ( $data['id'] ?? 0 ) ) : '';
933 // No chips → no section.
934 if ( '' === $list ) {
935 return '';
936 }
937
938 // Titled section wrapping the amenity chips; inside a Tabs/Accordion pane the
939 // pane carries the heading, so 'hide_title' drops this one.
940 $title = empty( $args['hide_title'] ) ? __( 'Features & Amenities', 'mlsimport' ) : '';
941 $html = mlsimport_property_section_open( 'features', $title, 'grid' );
942 $html .= $list;
943 $html .= mlsimport_property_section_close();
944 return $html;
945 }
946
947 /**
948 * Details as Tabs — the container's panes (field sections + Features by default,
949 * or the 'sections' list) in a tabbed panel (mlsimport-property-tabs.js).
950 *
951 * @param int $id Property post ID.
952 * @param array $args Behavioral options.
953 * @return string
954 */
955 function mlsimport_property_tabs( int $id = 0, array $args = array() ): string {
956 // Resolve the configured section panes; nothing to tab means no section.
957 $panes = mlsimport_property_container_panes( $id, $args );
958 if ( empty( $panes ) ) {
959 return '';
960 }
961
962 // Build the tab buttons and their panels; the first pane is the open one.
963 $nav = '';
964 $panels = '';
965 $first = true;
966 foreach ( $panes as $key => $pane ) {
967 // Tab button (aria-selected on the first).
968 $nav .= '<button type="button" class="mlsimport-property-tabs__tab" role="tab" data-tab="' . esc_attr( $key ) . '" aria-selected="' . ( $first ? 'true' : 'false' ) . '">' . esc_html( $pane[0] ) . '</button>';
969 // Matching panel (hidden on all but the first).
970 $panels .= '<div class="mlsimport-property-tabs__panel" role="tabpanel" data-panel="' . esc_attr( $key ) . '"' . ( $first ? '' : ' hidden' ) . '>' . $pane[1] . '</div>';
971 $first = false;
972 }
973
974 // Titled "Details" section wrapping the tablist + panels.
975 $html = mlsimport_property_section_open( 'tabs', __( 'Details', 'mlsimport' ), 'list' );
976 $html .= '<div class="mlsimport-property-tabs" data-mlsimport-tabs>';
977 $html .= '<div class="mlsimport-property-tabs__nav" role="tablist">' . $nav . '</div>';
978 $html .= $panels;
979 $html .= '</div>';
980 $html .= mlsimport_property_section_close();
981 return $html;
982 }
983
984 /**
985 * Details as Accordion — the container's panes (field sections + Features by
986 * default, or the 'sections' list) in native <details> panels (no JS).
987 *
988 * @param int $id Property post ID.
989 * @param array $args Behavioral options.
990 * @return string
991 */
992 function mlsimport_property_accordion( int $id = 0, array $args = array() ): string {
993 // Resolve the configured section panes; none means no section.
994 $panes = mlsimport_property_container_panes( $id, $args );
995 if ( empty( $panes ) ) {
996 return '';
997 }
998
999 // Native <details> per pane; only the first starts open.
1000 $items = '';
1001 $open = ' open';
1002 foreach ( $panes as $pane ) {
1003 $items .= '<details class="mlsimport-property-accordion__item"' . $open . '>'
1004 . '<summary class="mlsimport-property-accordion__summary">' . esc_html( $pane[0] ) . '</summary>'
1005 . '<div class="mlsimport-property-accordion__body">' . $pane[1] . '</div>'
1006 . '</details>';
1007 // Subsequent panels render collapsed.
1008 $open = '';
1009 }
1010
1011 // Titled "Details" section wrapping the accordion.
1012 $html = mlsimport_property_section_open( 'accordion', __( 'Details', 'mlsimport' ), 'list' );
1013 $html .= '<div class="mlsimport-property-accordion">' . $items . '</div>';
1014 $html .= mlsimport_property_section_close();
1015 return $html;
1016 }
1017
1018 /**
1019 * Description section — the listing's public remarks (post body) with a heading.
1020 *
1021 * @param int $id Property post ID.
1022 * @param array $args Behavioral options.
1023 * @return string
1024 */
1025 function mlsimport_property_description( int $id = 0, array $args = array() ): string {
1026 // No body content, no section.
1027 $data = mlsimport_property_data( $id );
1028 if ( empty( $data ) || '' === trim( $data['content'] ) ) {
1029 return '';
1030 }
1031 // Titled "Description" section; body is paragraph-wrapped and sanitized.
1032 $html = mlsimport_property_section_open( 'description', __( 'Description', 'mlsimport' ), 'text' );
1033 $html .= '<div class="mlsimport-property-description__body">' . wp_kses_post( wpautop( $data['content'] ) ) . '</div>';
1034 $html .= mlsimport_property_section_close();
1035 return $html;
1036 }
1037
1038 /**
1039 * Content section — the raw listing body, no heading.
1040 *
1041 * @param int $id Property post ID.
1042 * @param array $args Behavioral options.
1043 * @return string
1044 */
1045 function mlsimport_property_content( int $id = 0, array $args = array() ): string {
1046 // No body content, no section.
1047 $data = mlsimport_property_data( $id );
1048 if ( empty( $data ) || '' === trim( $data['content'] ) ) {
1049 return '';
1050 }
1051 // Headingless wrapper + the paragraph-wrapped, sanitized body.
1052 $html = mlsimport_property_section_open( 'content' );
1053 $html .= '<div class="mlsimport-property-content__body">' . wp_kses_post( wpautop( $data['content'] ) ) . '</div>';
1054 $html .= mlsimport_property_section_close();
1055 return $html;
1056 }
1057
1058 /**
1059 * Excerpt section — a short summary (post excerpt, or trimmed content).
1060 *
1061 * @param int $id Property post ID.
1062 * @param array $args Behavioral options.
1063 * @return string
1064 */
1065 function mlsimport_property_excerpt( int $id = 0, array $args = array() ): string {
1066 // Need a resolved property to have anything to summarize.
1067 $data = mlsimport_property_data( $id );
1068 if ( empty( $data ) ) {
1069 return '';
1070 }
1071 // Prefer the explicit excerpt; else trim the body to 40 words.
1072 $text = '' !== trim( $data['excerpt'] ) ? $data['excerpt'] : wp_trim_words( wp_strip_all_tags( $data['content'] ), 40 );
1073 // Nothing to summarize → no section.
1074 if ( '' === trim( $text ) ) {
1075 return '';
1076 }
1077 // Headingless wrapper + the summary paragraph.
1078 $html = mlsimport_property_section_open( 'excerpt' );
1079 $html .= '<p class="mlsimport-property-excerpt__text">' . esc_html( $text ) . '</p>';
1080 $html .= mlsimport_property_section_close();
1081 return $html;
1082 }
1083
1084 /**
1085 * Additional price info — original / previous / close price + HOA.
1086 *
1087 * @param int $id Property post ID.
1088 * @param array $args Behavioral options.
1089 * @return string
1090 */
1091 function mlsimport_property_price_info( int $id = 0, array $args = array() ): string {
1092 $data = mlsimport_property_data( $id );
1093 if ( empty( $data ) ) {
1094 return '';
1095 }
1096
1097 // Collect label => price rows, each added only when its value is present.
1098 $rows = array();
1099 if ( null !== $data['original_price'] ) {
1100 $rows[ __( 'Original price', 'mlsimport' ) ] = mlsimport_format_price( $data['original_price'] );
1101 }
1102 if ( null !== $data['previous_price'] ) {
1103 $rows[ __( 'Previous price', 'mlsimport' ) ] = mlsimport_format_price( $data['previous_price'] );
1104 }
1105 if ( null !== $data['close_price'] ) {
1106 $rows[ __( 'Sold price', 'mlsimport' ) ] = mlsimport_format_price( $data['close_price'] );
1107 }
1108 if ( null !== $data['hoa_fee'] ) {
1109 // HOA fee, optionally suffixed with its billing frequency ("... / Monthly").
1110 $hoa = mlsimport_format_price( $data['hoa_fee'] );
1111 if ( '' !== $data['hoa_frequency'] ) {
1112 $hoa .= ' / ' . $data['hoa_frequency'];
1113 }
1114 $rows[ __( 'HOA fee', 'mlsimport' ) ] = $hoa;
1115 }
1116 // No price rows → no section.
1117 if ( empty( $rows ) ) {
1118 return '';
1119 }
1120
1121 // Titled "Price details" section wrapping a plain facts grid.
1122 $html = mlsimport_property_section_open( 'price-info', __( 'Price details', 'mlsimport' ), 'list' );
1123 $html .= '<ul class="mlsimport-property-details__grid">';
1124 // One label/value <li> per collected row.
1125 foreach ( $rows as $label => $value ) {
1126 $html .= '<li class="mlsimport-property-details__item">'
1127 . '<span class="mlsimport-property-details__label">' . esc_html( $label ) . '</span>'
1128 . '<span class="mlsimport-property-details__value">' . esc_html( $value ) . '</span>'
1129 . '</li>';
1130 }
1131 $html .= '</ul>';
1132 $html .= mlsimport_property_section_close();
1133 return $html;
1134 }
1135
1136 /**
1137 * The breadcrumb trail: Home > County > City > Area > this listing — broadest
1138 * geography first, narrowing down to the listing. A rung with no value is
1139 * simply skipped; there are no stand-ins.
1140 *
1141 * Each place links to its taxonomy archive, which is where a visitor climbing the
1142 * trail expects to land — "Sarasota" should list Sarasota, not search for it. A
1143 * listing imported before the location taxonomies were assigned still names its
1144 * places from the flat columns; they just have nowhere to link.
1145 *
1146 * @param int $id Property post ID.
1147 * @param array $data Property view model (mlsimport_property_data()).
1148 * @return array<int,array{0:string,1:string}> [ label, url ] pairs; url '' = not a link.
1149 */
1150 function mlsimport_property_breadcrumb_items( int $id, array $data ): array {
1151 // A place is its term (linkable) or, failing that, the flat column text.
1152 $place = static function ( $taxonomy, $fallback ) use ( $id ) {
1153 $term = mlsimport_property_first_term( $id, $taxonomy );
1154 $name = $term ? (string) $term->name : (string) $fallback;
1155 if ( '' === $name ) {
1156 return null;
1157 }
1158 $link = $term ? get_term_link( $term ) : '';
1159 return array( $name, is_string( $link ) ? $link : '' );
1160 };
1161
1162 // The trail always starts at Home.
1163 $items = array( array( __( 'Home', 'mlsimport' ), (string) home_url( '/' ) ) );
1164
1165 // Geography rungs, broadest first: county → city → area/subdivision.
1166 $rungs = array(
1167 $place( 'mlsimport_county', $data['county'] ?? '' ),
1168 $place( 'mlsimport_city', $data['city'] ?? '' ),
1169 $place( 'mlsimport_area', $data['subdivision'] ?? '' ),
1170 );
1171 // Append only the rungs that resolved to a value.
1172 foreach ( $rungs as $rung ) {
1173 if ( $rung ) {
1174 $items[] = $rung;
1175 }
1176 }
1177
1178 // The listing itself is the final, non-linking rung.
1179 if ( '' !== (string) ( $data['title'] ?? '' ) ) {
1180 $items[] = array( (string) $data['title'], '' );
1181 }
1182
1183 /** Filter the breadcrumb trail. @since 6.4 */
1184 return (array) apply_filters( 'mlsimport_property_breadcrumb_items', $items, $id, $data );
1185 }
1186
1187 /**
1188 * Every term this listing carries, indexed by lower-cased display text, mapped
1189 * to its public term archive — the lookup behind mlsimport_property_link_term().
1190 *
1191 * A term whose link can't be built is left out, so it simply renders as
1192 * plain text.
1193 *
1194 * Built once per post per request: the property page asks for these links from
1195 * the chips, the facts grid and the amenity list.
1196 *
1197 * @param int $id Property post ID.
1198 * @return array<string,string> lower-cased term text => term archive URL.
1199 */
1200 function mlsimport_property_term_link_map( int $id ): array {
1201 static $cache = array();
1202 if ( isset( $cache[ $id ] ) ) {
1203 return $cache[ $id ];
1204 }
1205
1206 $map = array();
1207 // Walk every taxonomy attached to the property CPT.
1208 foreach ( get_object_taxonomies( 'mlsimport_property' ) as $taxonomy ) {
1209 $terms = get_the_terms( $id, $taxonomy );
1210 // get_the_terms() returns false/WP_Error when the post has no terms — skip.
1211 if ( ! is_array( $terms ) ) {
1212 continue;
1213 }
1214 foreach ( $terms as $term ) {
1215 $url = get_term_link( $term );
1216 // An unresolvable link means this term stays plain text.
1217 if ( is_wp_error( $url ) ) {
1218 continue;
1219 }
1220 // Index the term by its display text.
1221 $key = strtolower( trim( $term->name ) );
1222 if ( '' !== $key ) {
1223 $map[ $key ] = $url;
1224 }
1225 }
1226 }
1227
1228 $cache[ $id ] = $map;
1229 return $map;
1230 }
1231
1232 /**
1233 * A displayed value as a link to its term archive, when the listing actually
1234 * carries a term by that name — otherwise the value as plain escaped text.
1235 *
1236 * Matching on the listing's own terms is what keeps this honest: "Ashland" links
1237 * because this listing is filed under Ashland, while "2025" or a street number
1238 * matches nothing and is left alone. Nothing is guessed from the text itself.
1239 *
1240 * @param int $id Property post ID.
1241 * @param string $text Display value.
1242 * @return string Escaped text, linked when a term matches.
1243 */
1244 function mlsimport_property_link_term( int $id, string $text ): string {
1245 $map = $id ? mlsimport_property_term_link_map( $id ) : array();
1246 $key = strtolower( trim( $text ) );
1247 // No matching term → the value renders exactly as before.
1248 if ( ! isset( $map[ $key ] ) ) {
1249 return esc_html( $text );
1250 }
1251 return '<a class="mlsimport-property-term-link" href="' . esc_url( $map[ $key ] ) . '">' . esc_html( $text ) . '</a>';
1252 }
1253
1254 /**
1255 * The first term a listing carries in a taxonomy, or null.
1256 *
1257 * @param int $id Property post ID.
1258 * @param string $taxonomy Taxonomy slug.
1259 * @return object|null
1260 */
1261 function mlsimport_property_first_term( int $id, string $taxonomy ) {
1262 // No usable terms (missing, empty, or a WP_Error) → null.
1263 $terms = get_the_terms( $id, $taxonomy );
1264 if ( ! is_array( $terms ) || empty( $terms ) || is_wp_error( $terms ) ) {
1265 return null;
1266 }
1267 // The first term is the one the trail uses.
1268 return reset( $terms );
1269 }
1270
1271 /**
1272 * The breadcrumb trail as markup.
1273 *
1274 * @param array $items [ label, url ] pairs from mlsimport_property_breadcrumb_items().
1275 * @return string
1276 */
1277 function mlsimport_property_breadcrumbs_html( array $items ): string {
1278 // No rungs → no breadcrumb nav.
1279 if ( empty( $items ) ) {
1280 return '';
1281 }
1282
1283 // Open the breadcrumb <nav>/<ol>.
1284 $html = '<nav class="mlsimport-property-breadcrumbs" aria-label="' . esc_attr__( 'Breadcrumb', 'mlsimport' ) . '"><ol class="mlsimport-property-breadcrumbs__list">';
1285 foreach ( $items as $item ) {
1286 // A rung with a URL is a link; the URL-less final rung is the current page.
1287 $label = '' !== (string) $item[1]
1288 ? '<a href="' . esc_url( $item[1] ) . '">' . esc_html( $item[0] ) . '</a>'
1289 : '<span aria-current="page">' . esc_html( $item[0] ) . '</span>';
1290
1291 $html .= '<li class="mlsimport-property-breadcrumbs__item">' . $label . '</li>';
1292 }
1293 $html .= '</ol></nav>';
1294 return $html;
1295 }
1296
1297 /**
1298 * The sticky mobile agent bar as markup — the listing agent kept one tap away at
1299 * the bottom of a phone screen (WPResidence's mobile_agent_area, rebuilt here).
1300 *
1301 * Call, email and WhatsApp are the three things a visitor on a phone actually does
1302 * from a listing, so each is a single tap. An action with nothing behind it is not
1303 * drawn, and an agent with no name and no way to reach them draws no bar at all —
1304 * an empty bar would just eat the bottom of the screen. Hidden on desktop by CSS,
1305 * where the agent card and its contact rail are already in view.
1306 *
1307 * @param array $data Property view model (mlsimport_property_data()).
1308 * @return string
1309 */
1310 function mlsimport_property_mobile_agent_bar_html( array $data ): string {
1311 // Resolve the agent shape; no agent means no bar.
1312 $a = isset( $data['agent'] ) && is_array( $data['agent'] ) ? $data['agent'] : array();
1313 if ( empty( $a ) ) {
1314 return '';
1315 }
1316
1317 // Pull the three contact fields; $tel is the dial-able form of the phone.
1318 $name = (string) ( $a['name'] ?? '' );
1319 $email = (string) ( $a['email'] ?? '' );
1320 $phone = (string) ( $a['phone'] ?? '' );
1321 $tel = preg_replace( '/[^0-9+]/', '', $phone );
1322 // No name and no way to reach them → draw no bar at all.
1323 if ( '' === $name && '' === $email && '' === $tel ) {
1324 return '';
1325 }
1326
1327 // Build only the actions that have something behind them.
1328 $actions = '';
1329 if ( '' !== $email ) {
1330 $actions .= mlsimport_property_mobile_agent_action( 'email', 'mailto:' . $email, 'mail', __( 'Email the agent', 'mlsimport' ) );
1331 }
1332 if ( '' !== $tel ) {
1333 // A phone enables both a tel: call and a WhatsApp chat.
1334 $actions .= mlsimport_property_mobile_agent_action( 'phone', 'tel:' . $tel, 'phone', __( 'Call the agent', 'mlsimport' ) );
1335 $actions .= mlsimport_property_mobile_agent_action(
1336 'whatsapp',
1337 mlsimport_property_whatsapp_link( $tel, (string) ( $data['title'] ?? '' ), (string) ( $data['permalink'] ?? '' ) ),
1338 'whatsapp',
1339 __( 'WhatsApp the agent', 'mlsimport' )
1340 );
1341 }
1342
1343 // Avatar: the agent photo when present, else initials in a chip.
1344 $avatar = ! empty( $a['photo_id'] )
1345 ? wp_get_attachment_image( (int) $a['photo_id'], 'thumbnail', false, array( 'class' => 'mlsimport-property-mobile-agent__img' ) )
1346 : '<span class="mlsimport-property-mobile-agent__initials">' . esc_html( mlsimport_property_initials( $name ) ) . '</span>';
1347
1348 // Name links to the agent profile when there is a linked post.
1349 $profile = ! empty( $a['id'] ) ? (string) get_permalink( (int) $a['id'] ) : '';
1350 $name_html = '' !== $profile
1351 ? '<a class="mlsimport-property-mobile-agent__name" href="' . esc_url( $profile ) . '">' . esc_html( $name ) . '</a>'
1352 : '<span class="mlsimport-property-mobile-agent__name">' . esc_html( $name ) . '</span>';
1353
1354 // Compose the bar: identity (avatar + name) on the left, actions on the right.
1355 $html = '<div class="mlsimport-property-mobile-agent">';
1356 $html .= '<div class="mlsimport-property-mobile-agent__identity">'
1357 . '<span class="mlsimport-property-mobile-agent__photo">' . $avatar . '</span>'
1358 . $name_html
1359 . '</div>';
1360 $html .= '<div class="mlsimport-property-mobile-agent__actions">' . $actions . '</div>';
1361 $html .= '</div>';
1362 return $html;
1363 }
1364
1365 /**
1366 * One round action button in the mobile agent bar.
1367 *
1368 * @param string $type Action slug (email|phone|whatsapp).
1369 * @param string $href Link target.
1370 * @param string $icon Icon name.
1371 * @param string $label Accessible label.
1372 * @return string
1373 */
1374 function mlsimport_property_mobile_agent_action( string $type, string $href, string $icon, string $label ): string {
1375 return '<a class="mlsimport-property-mobile-agent__action mlsimport-property-mobile-agent__action--' . esc_attr( $type ) . '"'
1376 . ' href="' . esc_attr( $href ) . '" aria-label="' . esc_attr( $label ) . '">'
1377 . mlsimport_property_icon( $icon )
1378 . '</a>';
1379 }
1380
1381 /**
1382 * A wa.me link that opens a chat already talking about this listing — the agent
1383 * gets "Hello, I'm interested in [title] <url>" instead of a bare "hi".
1384 *
1385 * @param string $tel Phone, digits (and possibly a leading +).
1386 * @param string $title Listing title.
1387 * @param string $permalink Listing URL.
1388 * @return string
1389 */
1390 function mlsimport_property_whatsapp_link( string $tel, string $title, string $permalink ): string {
1391 // wa.me wants the number bare: digits only, no +, no spaces.
1392 $number = preg_replace( '/[^0-9]/', '', $tel );
1393 // No digits → no link.
1394 if ( '' === $number ) {
1395 return '';
1396 }
1397
1398 // Pre-fill the chat with the listing title + URL.
1399 $message = sprintf(
1400 /* translators: 1: listing title, 2: listing URL. */
1401 __( 'Hello, I\'m interested in [%1$s] %2$s', 'mlsimport' ),
1402 $title,
1403 $permalink
1404 );
1405
1406 /** Filter the WhatsApp message a visitor sends from a listing. @since 6.4 */
1407 $message = (string) apply_filters( 'mlsimport_property_whatsapp_message', $message, $title, $permalink );
1408
1409 // wa.me deep link with the pre-filled, URL-encoded message.
1410 return 'https://wa.me/' . $number . '?text=' . rawurlencode( $message );
1411 }
1412
1413 /**
1414 * Mobile agent bar section — fixed to the bottom of the viewport on phones.
1415 *
1416 * @param int $id Property post ID.
1417 * @param array $args Behavioral options.
1418 * @return string
1419 */
1420 function mlsimport_property_mobile_agent_bar( int $id = 0, array $args = array() ): string {
1421 // Resolve the post and its view model; nothing to show without one.
1422 $id = $id ? $id : (int) get_the_ID();
1423 $data = mlsimport_property_data( $id );
1424 if ( empty( $data ) ) {
1425 return '';
1426 }
1427
1428 // Build the bar markup; empty when there is no reachable agent.
1429 $bar = mlsimport_property_mobile_agent_bar_html( $data );
1430 if ( '' === $bar ) {
1431 return '';
1432 }
1433
1434 // Deliberately NOT a mlsimport_property_section_open() panel: the bar is fixed
1435 // to the viewport, so the section chrome (card, padding, heading) would only
1436 // wrap a thing that has left the document flow. The spacer is the in-flow
1437 // stand-in that keeps the bar from covering whatever ends the page.
1438 return '<div class="mlsimport-property-mobile-agent-spacer" aria-hidden="true"></div>'
1439 . '<div class="mlsimport-property-mobile-agent-bar" id="mlsimport-section-mobile_agent_bar">' . $bar . '</div>';
1440 }
1441
1442 /**
1443 * Breadcrumbs section — the trail pinned above the gallery, under the site header.
1444 *
1445 * @param int $id Property post ID.
1446 * @param array $args Behavioral options.
1447 * @return string
1448 */
1449 function mlsimport_property_breadcrumbs( int $id = 0, array $args = array() ): string {
1450 // Resolve the post and its view model.
1451 $id = $id ? $id : (int) get_the_ID();
1452 $data = mlsimport_property_data( $id );
1453 if ( empty( $data ) ) {
1454 return '';
1455 }
1456
1457 // Wrapper + the built breadcrumb trail markup.
1458 $html = mlsimport_property_section_open( 'breadcrumbs' );
1459 $html .= mlsimport_property_breadcrumbs_html( mlsimport_property_breadcrumb_items( $id, $data ) );
1460 $html .= mlsimport_property_section_close();
1461 return $html;
1462 }
1463
1464 /**
1465 * Render a property photo as a CSS background-image div (never an <img>) — the
1466 * standalone convention for listing photos. Remote MLS attachments often report a
1467 * 1x1 intrinsic size, so a background fills its container reliably where an <img>
1468 * would collapse. Callers supply the box sizing via the element class.
1469 *
1470 * @param int|string $aid Attachment ID, or a bare image URL (live mode's
1471 * galleries carry MLS CDN URLs instead of attachments).
1472 * @param string $size Registered image size used for the source URL.
1473 * @param string $class Element class (defines the box; .mlsimport-property-photo fills it).
1474 * @param string $label Accessible label for the role="img" element ('' = none).
1475 * @return string '' when the attachment resolves to no URL.
1476 */
1477 function mlsimport_property_photo_bg( $aid, string $size, string $class, string $label = '' ): string {
1478 // A non-numeric string is already a bare URL (live mode); else resolve the attachment.
1479 $url = is_string( $aid ) && ! is_numeric( $aid ) ? $aid : wp_get_attachment_image_url( (int) $aid, $size );
1480 // No URL → render nothing.
1481 if ( ! $url ) {
1482 return '';
1483 }
1484 // Add an aria-label only when one was supplied.
1485 $aria = '' !== $label ? ' aria-label="' . esc_attr( $label ) . '"' : '';
1486 // A role="img" div carrying the photo as a background-image.
1487 return '<div class="' . esc_attr( $class ) . '" role="img"' . $aria . ' style="background-image:url(\'' . esc_url( $url ) . '\')"></div>';
1488 }
1489
1490 /**
1491 * Featured image section — the post thumbnail.
1492 *
1493 * @param int $id Property post ID.
1494 * @param array $args Behavioral options.
1495 * @return string
1496 */
1497 function mlsimport_property_featured_image( int $id = 0, array $args = array() ): string {
1498 // Need either a thumbnail attachment or a live image URL.
1499 $data = mlsimport_property_data( $id );
1500 if ( empty( $data ) || ( ! $data['thumbnail_id'] && '' === (string) $data['image_url'] ) ) {
1501 return '';
1502 }
1503 // Prefer the attachment id; fall back to the raw image URL.
1504 $photo = mlsimport_property_photo_bg( $data['thumbnail_id'] ? (int) $data['thumbnail_id'] : (string) $data['image_url'], 'large', 'mlsimport-property-photo', $data['address'] );
1505 // The image resolved to no URL → no section.
1506 if ( '' === $photo ) {
1507 return '';
1508 }
1509 // Headingless wrapper + the background-image photo.
1510 $html = mlsimport_property_section_open( 'featured' );
1511 $html .= '<div class="mlsimport-property-featured__image">' . $photo . '</div>';
1512 $html .= mlsimport_property_section_close();
1513 return $html;
1514 }
1515
1516 /**
1517 * Property Gallery section — the single, user-facing media section. It renders
1518 * whichever gallery/slider variant the user picked in the "Media Section Type"
1519 * plugin setting (media_section_type), delegating to mlsimport_property_gallery().
1520 *
1521 * @param int $id Property post ID.
1522 * @param array $args Unused (the variant comes from the setting).
1523 * @return string
1524 */
1525 function mlsimport_property_media( int $id = 0, array $args = array() ): string {
1526 // The user's chosen media variant from the "Media Section Type" setting.
1527 $type = (string) mlsimport_standalone_option( 'media_section_type', 'classic' );
1528
1529 // Map each setting value to the gallery layout/variant args.
1530 $map = array(
1531 'classic' => array( 'layout' => 'slider', 'variant' => 'classic' ),
1532 'vertical' => array( 'layout' => 'slider', 'variant' => 'vertical' ),
1533 'v4' => array( 'layout' => 'slider', 'variant' => 'full' ),
1534 'multi' => array( 'layout' => 'slider', 'variant' => 'multi' ),
1535 'masonry1' => array( 'layout' => 'masonry' ),
1536 'masonry2' => array( 'layout' => 'masonry_v2' ),
1537 );
1538
1539 // Delegate to the one gallery renderer; unknown types fall back to classic.
1540 return mlsimport_property_gallery( $id, isset( $map[ $type ] ) ? $map[ $type ] : $map['classic'] );
1541 }
1542
1543 /**
1544 * Gallery / slider section — one render fn for every media layout. The manifest
1545 * bakes a layout (grid|masonry|slider) and, for sliders, a variant; the eight
1546 * WpResidence slider widgets and the masonry/grid galleries all route here (DRY).
1547 *
1548 * @param int $id Property post ID.
1549 * @param array $args { layout: grid|masonry|slider, variant: string, columns: int }
1550 * @return string
1551 */
1552 function mlsimport_property_gallery( int $id = 0, array $args = array() ): string {
1553 $data = mlsimport_property_data( $id );
1554 if ( empty( $data ) ) {
1555 return '';
1556 }
1557
1558 // Prefer the real gallery; fall back to the single featured image when empty.
1559 $ids = $data['gallery_ids'];
1560 if ( empty( $ids ) && $data['thumbnail_id'] ) {
1561 $ids = array( $data['thumbnail_id'] );
1562 }
1563 // Live mode: no attachments — the gallery items are the MLS CDN URLs.
1564 if ( empty( $ids ) && ! empty( $data['gallery_urls'] ) ) {
1565 $ids = $data['gallery_urls'];
1566 }
1567 // No images at all → no gallery.
1568 if ( empty( $ids ) ) {
1569 return '';
1570 }
1571
1572 // Layout defaults to a static grid.
1573 $layout = isset( $args['layout'] ) ? (string) $args['layout'] : 'grid';
1574
1575 // The "Featured listing" checkbox (issue #288), the same flag the list cards
1576 // badge; every layout shows it over the first photo.
1577 $featured = '1' === (string) get_post_meta( (int) $data['id'], 'mlsimport_featured', true );
1578
1579 // Slider layouts route to the Splide builder.
1580 if ( 'slider' === $layout ) {
1581 return mlsimport_property_gallery_slider( $ids, array_merge( $args, array( 'featured' => $featured ) ) );
1582 }
1583
1584 // Grid/masonry overlay: featured + status chips + photo count.
1585 $overlay = array(
1586 'status' => '' !== (string) $data['status'] ? (string) $data['status'] : ( '' !== (string) $data['property_sub_type'] ? (string) $data['property_sub_type'] : '' ),
1587 'count' => count( $ids ),
1588 'featured' => $featured,
1589 );
1590 return mlsimport_property_gallery_grid( $ids, $layout, $overlay );
1591 }
1592
1593 /**
1594 * A fresh data-gallery group id, unique per gallery instance, so two galleries on
1595 * one page don't merge into a single lightbox set.
1596 *
1597 * @return string
1598 */
1599 function mlsimport_property_gallery_group_id(): string {
1600 // Per-request counter so each gallery gets its own lightbox group.
1601 static $n = 0;
1602 return 'mlsimport-gallery-' . ( ++$n );
1603 }
1604
1605 /**
1606 * Wrap a gallery image in a GLightbox link to its full-size file, so a click opens
1607 * the lightbox slider. Falls back to the bare image when no full URL exists.
1608 *
1609 * @param int|string $aid Attachment ID, or a bare image URL (its own full size).
1610 * @param string $img Pre-rendered <img> markup.
1611 * @param string $group data-gallery group shared across one gallery instance.
1612 * @return string
1613 */
1614 function mlsimport_property_lightbox_link( $aid, string $img, string $group ): string {
1615 // A non-numeric string is its own full-size URL; else resolve the 'full' size.
1616 $is_url = is_string( $aid ) && ! is_numeric( $aid );
1617 $full = $is_url ? $aid : wp_get_attachment_image_url( (int) $aid, 'full' );
1618 // No full URL → return the bare image, unlinked.
1619 if ( ! $full ) {
1620 return $img;
1621 }
1622 // Attachment captions become the lightbox title (URLs carry none).
1623 $caption = $is_url ? '' : trim( (string) wp_get_attachment_caption( (int) $aid ) );
1624 $cap_attr = '' !== $caption ? ' data-title="' . esc_attr( $caption ) . '"' : '';
1625 // Wrap the image in the GLightbox anchor, tagged with the shared group id.
1626 return '<a class="mlsimport-glightbox" href="' . esc_url( $full ) . '" data-gallery="' . esc_attr( $group ) . '"' . $cap_attr . '>' . $img . '</a>';
1627 }
1628
1629 /**
1630 * Static grid / masonry gallery markup.
1631 *
1632 * @param int[] $ids Attachment IDs.
1633 * @param string $layout 'grid', 'masonry' (column flow) or 'masonry_v2' (hero + strip).
1634 * @return string
1635 */
1636 function mlsimport_property_gallery_grid( array $ids, string $layout, array $overlay = array() ): string {
1637 // Masonry v1 ('masonry') is the hero + 2×2 mosaic — the base grid already is
1638 // that (WpResidence's masonry gallery 1). Only v2 needs a modifier.
1639 $modifier = ( 'masonry_v2' === $layout ) ? ' mlsimport-property-gallery--masonry-v2' : '';
1640 $status = isset( $overlay['status'] ) ? (string) $overlay['status'] : '';
1641 $count = isset( $overlay['count'] ) ? (int) $overlay['count'] : 0;
1642 $featured = ! empty( $overlay['featured'] );
1643 // Every layout shows the first 5 tiles only (the rest are hidden in CSS, the
1644 // WpResidence pattern); put the count chip on the last VISIBLE tile so it
1645 // reads as "see all N photos" rather than sitting on a hidden overflow image.
1646 $count_index = min( count( $ids ) - 1, 4 );
1647
1648 // One lightbox group for this gallery instance.
1649 $group = mlsimport_property_gallery_group_id();
1650 // Section wrapper + the grid container (with any masonry-v2 modifier).
1651 $html = mlsimport_property_section_open( 'gallery' );
1652 $html .= '<div class="mlsimport-property-gallery__grid' . esc_attr( $modifier ) . '">';
1653 $i = 0;
1654 foreach ( $ids as $aid ) {
1655 // Resolve this photo; a photo that produced no URL still advances the index.
1656 $img = mlsimport_property_photo_bg( $aid, 'large', 'mlsimport-property-photo' );
1657 if ( '' === $img ) {
1658 ++$i;
1659 continue;
1660 }
1661 $over = '';
1662 // Featured + status chips ride the first tile, side by side in one row.
1663 if ( 0 === $i && ( $featured || '' !== $status ) ) {
1664 $over .= '<span class="mlsimport-property-gallery__chips">';
1665 if ( $featured ) {
1666 $over .= '<span class="mlsimport-property-gallery__chip mlsimport-property-gallery__chip--featured">' . esc_html__( 'Featured', 'mlsimport' ) . '</span>';
1667 }
1668 if ( '' !== $status ) {
1669 $over .= '<span class="mlsimport-property-gallery__chip mlsimport-property-gallery__chip--status">' . esc_html( $status ) . '</span>';
1670 }
1671 $over .= '</span>';
1672 }
1673 // Count chip rides the last visible tile.
1674 if ( $i === $count_index && $count > 1 ) {
1675 /* translators: %d: number of photos. */
1676 $over .= '<span class="mlsimport-property-gallery__chip mlsimport-property-gallery__chip--count">' . esc_html( sprintf( _n( '%d photo', '%d photos', $count, 'mlsimport' ), $count ) ) . '</span>';
1677 }
1678 // Each tile is a lightbox-linked figure carrying any overlay chip.
1679 $html .= '<figure class="mlsimport-property-gallery__item">' . mlsimport_property_lightbox_link( $aid, $img, $group ) . $over . '</figure>';
1680 ++$i;
1681 }
1682 $html .= '</div>';
1683 $html .= mlsimport_property_section_close();
1684 return $html;
1685 }
1686
1687 /**
1688 * Splide slider gallery markup. Variant tunes the Splide options consumed by
1689 * mlsimport-property-slider.js (the assets are enqueued by the dispatcher).
1690 *
1691 * @param int[] $ids Attachment IDs.
1692 * @param array $args { variant: classic|vertical|multi|full }
1693 * @return string
1694 */
1695 function mlsimport_property_gallery_slider( array $ids, array $args ): string {
1696 // Resolve the variant, clamping any unknown value back to 'classic'.
1697 $variant = isset( $args['variant'] ) ? (string) $args['variant'] : 'classic';
1698 $allowed = array( 'classic', 'vertical', 'multi', 'full' );
1699 $variant = in_array( $variant, $allowed, true ) ? $variant : 'classic';
1700
1701 // Classic and vertical pair the main carousel with a synced thumbnail strip
1702 // (the WpResidence pattern); multi and full are plain.
1703 $thumbs = in_array( $variant, array( 'classic', 'vertical' ), true );
1704
1705 // One lightbox group for this slider instance.
1706 $group = mlsimport_property_gallery_group_id();
1707 $html = mlsimport_property_section_open( 'gallery' );
1708
1709 // Open the wrap that pairs the main carousel with its thumbnail strip.
1710 if ( $thumbs ) {
1711 $html .= '<div class="mlsimport-property-slider-wrap mlsimport-property-slider-wrap--' . esc_attr( $variant ) . '" data-mlsimport-slider-wrap>';
1712 }
1713
1714 // Main carousel: one lightbox-linked slide per resolvable photo.
1715 $html .= '<div class="mlsimport-property-slider splide" data-mlsimport-slider="' . esc_attr( $variant ) . '">';
1716 $html .= '<div class="splide__track"><ul class="splide__list">';
1717 foreach ( $ids as $aid ) {
1718 $img = mlsimport_property_photo_bg( $aid, 'large', 'mlsimport-property-photo' );
1719 if ( '' !== $img ) {
1720 $html .= '<li class="splide__slide">' . mlsimport_property_lightbox_link( $aid, $img, $group ) . '</li>';
1721 }
1722 }
1723 $html .= '</ul></div>';
1724 // Featured chip: pinned over the carousel (not inside a slide), so it stays put
1725 // while the photos move.
1726 if ( ! empty( $args['featured'] ) ) {
1727 $html .= '<span class="mlsimport-property-gallery__chips"><span class="mlsimport-property-gallery__chip mlsimport-property-gallery__chip--featured">' . esc_html__( 'Featured', 'mlsimport' ) . '</span></span>';
1728 }
1729 $html .= '</div>';
1730
1731 // Synced thumbnail strip (classic/vertical only): a medium tile per photo.
1732 if ( $thumbs ) {
1733 $html .= '<div class="mlsimport-property-slider-thumbs splide" data-mlsimport-slider-thumbs>';
1734 $html .= '<div class="splide__track"><ul class="splide__list">';
1735 foreach ( $ids as $aid ) {
1736 $thumb = mlsimport_property_photo_bg( $aid, 'medium', 'mlsimport-property-photo' );
1737 if ( '' !== $thumb ) {
1738 $html .= '<li class="splide__slide">' . $thumb . '</li>';
1739 }
1740 }
1741 $html .= '</ul></div></div>';
1742 $html .= '</div>'; // .mlsimport-property-slider-wrap
1743 }
1744
1745 $html .= mlsimport_property_section_close();
1746 return $html;
1747 }
1748
1749 /**
1750 * Agent card section — reuses the theme-overridable parts/agent-box.php partial.
1751 *
1752 * @param int $id Property post ID.
1753 * @param array $args Behavioral options.
1754 * @return string
1755 */
1756 function mlsimport_property_agent_card( int $id = 0, array $args = array() ): string {
1757 // Need a resolved property that carries an agent.
1758 $data = mlsimport_property_data( $id );
1759 if ( empty( $data ) || empty( $data['agent'] ) ) {
1760 return '';
1761 }
1762
1763 // A nameless agent draws no card.
1764 $a = $data['agent'];
1765 if ( '' === (string) $a['name'] ) {
1766 return '';
1767 }
1768
1769 // Profile link (when a linked post exists) and a dial-able phone.
1770 $permalink = ! empty( $a['id'] ) ? (string) get_permalink( (int) $a['id'] ) : '';
1771 $tel = preg_replace( '/[^0-9+]/', '', (string) $a['phone'] );
1772 // The "Verified agent" check shows only when the linked agent post is ticked verified
1773 // (mlsimport_featured). An MLS-only agent with no post is never marked.
1774 $verified = ! empty( $a['id'] ) && '1' === (string) get_post_meta( (int) $a['id'], 'mlsimport_featured', true );
1775
1776 // Avatar: agent photo when present, otherwise initials in a tinted circle.
1777 if ( ! empty( $a['photo_id'] ) ) {
1778 $avatar = wp_get_attachment_image( (int) $a['photo_id'], 'thumbnail', false, array( 'class' => 'mlsimport-property-agent__photo-img' ) );
1779 } else {
1780 $avatar = '<span class="mlsimport-property-agent__initials">' . esc_html( mlsimport_property_initials( $a['name'] ) ) . '</span>';
1781 }
1782
1783 // Credential tiles — only those with a value render.
1784 $creds = array(
1785 array( 'building', __( 'Brokerage', 'mlsimport' ), $a['office'] ),
1786 array( 'badge', __( 'License', 'mlsimport' ), $a['license'] ),
1787 array( 'hash', __( 'Agent MLS ID', 'mlsimport' ), $a['agent_mls_id'] ),
1788 array( 'building', __( 'Office MLS ID', 'mlsimport' ), $a['office_mls_id'] ),
1789 );
1790 $cred_html = '';
1791 foreach ( $creds as $c ) {
1792 // Skip a credential with no value.
1793 if ( '' === (string) $c[2] ) {
1794 continue;
1795 }
1796 // Icon + label + value tile.
1797 $cred_html .= '<div class="mlsimport-property-agent__cred">'
1798 . '<span class="mlsimport-property-agent__cred-icon" aria-hidden="true">' . mlsimport_property_icon( $c[0] ) . '</span>'
1799 . '<span class="mlsimport-property-agent__cred-text">'
1800 . '<span class="mlsimport-property-agent__cred-label">' . esc_html( $c[1] ) . '</span>'
1801 . '<span class="mlsimport-property-agent__cred-value">' . esc_html( $c[2] ) . '</span>'
1802 . '</span></div>';
1803 }
1804
1805 // Name links to the profile when one exists, else plain text.
1806 $name_html = '' !== $permalink
1807 ? '<a href="' . esc_url( $permalink ) . '">' . esc_html( $a['name'] ) . '</a>'
1808 : esc_html( $a['name'] );
1809
1810 // Titled "Meet your agent" section wrapping a two-column grid.
1811 $html = mlsimport_property_section_open( 'agent', __( 'Meet your agent', 'mlsimport' ), 'user' );
1812 $html .= '<div class="mlsimport-property-agent__grid">';
1813
1814 // Identity + credentials + bio.
1815 $html .= '<div class="mlsimport-property-agent__main">';
1816 $html .= '<div class="mlsimport-property-agent__identity">';
1817 $html .= '<span class="mlsimport-property-agent__photo">' . $avatar
1818 . ( $verified ? '<span class="mlsimport-property-agent__verified" title="' . esc_attr__( 'Verified agent', 'mlsimport' ) . '" aria-hidden="true">' . mlsimport_property_icon( 'check' ) . '</span>' : '' )
1819 . '</span>';
1820 $html .= '<div class="mlsimport-property-agent__id-text">'
1821 . '<h3 class="mlsimport-property-agent__name">' . $name_html . '</h3>'
1822 . '<p class="mlsimport-property-agent__role">' . esc_html__( 'Listing Agent', 'mlsimport' ) . ( '' !== (string) $a['office'] ? ' · ' . esc_html( $a['office'] ) : '' ) . '</p>'
1823 . '</div>';
1824 $html .= '</div>'; // identity.
1825 if ( '' !== $cred_html ) {
1826 $html .= '<div class="mlsimport-property-agent__creds">' . $cred_html . '</div>';
1827 }
1828 $html .= '</div>'; // main.
1829
1830 // Contact rail.
1831 $html .= '<div class="mlsimport-property-agent__contact">';
1832 $html .= '<p class="mlsimport-property-agent__contact-title">' . esc_html__( 'Get in touch', 'mlsimport' ) . '</p>';
1833 // Primary call button when a phone exists.
1834 if ( '' !== $tel ) {
1835 $html .= '<a class="mlsimport-property-agent__btn mlsimport-property-agent__btn--primary" href="tel:' . esc_attr( $tel ) . '">' . mlsimport_property_icon( 'phone' ) . '<span>' . esc_html( $a['phone'] ) . '</span></a>';
1836 }
1837 // Outline message button when an email exists.
1838 if ( '' !== (string) $a['email'] ) {
1839 $html .= '<a class="mlsimport-property-agent__btn mlsimport-property-agent__btn--outline" href="mailto:' . esc_attr( $a['email'] ) . '">' . mlsimport_property_icon( 'message' ) . '<span>' . esc_html__( 'Send a message', 'mlsimport' ) . '</span></a>';
1840 }
1841 // Office phone line, when present.
1842 if ( '' !== (string) $a['office_phone'] ) {
1843 $html .= '<p class="mlsimport-property-agent__office-line">' . mlsimport_property_icon( 'building' ) . '<span>' . esc_html__( 'Office', 'mlsimport' ) . ' · ' . esc_html( $a['office_phone'] ) . '</span></p>';
1844 }
1845 // License line, when present.
1846 if ( '' !== (string) $a['license'] ) {
1847 $html .= '<p class="mlsimport-property-agent__license">' . esc_html( $a['license'] ) . '</p>';
1848 }
1849 $html .= '</div>'; // contact.
1850
1851 $html .= '</div>'; // grid.
1852 $html .= mlsimport_property_section_close();
1853 return $html;
1854 }
1855
1856 /**
1857 * First-two-word initials for an avatar fallback, uppercased.
1858 *
1859 * @param string $name Full name.
1860 * @return string
1861 */
1862 function mlsimport_property_initials( string $name ): string {
1863 // Split the name on whitespace into words.
1864 $parts = preg_split( '/\s+/', trim( $name ) );
1865 $out = '';
1866 foreach ( (array) $parts as $word ) {
1867 // Take the first letter of each non-empty word.
1868 if ( '' !== $word ) {
1869 $out .= mb_substr( $word, 0, 1 );
1870 }
1871 // Stop once two initials are collected.
1872 if ( mb_strlen( $out ) >= 2 ) {
1873 break;
1874 }
1875 }
1876 // Uppercase for the avatar chip.
1877 return mb_strtoupper( $out );
1878 }
1879
1880 /**
1881 * Lead form section — one render fn for all four lead forms. The manifest bakes
1882 * a variant (contact|form|sidebar|tour); they all post to the single
1883 * mlsimport_property_lead AJAX endpoint. See decision 5.
1884 *
1885 * @param int $id Property post ID.
1886 * @param array $args { variant: contact|form|sidebar|tour }
1887 * @return string
1888 */
1889 function mlsimport_property_lead_form( int $id = 0, array $args = array() ): string {
1890 $data = mlsimport_property_data( $id );
1891 if ( empty( $data ) ) {
1892 return '';
1893 }
1894
1895 // Which of the four lead-form variants this call renders.
1896 $variant = isset( $args['variant'] ) ? (string) $args['variant'] : 'contact';
1897 // Per-variant heading; unknown variants fall back to the contact title.
1898 $titles = array(
1899 'contact' => __( 'Contact Agent', 'mlsimport' ),
1900 'form' => __( 'Request Information', 'mlsimport' ),
1901 'sidebar' => __( 'Contact Agent', 'mlsimport' ),
1902 'tour' => __( 'Schedule a Tour', 'mlsimport' ),
1903 );
1904 $title = isset( $titles[ $variant ] ) ? $titles[ $variant ] : $titles['contact'];
1905
1906 // The shared field set (variant tweaks which inputs appear).
1907 $fields = mlsimport_property_lead_fields( $data, $variant );
1908
1909 // Titled section wrapping the form that posts to the lead endpoint.
1910 $html = mlsimport_property_section_open( 'lead lead--' . $variant, $title, 'message' );
1911 $html .= '<form class="mlsimport-property-lead-form" data-mlsimport-lead method="post">';
1912 $html .= $fields;
1913 $html .= '<button type="submit">' . esc_html__( 'Send', 'mlsimport' ) . '</button>';
1914 $html .= '<div class="mlsimport-property-lead-form__status" role="status" aria-live="polite"></div>';
1915 $html .= '</form>';
1916 $html .= mlsimport_property_section_close();
1917 return $html;
1918 }
1919
1920 /**
1921 * Mortgage calculator section — browser-only, seeded with the list price.
1922 *
1923 * @param int $id Property post ID.
1924 * @param array $args Behavioral options.
1925 * @return string
1926 */
1927 function mlsimport_property_calculator( int $id = 0, array $args = array() ): string {
1928 // A calculator needs a list price to seed itself.
1929 $data = mlsimport_property_data( $id );
1930 if ( empty( $data ) || null === $data['price'] ) {
1931 return '';
1932 }
1933
1934 // Seed values: list price, an estimated monthly tax, and any known HOA fee.
1935 $price = (float) $data['price'];
1936 $tax = (int) round( $price * 0.0125 / 12 ); // ~1.25%/yr property tax, monthly.
1937 $hoa = null !== $data['hoa_fee'] ? (int) round( (float) $data['hoa_fee'] ) : 0;
1938
1939 // Payment-breakdown segments: key, label (the bar + legend share this list).
1940 $segments = array(
1941 'pi' => __( 'Principal & interest', 'mlsimport' ),
1942 'tax' => __( 'Property tax', 'mlsimport' ),
1943 'ins' => __( 'Home insurance', 'mlsimport' ),
1944 'pmi' => __( 'PMI', 'mlsimport' ),
1945 'hoa' => __( 'HOA dues', 'mlsimport' ),
1946 );
1947
1948 // Build the stacked bar and its legend from the same segment list.
1949 $bar = '';
1950 $legend = '';
1951 foreach ( $segments as $key => $label ) {
1952 // Bar segment (JS sizes it) + legend row (JS fills the amount).
1953 $bar .= '<span class="mlsimport-property-calculator__seg mlsimport-property-calculator__seg--' . esc_attr( $key ) . '" data-seg="' . esc_attr( $key ) . '"></span>';
1954 $legend .= '<li class="mlsimport-property-calculator__legend-item" data-legend="' . esc_attr( $key ) . '">'
1955 . '<span class="mlsimport-property-calculator__dot mlsimport-property-calculator__dot--' . esc_attr( $key ) . '"></span>'
1956 . '<span class="mlsimport-property-calculator__legend-label">' . esc_html( $label ) . '</span>'
1957 . '<b class="mlsimport-property-calculator__legend-amount" data-amt="' . esc_attr( $key ) . '"></b>'
1958 . '</li>';
1959 }
1960
1961 // label, data-calc key, value, step (controls grid).
1962 $controls = array(
1963 array( __( 'Home price ($)', 'mlsimport' ), 'price', (string) (int) $price, '1000' ),
1964 array( __( 'Down payment (%)', 'mlsimport' ), 'down', '20', '1' ),
1965 array( __( 'Interest rate (%)', 'mlsimport' ), 'rate', '6.5', '0.01' ),
1966 array( __( 'Loan term (years)', 'mlsimport' ), 'term', '30', '1' ),
1967 array( __( 'Property tax ($/mo)', 'mlsimport' ), 'tax', (string) $tax, '1' ),
1968 array( __( 'Home insurance ($/mo)', 'mlsimport' ), 'ins', '120', '1' ),
1969 array( __( 'HOA ($/mo)', 'mlsimport' ), 'hoa', (string) $hoa, '1' ),
1970 );
1971 // One numeric input per control row.
1972 $fields = '';
1973 foreach ( $controls as $c ) {
1974 $fields .= '<label class="mlsimport-property-calculator__field">'
1975 . '<span class="mlsimport-property-calculator__field-label">' . esc_html( $c[0] ) . '</span>'
1976 . '<input type="number" inputmode="decimal" step="' . esc_attr( $c[3] ) . '" min="0" data-calc="' . esc_attr( $c[1] ) . '" value="' . esc_attr( $c[2] ) . '" />'
1977 . '</label>';
1978 }
1979
1980 // Titled section; the summary (headline + bar + legend), then the controls.
1981 $html = mlsimport_property_section_open( 'calculator', __( 'Mortgage Calculator', 'mlsimport' ), 'calc' );
1982 $html .= '<div class="mlsimport-property-calculator" data-mlsimport-calculator>';
1983 $html .= '<div class="mlsimport-property-calculator__summary">';
1984 $html .= '<div class="mlsimport-property-calculator__headline">'
1985 . '<span class="mlsimport-property-calculator__headline-label">' . esc_html__( 'Estimated monthly payment', 'mlsimport' ) . '</span>'
1986 . '<span class="mlsimport-property-calculator__amount" data-calc="result">—</span>'
1987 . '</div>';
1988 $html .= '<div class="mlsimport-property-calculator__bar" aria-hidden="true">' . $bar . '</div>';
1989 $html .= '<ul class="mlsimport-property-calculator__legend">' . $legend . '</ul>';
1990 $html .= '</div>';
1991 $html .= '<div class="mlsimport-property-calculator__controls">' . $fields . '</div>';
1992 $html .= '<p class="mlsimport-property-calculator__note">' . esc_html__( 'Estimates only and not a loan offer. Taxes, insurance and rates vary — confirm with a lender.', 'mlsimport' ) . '</p>';
1993 $html .= '</div>';
1994 $html .= mlsimport_property_section_close();
1995 return $html;
1996 }
1997
1998 /**
1999 * Map section — a pin at the listing's coordinates, drawn with Leaflet +
2000 * OpenStreetMap. The section enqueues the map assets itself and renders through
2001 * mlsimport-property-map.js. No geocoding — uses lat/lng.
2002 *
2003 * @param int $id Property post ID.
2004 * @param array $args Behavioral options.
2005 * @return string
2006 */
2007 function mlsimport_property_map( int $id = 0, array $args = array() ): string {
2008 $data = mlsimport_property_data( $id );
2009 if ( empty( $data ) ) {
2010 return '';
2011 }
2012
2013 // The address rows ride the same facts grid as every other section, so they obey
2014 // the column setting and carry the same hairline dividers.
2015 $rows = array(
2016 array( __( 'Street', 'mlsimport' ), $data['street'] ),
2017 array( __( 'City', 'mlsimport' ), $data['city'] ),
2018 array( __( 'Subdivision', 'mlsimport' ), $data['subdivision'] ),
2019 array( __( 'County', 'mlsimport' ), $data['county'] ),
2020 array( __( 'State', 'mlsimport' ), $data['state'] ),
2021 array( __( 'Zip', 'mlsimport' ), $data['zip'] ),
2022 array( __( 'Country', 'mlsimport' ), $data['country'] ),
2023 array( __( 'MLS #', 'mlsimport' ), $data['mls_id'] ),
2024 );
2025 // Keep only address rows that carry a value.
2026 $facts = array();
2027 foreach ( $rows as $row ) {
2028 if ( '' !== (string) $row[1] ) {
2029 $facts[] = array( $row[0], (string) $row[1] );
2030 }
2031 }
2032 $grid = mlsimport_property_facts_grid_html( $facts, mlsimport_property_columns_class() . ' mlsimport-property-details__grid--address', (int) ( $data['id'] ?? 0 ) );
2033
2034 // A map only draws when both coordinates are present.
2035 $has_map = ( null !== $data['latitude'] && null !== $data['longitude'] );
2036 // Neither address rows nor a map → no section.
2037 if ( '' === $grid && ! $has_map ) {
2038 return '';
2039 }
2040
2041 // Titled "Address" section: the address grid, then optionally the map canvas.
2042 $html = mlsimport_property_section_open( 'map', __( 'Address', 'mlsimport' ), 'pin' );
2043 $html .= $grid;
2044 // Address-only listing (no coordinates): close and return here.
2045 if ( ! $has_map ) {
2046 $html .= mlsimport_property_section_close();
2047 return $html;
2048 }
2049
2050 // Enqueue the map assets.
2051 mlsimport_property_map_enqueue();
2052
2053 // Price-pin + info-card payload: the marker is a price pill, clicking it opens
2054 // a card (image, title, price, beds/baths/area).
2055 $thumb_id = (int) $data['thumbnail_id'];
2056 $pin_image = $thumb_id ? (string) wp_get_attachment_image_url( $thumb_id, 'medium' ) : '';
2057 if ( '' === $pin_image && '' !== ( $data['image_url'] ?? '' ) ) {
2058 // Live listings carry media as URLs, not attachments.
2059 $pin_image = (string) $data['image_url'];
2060 }
2061 // Formatted price + bed/bath counts for the info-card.
2062 $price_fmt = null !== $data['price'] ? mlsimport_format_price( $data['price'] ) : '';
2063 $beds = null !== $data['bedrooms'] ? mlsimport_format_amount( $data['bedrooms'] ) : '';
2064 $baths = null !== $data['bathrooms'] ? mlsimport_format_amount( $data['bathrooms'] ) : '';
2065 // Whole-number ft², matching the listing card's spec line (which uses
2066 // number_format_i18n at 0 decimals) rather than leaking a fractional area.
2067 $area = null !== $data['living_area'] ? number_format_i18n( (float) $data['living_area'] ) : '';
2068
2069 // The map canvas: JS reads the coords + info-card payload from these data attrs.
2070 $html .= '<div class="mlsimport-property-map__canvas" data-mlsimport-map'
2071 . ' data-lat="' . esc_attr( (string) $data['latitude'] ) . '"'
2072 . ' data-lng="' . esc_attr( (string) $data['longitude'] ) . '"'
2073 . ' data-title="' . esc_attr( $data['title'] ) . '"'
2074 . ' data-url="' . esc_url( $data['permalink'] ) . '"'
2075 . ' data-image="' . esc_attr( $pin_image ) . '"'
2076 . ' data-price="' . esc_attr( $price_fmt ) . '"'
2077 . ' data-price-raw="' . esc_attr( null !== $data['price'] ? (string) $data['price'] : '' ) . '"'
2078 . ' data-beds="' . esc_attr( $beds ) . '"'
2079 . ' data-baths="' . esc_attr( $baths ) . '"'
2080 . ' data-area="' . esc_attr( $area ) . '"'
2081 . ' data-zoom="' . esc_attr( (string) mlsimport_standalone_map_zoom() ) . '"'
2082 . ' data-tile="' . esc_attr( (string) apply_filters( 'mlsimport_map_tile_url', 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' ) ) . '"'
2083 . '></div>';
2084 $html .= mlsimport_property_section_close();
2085 return $html;
2086 }
2087
2088 /**
2089 * Enqueue the Leaflet + OpenStreetMap assets the map section needs.
2090 *
2091 * @return void
2092 */
2093 function mlsimport_property_map_enqueue(): void {
2094 // Nothing to do outside a WP front-end request.
2095 if ( ! function_exists( 'wp_enqueue_script' ) ) {
2096 return;
2097 }
2098 // Register the plugin's map scripts/styles once.
2099 Mlsimport_Property_Section_Assets::ensure_registered();
2100
2101 wp_enqueue_style( 'mlsimport-leaflet' );
2102 wp_enqueue_script( 'mlsimport-leaflet' );
2103 wp_enqueue_script( 'mlsimport-property-map' );
2104 }
2105
2106 /**
2107 * Embed an external media URL (virtual tour or video) as a responsive iframe.
2108 * One render fn for both; the manifest bakes which view-model field to read.
2109 *
2110 * @param int $id Property post ID.
2111 * @param array $args { source: virtual_tour|video, title: string }
2112 * @return string
2113 */
2114 function mlsimport_property_embed( int $id = 0, array $args = array() ): string {
2115 $data = mlsimport_property_data( $id );
2116 if ( empty( $data ) ) {
2117 return '';
2118 }
2119
2120 // Which media field this call renders, and its URL from the view model.
2121 $source = isset( $args['source'] ) ? (string) $args['source'] : 'virtual_tour';
2122 $url = 'video' === $source ? $data['video_url'] : $data['virtual_tour'];
2123 // No URL → no section.
2124 if ( '' === trim( (string) $url ) ) {
2125 return '';
2126 }
2127
2128 // Per-source slug, heading, icon and badge label.
2129 $slug = 'video' === $source ? 'video' : 'virtual-tour';
2130 $title = 'video' === $source ? __( 'Video', 'mlsimport' ) : __( 'Virtual Tour', 'mlsimport' );
2131 $icon = 'video' === $source ? 'video' : 'cube';
2132 $badge = 'video' === $source ? __( 'Video', 'mlsimport' ) : __( '3D Walkthrough', 'mlsimport' );
2133
2134 // Poster + play that opens the tour/video. A live iframe is avoided on purpose:
2135 // most providers (Zillow, Matterport, YouTube privacy mode) block framing, so a
2136 // poster that launches the URL is both robust and matches the design.
2137 // Poster attachment: the thumbnail, else the first gallery image.
2138 $poster_id = $data['thumbnail_id'];
2139 if ( ! $poster_id && ! empty( $data['gallery_ids'] ) ) {
2140 $poster_id = (int) $data['gallery_ids'][0];
2141 }
2142 $poster_img = $poster_id ? wp_get_attachment_image( $poster_id, 'large', false, array( 'class' => 'mlsimport-property-poster__img' ) ) : '';
2143 // No attachment poster (e.g. live mode): fall back to a raw image URL.
2144 if ( '' === $poster_img ) {
2145 // Live listings carry media as URLs, not attachments.
2146 $poster_url = '' !== ( $data['image_url'] ?? '' ) ? (string) $data['image_url'] : (string) ( $data['gallery_urls'][0] ?? '' );
2147 if ( '' !== $poster_url ) {
2148 $poster_img = '<img class="mlsimport-property-poster__img" src="' . esc_url( $poster_url ) . '" alt="" />';
2149 }
2150 }
2151
2152 // Titled section: a poster link that opens the external tour/video in a new tab.
2153 $html = mlsimport_property_section_open( $slug, $title, $icon );
2154 $html .= '<a class="mlsimport-property-poster mlsimport-property-' . esc_attr( $slug ) . '__poster" href="' . esc_url( $url ) . '" target="_blank" rel="noopener noreferrer">';
2155 $html .= $poster_img;
2156 $html .= '<span class="mlsimport-property-poster__badge">' . mlsimport_property_icon( $icon ) . '<span>' . esc_html( $badge ) . '</span></span>';
2157 $html .= '<span class="mlsimport-property-poster__play" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor" focusable="false"><path d="M9 7l9 5-9 5z"/></svg></span>';
2158 $html .= '</a>';
2159 $html .= mlsimport_property_section_close();
2160 return $html;
2161 }
2162
2163 /**
2164 * Outbound share targets for a listing, shared by the Share section and the
2165 * title-bar share popup so both offer the same networks.
2166 *
2167 * @param string $url Listing permalink.
2168 * @param string $title Listing title.
2169 * @return array<string,string> Label => href.
2170 */
2171 function mlsimport_property_share_targets( string $url, string $title ): array {
2172 // Both the URL and the title travel inside query strings / mailto parts.
2173 $enc = rawurlencode( $url );
2174 $enct = rawurlencode( $title );
2175
2176 /**
2177 * Filters the outbound share targets offered for a listing.
2178 *
2179 * @param array<string,string> $targets Label => href.
2180 * @param string $url Listing permalink.
2181 * @param string $title Listing title.
2182 */
2183 return apply_filters(
2184 'mlsimport_property_share_targets',
2185 array(
2186 'Facebook' => 'https://www.facebook.com/sharer/sharer.php?u=' . $enc,
2187 'X' => 'https://twitter.com/intent/tweet?url=' . $enc . '&text=' . $enct,
2188 'WhatsApp' => 'https://api.whatsapp.com/send?text=' . $enct . '%20' . $enc,
2189 'Email' => 'mailto:?subject=' . $enct . '&body=' . $enc,
2190 ),
2191 $url,
2192 $title
2193 );
2194 }
2195
2196 /**
2197 * Share / print section — social share links + copy-link + print.
2198 *
2199 * @param int $id Property post ID.
2200 * @param array $args Behavioral options.
2201 * @return string
2202 */
2203 function mlsimport_property_share( int $id = 0, array $args = array() ): string {
2204 // A share section needs a permalink to point at.
2205 $data = mlsimport_property_data( $id );
2206 if ( empty( $data ) || '' === $data['permalink'] ) {
2207 return '';
2208 }
2209
2210 // Outbound share targets.
2211 $url = $data['permalink'];
2212 $links = mlsimport_property_share_targets( $url, $data['title'] );
2213
2214 // Titled section: one button per share target, then copy-link + print.
2215 $html = mlsimport_property_section_open( 'share', __( 'Share', 'mlsimport' ), 'share' );
2216 $html .= '<div class="mlsimport-property-share__actions">';
2217 foreach ( $links as $label => $href ) {
2218 $html .= '<a class="mlsimport-property-share__button" href="' . esc_url( $href ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( $label ) . '</a>';
2219 }
2220 $html .= '<button type="button" class="mlsimport-property-share__button" data-mlsimport-copy="' . esc_attr( $url ) . '">' . esc_html__( 'Copy link', 'mlsimport' ) . '</button>';
2221 $html .= '<button type="button" class="mlsimport-property-share__button" data-mlsimport-print="' . esc_attr( mlsimport_property_print_url( $url ) ) . '">' . esc_html__( 'Print', 'mlsimport' ) . '</button>';
2222 $html .= '</div>';
2223 $html .= mlsimport_property_section_close();
2224 return $html;
2225 }
2226
2227 /**
2228 * Similar listings section — other listings that share the current property's
2229 * taxonomy terms, the way WpResidence computes related properties (city / type /
2230 * action), rendered with the existing card grid.
2231 *
2232 * Matching is taxonomy-driven (not the flat city column): import always assigns
2233 * mlsimport_city / mlsimport_property_type / mlsimport_listing_type terms, so
2234 * this finds siblings even when the flat city column is empty. Like WpResidence,
2235 * the term sets are ANDed; if that is too narrow to return anything, it relaxes
2236 * to the city term alone so the section still populates.
2237 *
2238 * @param int $id Property post ID.
2239 * @param array $args { limit: int }
2240 * @return string
2241 */
2242 function mlsimport_property_similar( int $id = 0, array $args = array() ): string {
2243 // The card renderer supplies the listing-card markup reused here.
2244 require_once __DIR__ . '/class-mlsimport-standalone-render.php';
2245
2246 // Need a resolved current property to find siblings of.
2247 $data = mlsimport_property_data( $id );
2248 if ( empty( $data ) ) {
2249 return '';
2250 }
2251 // Current post id + how many similar listings to show (settings, args win).
2252 $pid = (int) $data['id'];
2253 $limit = isset( $args['limit'] ) ? (int) $args['limit'] : (int) mlsimport_standalone_option( 'similar_count', 3 );
2254 $limit = max( 1, $limit );
2255
2256 /** Filter the taxonomies used to match similar listings (WpResidence parity). @since 6.4 */
2257 $taxes = (array) apply_filters(
2258 'mlsimport_similar_taxonomies',
2259 array( 'mlsimport_city', 'mlsimport_property_type', 'mlsimport_listing_type' ),
2260 $pid
2261 );
2262
2263 // Build a tax_query clause per taxonomy the current listing has terms in,
2264 // remembering the city clause so it can serve as the relaxed fallback.
2265 $clauses = array();
2266 $city_cl = null;
2267 foreach ( $taxes as $tax ) {
2268 $terms = get_the_terms( $pid, $tax );
2269 // Skip a taxonomy the listing carries no terms in.
2270 if ( ! is_array( $terms ) || empty( $terms ) ) {
2271 continue;
2272 }
2273 $clause = array( 'taxonomy' => $tax, 'field' => 'term_id', 'terms' => wp_list_pluck( $terms, 'term_id' ) );
2274 $clauses[] = $clause;
2275 // Keep the city clause aside for the fallback query.
2276 if ( 'mlsimport_city' === $tax ) {
2277 $city_cl = $clause;
2278 }
2279 }
2280 // No matchable terms → no section.
2281 if ( empty( $clauses ) ) {
2282 return '';
2283 }
2284
2285 // First try the full AND match across every clause.
2286 $ids = mlsimport_property_similar_ids( $pid, $clauses, $limit );
2287 // Relax to the city anchor when the full AND match is too narrow to fill out.
2288 if ( empty( $ids ) && $city_cl && count( $clauses ) > 1 ) {
2289 $ids = mlsimport_property_similar_ids( $pid, array( $city_cl ), $limit );
2290 }
2291 // Still nothing → no section.
2292 if ( empty( $ids ) ) {
2293 return '';
2294 }
2295
2296 // Render the matched listings as cards; empty markup means no section.
2297 $cards = Mlsimport_Standalone_Render::cards_for_posts( $ids );
2298 if ( '' === trim( $cards ) ) {
2299 return '';
2300 }
2301
2302 // Cards per row: --mli-cols drives the grid, so the narrow-screen media queries
2303 // (2 then 1 across) still override it.
2304 $per_row = (int) mlsimport_standalone_option( 'similar_per_row', 3 );
2305 $per_row = max( 2, min( 4, $per_row ) );
2306
2307 // Titled section wrapping the card grid.
2308 $html = mlsimport_property_section_open( 'similar', __( 'Similar Listings', 'mlsimport' ), 'grid' );
2309 $html .= '<div class="mlsimport-results__grid" style="--mli-cols:' . esc_attr( (string) $per_row ) . '">' . $cards . '</div>';
2310 $html .= mlsimport_property_section_close();
2311 return $html;
2312 }
2313
2314 /**
2315 * Run the similar-listings query for a set of tax_query clauses and return the
2316 * matching property IDs (newest first), excluding the current listing.
2317 *
2318 * @param int $exclude Current property ID to exclude.
2319 * @param array $clauses tax_query clauses (each taxonomy/field/terms).
2320 * @param int $limit Max results.
2321 * @return int[]
2322 */
2323 function mlsimport_property_similar_ids( int $exclude, array $clauses, int $limit ): array {
2324 // Multiple clauses are ANDed together (all terms must match).
2325 $tax_query = $clauses;
2326 if ( count( $clauses ) > 1 ) {
2327 $tax_query['relation'] = 'AND';
2328 }
2329 // IDs-only query for the newest matching listings, excluding the current one.
2330 $query = new WP_Query(
2331 array(
2332 'post_type' => 'mlsimport_property',
2333 'post_status' => 'publish',
2334 'posts_per_page' => $limit,
2335 'post__not_in' => array( $exclude ),
2336 'tax_query' => $tax_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
2337 'orderby' => 'date',
2338 'order' => 'DESC',
2339 'no_found_rows' => true,
2340 'ignore_sticky_posts' => true,
2341 'fields' => 'ids',
2342 )
2343 );
2344 return array_map( 'intval', (array) $query->posts );
2345 }
2346
2347 /**
2348 * Header section — compact identity block: title, address, status, price.
2349 *
2350 * @param int $id Property post ID.
2351 * @param array $args Behavioral options.
2352 * @return string
2353 */
2354 function mlsimport_property_header( int $id = 0, array $args = array() ): string {
2355 $data = mlsimport_property_data( $id );
2356 if ( empty( $data ) ) {
2357 return '';
2358 }
2359
2360 // Build the identity block, appending each part only when it has a value.
2361 $inner = '';
2362 if ( '' !== $data['title'] ) {
2363 $inner .= '<h1 class="mlsimport-property-title__heading">' . esc_html( $data['title'] ) . '</h1>';
2364 }
2365 if ( '' !== $data['address'] ) {
2366 $inner .= '<p class="mlsimport-property-address__line">' . esc_html( $data['address'] ) . '</p>';
2367 }
2368 if ( '' !== $data['status'] ) {
2369 $inner .= '<span class="mlsimport-property-status__badge">' . esc_html( $data['status'] ) . '</span>';
2370 }
2371 if ( null !== $data['price'] ) {
2372 $inner .= '<p class="mlsimport-property-price__amount">' . esc_html( mlsimport_format_price( $data['price'] ) ) . '</p>';
2373 }
2374 // Nothing populated → no section.
2375 if ( '' === $inner ) {
2376 return '';
2377 }
2378
2379 // Headingless wrapper around the identity block.
2380 $html = mlsimport_property_section_open( 'header' );
2381 $html .= '<div class="mlsimport-property-header__inner">' . $inner . '</div>';
2382 $html .= mlsimport_property_section_close();
2383 return $html;
2384 }
2385
2386 /**
2387 * The shared lead-form fields markup (name / email / phone [/ tour date] /
2388 * message [/ interest dropdown] + mandatory privacy consent + honeypot +
2389 * hidden property_id + nonce). One source for the four lead-form section
2390 * variants; the booking sidebar builds its own panels but shares the consent
2391 * checkbox via mlsimport_property_lead_consent_field().
2392 *
2393 * @param array $data Property view model.
2394 * @param string $variant contact|form|sidebar|tour.
2395 * @return string
2396 */
2397 function mlsimport_property_lead_fields( array $data, string $variant ): string {
2398 // CSRF nonce checked by the lead handler.
2399 $nonce = wp_create_nonce( Mlsimport_Property_Lead::NONCE );
2400
2401 // Core contact inputs: name + email required, phone optional.
2402 $fields = '<input type="text" name="mlsimport_name" required placeholder="' . esc_attr__( 'Your name', 'mlsimport' ) . '" />';
2403 $fields .= '<input type="email" name="mlsimport_email" required placeholder="' . esc_attr__( 'Your email', 'mlsimport' ) . '" />';
2404 $fields .= '<input type="tel" name="mlsimport_phone" placeholder="' . esc_attr__( 'Your phone', 'mlsimport' ) . '" />';
2405 // The tour variant adds a preferred-date picker.
2406 if ( 'tour' === $variant ) {
2407 $fields .= '<input type="date" name="mlsimport_tour_date" aria-label="' . esc_attr__( 'Preferred tour date', 'mlsimport' ) . '" />';
2408 }
2409 $fields .= '<textarea name="mlsimport_message" rows="4" placeholder="' . esc_attr__( 'Message', 'mlsimport' ) . '"></textarea>';
2410
2411 // Optional interest dropdown + mandatory consent checkbox.
2412 $fields .= mlsimport_property_lead_interest_field();
2413 $fields .= mlsimport_property_lead_consent_field();
2414
2415 // Honeypot: real users leave it blank; bots fill it.
2416 // Hidden plumbing: honeypot, the property id, and the nonce.
2417 $fields .= '<input type="text" name="mlsimport_hp" class="mlsimport-property-lead-form__hp" tabindex="-1" autocomplete="off" aria-hidden="true" />';
2418 $fields .= '<input type="hidden" name="property_id" value="' . esc_attr( (string) $data['id'] ) . '" />';
2419 // Live listings have no post id (0) — the ListingKey URL is the listing
2420 // context, picked up by the lead handler's generic mlsimport_* collector.
2421 if ( '' !== ( $data['listing_key'] ?? '' ) ) {
2422 $fields .= '<input type="hidden" name="mlsimport_listing" value="' . esc_attr( mlsimport_live_url( (string) $data['listing_key'] ) ) . '" />';
2423 }
2424 $fields .= '<input type="hidden" name="nonce" value="' . esc_attr( $nonce ) . '" />';
2425
2426 /** Filter the lead form fields markup. @since 6.3 */
2427 return (string) apply_filters( 'mlsimport_property_lead_fields', $fields, $variant, $data['id'] );
2428 }
2429
2430 /**
2431 * The "I'm interested in" intent dropdown (Buy / Rent / Sell by default), or ''
2432 * when the show_looking_dropdown setting gates it off or looking_options is
2433 * empty. Shared by the lead-form sections and the booking sidebar's Ask a
2434 * Question panel; the mlsimport_interest value reaches the lead email via the
2435 * generic collector ("Interest: X").
2436 *
2437 * @return string
2438 */
2439 function mlsimport_property_lead_interest_field(): string {
2440 // Gated off unless the setting explicitly enables the dropdown.
2441 if ( 'yes' !== mlsimport_standalone_option( 'show_looking_dropdown' ) ) {
2442 return '';
2443 }
2444 // Parse the comma-separated options; empty list → no dropdown.
2445 $choices = array_filter( array_map( 'trim', explode( ',', (string) mlsimport_standalone_option( 'looking_options' ) ) ) );
2446 if ( empty( $choices ) ) {
2447 return '';
2448 }
2449
2450 // A real <select> inside a wrapper: mlsimport-property-interest.js swaps in a
2451 // styled button + listbox and writes every pick back here, because a native
2452 // option list is OS-drawn and cannot be made to match the rest of the site.
2453 // With JS off the select is simply the control, so nothing is lost.
2454 $html = '<div class="mlsimport-interest">';
2455 $html .= '<select name="mlsimport_interest" aria-label="' . esc_attr__( "I'm interested in", 'mlsimport' ) . '">';
2456 $html .= '<option value="">' . esc_html__( "I'm interested in…", 'mlsimport' ) . '</option>';
2457 foreach ( $choices as $choice ) {
2458 $html .= '<option value="' . esc_attr( $choice ) . '">' . esc_html( $choice ) . '</option>';
2459 }
2460 return $html . '</select></div>';
2461 }
2462
2463 /**
2464 * The mandatory privacy-consent checkbox. Every form that submits a property
2465 * lead (the lead-form sections AND the booking sidebar panels) renders it —
2466 * Mlsimport_Property_Lead::process() rejects a property submission without it,
2467 * so the browser `required` here is the courtesy layer, not the gate. Label
2468 * and link text come from the consent_label / terms_link_text settings; the
2469 * link target is the site's WP privacy page (plain text when none is set).
2470 *
2471 * @return string
2472 */
2473 function mlsimport_property_lead_consent_field(): string {
2474 // Consent lead-in text (settings override, else a default phrase).
2475 $text = (string) mlsimport_standalone_option( 'consent_label' );
2476 if ( '' === $text ) {
2477 $text = __( 'I have read and agree to the', 'mlsimport' );
2478 }
2479 // Link text from settings; the target is the site's privacy page when set.
2480 $link_text = (string) mlsimport_standalone_option( 'terms_link_text' );
2481 $policy_url = get_privacy_policy_url();
2482 $link = '' !== $policy_url
2483 ? '<a href="' . esc_url( $policy_url ) . '" target="_blank" rel="noopener">' . esc_html( $link_text ) . '</a>'
2484 : esc_html( $link_text );
2485
2486 // Required checkbox + label; the server also enforces consent.
2487 return '<label class="mlsimport-property-lead-form__consent">'
2488 . '<input type="checkbox" name="mlsimport_consent" value="yes" required />'
2489 . '<span>' . esc_html( $text ) . ' ' . $link . '</span>'
2490 . '</label>';
2491 }
2492
2493 /**
2494 * The badges beside the listing title: its Status, then what the listing IS —
2495 * the listing type (Residential), the property type (Single Family Residence) and
2496 * the sub-type when the feed carries one.
2497 *
2498 * Status is the loud badge; the type badges are quiet, because a visitor scanning
2499 * the page wants "is it still for sale" before "what kind of building is it". Feeds
2500 * routinely repeat themselves — Stellar sends "Residential" as BOTH the listing type
2501 * and the property type — so a value already shown is not shown twice.
2502 *
2503 * @param array $data Property view model (mlsimport_property_data()).
2504 * @return string
2505 */
2506 function mlsimport_property_title_bar_chips( array $data ): string {
2507 $chips = '';
2508 // Case-insensitive de-dupe set so a repeated value is shown once.
2509 $seen = array();
2510
2511 // Status is the loud "accent" chip; the type chips are quiet "soft" ones.
2512 $badges = array(
2513 array( (string) ( $data['status'] ?? '' ), 'accent' ),
2514 array( (string) ( $data['listing_type'] ?? '' ), 'soft' ),
2515 array( (string) ( $data['property_type'] ?? '' ), 'soft' ),
2516 array( (string) ( $data['property_sub_type'] ?? '' ), 'soft' ),
2517 );
2518
2519 /** Filter the title-bar badges: [ label, 'accent'|'soft' ] pairs. @since 6.4 */
2520 $badges = (array) apply_filters( 'mlsimport_property_title_bar_chips', $badges, $data );
2521
2522 foreach ( $badges as $badge ) {
2523 $label = trim( (string) $badge[0] );
2524 $key = strtolower( $label );
2525 // Skip blanks and any value already emitted.
2526 if ( '' === $label || isset( $seen[ $key ] ) ) {
2527 continue;
2528 }
2529 $seen[ $key ] = true;
2530
2531 // Chip carrying its accent/soft modifier; the label links to its term archive.
2532 $chips .= '<span class="mlsimport-property-title-bar__chip mlsimport-property-title-bar__chip--' . esc_attr( $badge[1] ) . '">'
2533 . mlsimport_property_link_term( (int) ( $data['id'] ?? 0 ), $label )
2534 . '</span>';
2535 }
2536
2537 return $chips;
2538 }
2539
2540 /**
2541 * Title bar — the listing hero: status/type chips, title, address, the MLS#
2542 * /days-on-market/updated meta row, and the price block with share/save/print.
2543 *
2544 * @param int $id Property post ID.
2545 * @param array $args Behavioral options.
2546 * @return string
2547 */
2548 function mlsimport_property_title_bar( int $id = 0, array $args = array() ): string {
2549 // Need at least a title or a price to justify the hero.
2550 $data = mlsimport_property_data( $id );
2551 if ( empty( $data ) || ( '' === $data['title'] && null === $data['price'] ) ) {
2552 return '';
2553 }
2554
2555 // Left column: chips, title, address, meta.
2556 $left = '';
2557
2558 // Status/type chips, when any resolve.
2559 $chips = mlsimport_property_title_bar_chips( $data );
2560 if ( '' !== $chips ) {
2561 $left .= '<div class="mlsimport-property-title-bar__chips">' . $chips . '</div>';
2562 }
2563
2564 // Title heading, when present.
2565 if ( '' !== $data['title'] ) {
2566 $left .= '<h1 class="mlsimport-property-title-bar__title">' . esc_html( $data['title'] ) . '</h1>';
2567 }
2568 // Address line with a pin icon, when present.
2569 if ( '' !== $data['address'] ) {
2570 $left .= '<p class="mlsimport-property-title-bar__address">' . mlsimport_property_icon( 'pin' ) . '<span>' . esc_html( $data['address'] ) . '</span></p>';
2571 }
2572
2573 // Meta row: MLS#, days-on-market, last-updated — each added only when present.
2574 $meta = '';
2575 if ( '' !== (string) $data['mls_id'] ) {
2576 $meta .= '<span class="mlsimport-property-title-bar__meta-item">' . mlsimport_property_icon( 'hash' ) . '<span>' . esc_html( sprintf( /* translators: %s: MLS id. */ __( 'MLS# %s', 'mlsimport' ), $data['mls_id'] ) ) . '</span></span>';
2577 }
2578 if ( null !== $data['days_on_market'] ) {
2579 /* translators: %d: days on market. */
2580 $meta .= '<span class="mlsimport-property-title-bar__meta-item">' . mlsimport_property_icon( 'clock' ) . '<span>' . esc_html( sprintf( _n( '%d day on market', '%d days on market', $data['days_on_market'], 'mlsimport' ), $data['days_on_market'] ) ) . '</span></span>';
2581 }
2582 if ( '' !== (string) $data['updated'] ) {
2583 $meta .= '<span class="mlsimport-property-title-bar__meta-item">' . mlsimport_property_icon( 'calendar' ) . '<span>' . esc_html( sprintf( /* translators: %s: date. */ __( 'Updated %s', 'mlsimport' ), $data['updated'] ) ) . '</span></span>';
2584 }
2585 // Wrap the meta items only when at least one exists.
2586 if ( '' !== $meta ) {
2587 $left .= '<div class="mlsimport-property-title-bar__meta">' . $meta . '</div>';
2588 }
2589
2590 // Right column: price + actions.
2591 $right = '';
2592 if ( null !== $data['price'] ) {
2593 // Optional price-per-sqft line above the headline price.
2594 if ( null !== $data['price_per_sqft'] ) {
2595 $right .= '<p class="mlsimport-property-title-bar__psf">' . esc_html( mlsimport_format_price( $data['price_per_sqft'] ) ) . ' <span>/ ' . esc_html__( 'sqft', 'mlsimport' ) . '</span></p>';
2596 }
2597 $right .= '<p class="mlsimport-property-title-bar__price">' . esc_html( mlsimport_format_price( $data['price'] ) ) . '</p>';
2598 }
2599
2600 // Action buttons: share (toggles a popup of share targets), favorite, print.
2601 $url = $data['permalink'];
2602
2603 // Popup content: one link per network, then copy-link, as siblings of the button.
2604 $menu = '';
2605 foreach ( mlsimport_property_share_targets( $url, $data['title'] ) as $label => $href ) {
2606 $menu .= '<a class="mlsimport-property-share-menu__item" href="' . esc_url( $href ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( $label ) . '</a>';
2607 }
2608 $menu .= '<button type="button" class="mlsimport-property-share-menu__item" data-mlsimport-copy="' . esc_attr( $url ) . '"><span data-copy-label>' . esc_html__( 'Copy link', 'mlsimport' ) . '</span></button>';
2609
2610 $actions = '<div class="mlsimport-property-title-bar__share">';
2611 $actions .= '<button type="button" class="mlsimport-property-title-bar__action" data-mlsimport-share-toggle aria-expanded="false">' . mlsimport_property_icon( 'share' ) . '<span>' . esc_html__( 'Share', 'mlsimport' ) . '</span></button>';
2612 $actions .= '<div class="mlsimport-property-share-menu">' . $menu . '</div>';
2613 $actions .= '</div>';
2614 // Real, persisted favorite (shared store with the listing-card hearts). The
2615 // ListingKey is the durable identity: live/passthrough mode carries it on $data
2616 // (post id 0), stored mode resolves it from the post meta.
2617 $fav_key = '' !== (string) ( $data['listing_key'] ?? '' ) ? (string) $data['listing_key'] : Mlsimport_Favorites::listing_key_for( (int) $data['id'] );
2618 $actions .= Mlsimport_Favorites::single_button_html( (int) $data['id'], $fav_key );
2619 $actions .= '<button type="button" class="mlsimport-property-title-bar__action" data-mlsimport-print="' . esc_attr( mlsimport_property_print_url( $url ) ) . '">' . mlsimport_property_icon( 'print' ) . '<span>' . esc_html__( 'Print', 'mlsimport' ) . '</span></button>';
2620 $right .= '<div class="mlsimport-property-title-bar__actions">' . $actions . '</div>';
2621
2622 // "Reduced from" line only when the original price was strictly higher.
2623 if ( null !== $data['original_price'] && null !== $data['price'] && (float) $data['original_price'] > (float) $data['price'] ) {
2624 $right .= '<p class="mlsimport-property-title-bar__reduced">' . mlsimport_property_icon( 'arrow-down' ) . '<span>' . esc_html( sprintf( /* translators: %s: original price. */ __( 'Reduced from %s', 'mlsimport' ), mlsimport_format_price( $data['original_price'] ) ) ) . '</span></p>';
2625 }
2626
2627 // Two-column hero wrapper.
2628 $html = mlsimport_property_section_open( 'title-bar' );
2629 $html .= '<div class="mlsimport-property-title-bar">';
2630 $html .= '<div class="mlsimport-property-title-bar__left">' . $left . '</div>';
2631 $html .= '<div class="mlsimport-property-title-bar__right">' . $right . '</div>';
2632 $html .= '</div>';
2633 $html .= mlsimport_property_section_close();
2634 return $html;
2635 }
2636
2637 /**
2638 * In-page sub-navigation — a sticky bar of anchor links that jump to the
2639 * sections present on the page (only links whose target section exists render).
2640 *
2641 * @param int $id Property post ID.
2642 * @param array $args Behavioral options.
2643 * @return string
2644 */
2645 function mlsimport_property_subnav( int $id = 0, array $args = array() ): string {
2646 $data = mlsimport_property_data( $id );
2647 if ( empty( $data ) ) {
2648 return '';
2649 }
2650
2651 // label => anchor target id (the section's sanitized anchor). The field
2652 // sections are asked what they actually render, so the nav never offers a jump
2653 // link to a section that isn't on the page.
2654 $items = array_merge(
2655 array(
2656 __( 'Overview', 'mlsimport' ) => 'mlsimport-section-overview',
2657 __( 'Description', 'mlsimport' ) => 'mlsimport-section-description',
2658 __( 'Tour', 'mlsimport' ) => 'mlsimport-section-virtual-tour',
2659 __( 'Map', 'mlsimport' ) => 'mlsimport-section-map',
2660 ),
2661 mlsimport_property_subnav_field_items( (int) $data['id'] ),
2662 array(
2663 __( 'Features', 'mlsimport' ) => 'mlsimport-section-features',
2664 __( 'Mortgage', 'mlsimport' ) => 'mlsimport-section-calculator',
2665 __( 'Agent', 'mlsimport' ) => 'mlsimport-section-agent',
2666 )
2667 );
2668 /** Filter the sub-nav items (label => anchor id). @since 6.4 */
2669 $items = (array) apply_filters( 'mlsimport_property_subnav_items', $items, $id );
2670
2671 // One jump link per item (target is the section's anchor id).
2672 $links = '';
2673 foreach ( $items as $label => $target ) {
2674 $links .= '<a class="mlsimport-property-subnav__link" href="#' . esc_attr( $target ) . '" data-target="' . esc_attr( $target ) . '">' . esc_html( $label ) . '</a>';
2675 }
2676 // No links → no sub-nav.
2677 if ( '' === $links ) {
2678 return '';
2679 }
2680
2681 // The strip carries the arrows. Fifteen section chips do not fit on a phone, so
2682 // the nav scrolls — and a scroll strip with no visible scrollbar needs a way to
2683 // say "there is more this way". The buttons are hidden (and the whole strip is
2684 // inert) whenever the chips already fit; see mlsimport-property-subnav.js.
2685 $html = mlsimport_property_section_open( 'subnav' );
2686 $html .= '<div class="mlsimport-property-subnav__strip" data-subnav-strip>';
2687 $html .= '<button type="button" class="mlsimport-property-subnav__arrow mlsimport-property-subnav__arrow--prev" data-subnav-prev aria-label="' . esc_attr__( 'Scroll sections left', 'mlsimport' ) . '">' . mlsimport_property_icon( 'chevron-left' ) . '</button>';
2688 $html .= '<nav class="mlsimport-property-subnav" data-mlsimport-subnav aria-label="' . esc_attr__( 'Property sections', 'mlsimport' ) . '">' . $links . '</nav>';
2689 $html .= '<button type="button" class="mlsimport-property-subnav__arrow mlsimport-property-subnav__arrow--next" data-subnav-next aria-label="' . esc_attr__( 'Scroll sections right', 'mlsimport' ) . '">' . mlsimport_property_icon( 'chevron-right' ) . '</button>';
2690 $html .= '</div>';
2691 $html .= mlsimport_property_section_close();
2692 return $html;
2693 }
2694
2695 /**
2696 * Booking sidebar — the sticky rail: agent mini, a Schedule-a-Tour / Ask-a-
2697 * Question tab pair (both post to the one lead endpoint), and a beds/baths/sqft
2698 * mini-stats card.
2699 *
2700 * @param int $id Property post ID.
2701 * @param array $args Behavioral options.
2702 * @return string
2703 */
2704 function mlsimport_property_booking( int $id = 0, array $args = array() ): string {
2705 $data = mlsimport_property_data( $id );
2706 if ( empty( $data ) ) {
2707 return '';
2708 }
2709
2710 // Resolved agent for the mini card and the "Call" shortcut (may be null).
2711 $agent = ! empty( $data['agent'] ) ? $data['agent'] : null;
2712
2713 // Nonce + hidden inputs shared by both booking forms.
2714 $nonce = wp_create_nonce( Mlsimport_Property_Lead::NONCE );
2715 $hidden = '<input type="text" name="mlsimport_hp" class="mlsimport-property-lead-form__hp" tabindex="-1" autocomplete="off" aria-hidden="true" />'
2716 . '<input type="hidden" name="property_id" value="' . esc_attr( (string) $data['id'] ) . '" />'
2717 . '<input type="hidden" name="nonce" value="' . esc_attr( $nonce ) . '" />';
2718 // Live listings have no post id — carry the ListingKey URL as the context.
2719 if ( '' !== ( $data['listing_key'] ?? '' ) ) {
2720 $hidden .= '<input type="hidden" name="mlsimport_listing" value="' . esc_attr( mlsimport_live_url( (string) $data['listing_key'] ) ) . '" />';
2721 }
2722 // Shared contact inputs reused by both panels.
2723 $contact = '<input type="text" name="mlsimport_name" required placeholder="' . esc_attr__( 'Full name', 'mlsimport' ) . '" />'
2724 . '<input type="email" name="mlsimport_email" required placeholder="' . esc_attr__( 'Email', 'mlsimport' ) . '" />'
2725 . '<input type="tel" name="mlsimport_phone" placeholder="' . esc_attr__( 'Phone', 'mlsimport' ) . '" />';
2726
2727 // --- Tab nav ---
2728 $nav = '<button type="button" class="mlsimport-property-booking__tab is-active" data-tab="tour" aria-selected="true">' . esc_html__( 'Schedule a Tour', 'mlsimport' ) . '</button>';
2729 $nav .= '<button type="button" class="mlsimport-property-booking__tab" data-tab="ask" aria-selected="false">' . esc_html__( 'Ask a Question', 'mlsimport' ) . '</button>';
2730
2731 // --- Tour panel: day strip, time slots ---
2732 $base = function_exists( 'current_time' ) ? (int) current_time( 'timestamp' ) : time(); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- local-day strip for display.
2733 $days = '';
2734 // A month of days; the strip is a slider, so the arrows page through them.
2735 for ( $i = 0; $i < 30; $i++ ) {
2736 // This day's timestamp and its display parts (dow / day-num / month).
2737 $ts = $base + $i * DAY_IN_SECONDS;
2738 $iso = date_i18n( 'Y-m-d', $ts );
2739 $dow = date_i18n( 'D', $ts );
2740 $dnum = date_i18n( 'd', $ts );
2741 $mon = date_i18n( 'M', $ts );
2742 $days .= '<button type="button" class="mlsimport-property-booking__day' . ( 0 === $i ? ' is-active' : '' ) . '" data-day="' . esc_attr( $iso ) . '" data-label="' . esc_attr( $dow . ' ' . $dnum . ' ' . $mon ) . '">'
2743 . '<span class="mlsimport-property-booking__day-dow">' . esc_html( $dow ) . '</span>'
2744 . '<span class="mlsimport-property-booking__day-num">' . esc_html( $dnum ) . '</span>'
2745 . '<span class="mlsimport-property-booking__day-mon">' . esc_html( $mon ) . '</span>'
2746 . '</button>';
2747 }
2748
2749 // Time slots are admin-configurable (Standalone settings → Property Page → Tour
2750 // Details), stored as a comma-separated list. Blank means the admin does not
2751 // offer fixed slots, so the whole picker is left out.
2752 $slots = array_filter( array_map( 'trim', explode( ',', (string) mlsimport_standalone_option( 'tour_times' ) ) ), 'strlen' );
2753 // One time-slot button per slot; the first starts active.
2754 $times = '';
2755 foreach ( array_values( $slots ) as $i => $slot ) {
2756 $times .= '<button type="button" class="mlsimport-property-booking__time' . ( 0 === $i ? ' is-active' : '' ) . '" data-time="' . esc_attr( $slot ) . '">' . esc_html( $slot ) . '</button>';
2757 }
2758
2759 // --- Tour panel: day/time pickers + contact form ---
2760 $tour = '<div class="mlsimport-property-booking__panel is-active" data-panel="tour">';
2761 $tour .= '<form class="mlsimport-property-lead-form" data-mlsimport-lead data-success-title="' . esc_attr__( 'Tour requested', 'mlsimport' ) . '" method="post">';
2762 // The day strip scrolls as a slider; the arrows sit on the label's own line.
2763 $tour .= '<div class="mlsimport-property-booking__picker-head">';
2764 $tour .= '<p class="mlsimport-property-booking__field-label">' . esc_html__( 'Select a day', 'mlsimport' ) . '</p>';
2765 $tour .= '<div class="mlsimport-property-booking__days-nav">';
2766 $tour .= '<button type="button" class="mlsimport-property-booking__arrow" data-days-prev aria-label="' . esc_attr__( 'Previous days', 'mlsimport' ) . '">' . mlsimport_property_icon( 'chevron-left' ) . '</button>';
2767 $tour .= '<button type="button" class="mlsimport-property-booking__arrow" data-days-next aria-label="' . esc_attr__( 'Next days', 'mlsimport' ) . '">' . mlsimport_property_icon( 'chevron-right' ) . '</button>';
2768 $tour .= '</div>';
2769 $tour .= '</div>';
2770 $tour .= '<div class="mlsimport-property-booking__days" data-days>' . $days . '</div>';
2771 if ( '' !== $times ) {
2772 $tour .= '<p class="mlsimport-property-booking__field-label">' . esc_html__( 'Preferred time', 'mlsimport' ) . '</p>';
2773 $tour .= '<div class="mlsimport-property-booking__times">' . $times . '</div>';
2774 }
2775 $tour .= $contact;
2776 // JS composes "<day> at <time> (<mode>)" into this field; the lead email reads it.
2777 $tour .= '<input type="hidden" name="mlsimport_tour_date" data-tour-summary value="" />';
2778 $tour .= mlsimport_property_lead_consent_field();
2779 $tour .= $hidden;
2780 $tour .= '<button type="submit">' . esc_html__( 'Request This Tour', 'mlsimport' ) . '</button>';
2781 $tour .= '<p class="mlsimport-property-booking__note">' . mlsimport_property_icon( 'badge' ) . '<span>' . esc_html__( 'Free tour, no obligation — cancel anytime.', 'mlsimport' ) . '</span></p>';
2782 $tour .= '<div class="mlsimport-property-lead-form__status" role="status" aria-live="polite"></div>';
2783 $tour .= '</form>';
2784 $tour .= mlsimport_property_booking_success( __( 'Tour requested', 'mlsimport' ), __( 'The agent will confirm your tour time shortly.', 'mlsimport' ) );
2785 $tour .= '</div>';
2786
2787 // --- Ask panel ---
2788 $ask = '<div class="mlsimport-property-booking__panel" data-panel="ask" hidden>';
2789 $ask .= '<form class="mlsimport-property-lead-form" data-mlsimport-lead data-success-title="' . esc_attr__( 'Message sent', 'mlsimport' ) . '" method="post">';
2790 $ask .= $contact;
2791 $ask .= '<textarea name="mlsimport_message" rows="3" placeholder="' . esc_attr__( 'Message', 'mlsimport' ) . '">' . esc_textarea( sprintf( /* translators: %s: listing title. */ __( "Hello, I'm interested in %s", 'mlsimport' ), $data['title'] ) ) . '</textarea>';
2792 $ask .= mlsimport_property_lead_interest_field();
2793 $ask .= mlsimport_property_lead_consent_field();
2794 $ask .= $hidden;
2795 $ask .= '<button type="submit">' . esc_html__( 'Send Message', 'mlsimport' ) . '</button>';
2796 // A one-tap call shortcut when the agent has a phone.
2797 if ( $agent && '' !== (string) $agent['phone'] ) {
2798 $tel = preg_replace( '/[^0-9+]/', '', (string) $agent['phone'] );
2799 $ask .= '<a class="mlsimport-property-booking__call" href="tel:' . esc_attr( $tel ) . '">' . mlsimport_property_icon( 'phone' ) . '<span>' . esc_html__( 'Call', 'mlsimport' ) . '</span></a>';
2800 }
2801 $ask .= '<div class="mlsimport-property-lead-form__status" role="status" aria-live="polite"></div>';
2802 $ask .= '</form>';
2803 $ask .= mlsimport_property_booking_success( __( 'Message sent', 'mlsimport' ), __( 'The agent will get back to you shortly.', 'mlsimport' ) );
2804 $ask .= '</div>';
2805
2806 // Both panels sit inside the sticky card.
2807 $panels = $tour . $ask;
2808
2809 // Headingless wrapper + the booking card.
2810 $html = mlsimport_property_section_open( 'booking' );
2811 $html .= '<div class="mlsimport-property-booking" data-mlsimport-booking>';
2812 $html .= '<div class="mlsimport-property-booking__card">';
2813
2814 // Agent mini (avatar + name), only when an agent with a name resolved. A
2815 // feed-sourced agent's name may not appear on the contact form (#181) — the
2816 // company name from Social & Contact fronts the card instead.
2817 $card_name = $agent ? ( ! empty( $agent['is_feed'] ) ? (string) mlsimport_standalone_option( 'company_name', '' ) : (string) $agent['name'] ) : '';
2818 $card_role = ( $agent && ! empty( $agent['is_feed'] ) ) ? __( 'Contact', 'mlsimport' ) : __( 'Listing Agent', 'mlsimport' );
2819 if ( $agent && '' !== $card_name ) {
2820 $avatar = ( empty( $agent['is_feed'] ) && ! empty( $agent['photo_id'] ) )
2821 ? wp_get_attachment_image( (int) $agent['photo_id'], 'thumbnail', false, array( 'class' => 'mlsimport-property-booking__avatar-img' ) )
2822 : '<span class="mlsimport-property-booking__initials">' . esc_html( mlsimport_property_initials( $card_name ) ) . '</span>';
2823 $html .= '<div class="mlsimport-property-booking__agent">'
2824 . '<span class="mlsimport-property-booking__avatar">' . $avatar . '</span>'
2825 . '<span class="mlsimport-property-booking__agent-text">'
2826 . '<span class="mlsimport-property-booking__agent-name">' . esc_html( $card_name ) . '</span>'
2827 . '<span class="mlsimport-property-booking__agent-role">' . esc_html( $card_role ) . '</span>'
2828 . '</span></div>';
2829 }
2830
2831 $html .= '<div class="mlsimport-property-booking__tabs" role="tablist">' . $nav . '</div>';
2832 $html .= $panels;
2833 $html .= '</div>'; // card.
2834
2835 $html .= '</div>'; // booking.
2836 $html .= mlsimport_property_section_close();
2837 return $html;
2838 }
2839
2840 /**
2841 * The success confirmation block a booking form swaps in after a sent lead
2842 * (revealed by mlsimport-property-lead.js when the form's request succeeds).
2843 *
2844 * @param string $title Headline (e.g. "Tour requested").
2845 * @param string $text Sub-text.
2846 * @return string
2847 */
2848 function mlsimport_property_booking_success( string $title, string $text ): string {
2849 return '<div class="mlsimport-property-booking__success" data-lead-success hidden>'
2850 . '<span class="mlsimport-property-booking__success-icon" aria-hidden="true">' . mlsimport_property_icon( 'check' ) . '</span>'
2851 . '<p class="mlsimport-property-booking__success-title">' . esc_html( $title ) . '</p>'
2852 . '<p class="mlsimport-property-booking__success-text">' . esc_html( $text ) . '</p>'
2853 . '</div>';
2854 }
2855
2856 /**
2857 * The admin's MLS disclaimer, resolved for one listing.
2858 *
2859 * The wording is mandated by the MLS and identical on every property, so it is
2860 * authored once in Property Page -> MLS Attribution. Variables make the one text
2861 * serve every listing: %mls_id% (this listing's MLS number), %year% (so a
2862 * copyright line never goes stale), %agent_phone% / %agent_email% for the boards
2863 * that require the listing agent to be reachable here, and %office_phone% /
2864 * %office_email% / %attribution_contact% for the ones that require the listing
2865 * office instead. The agent's name and the office name are not variables — the
2866 * block prints those itself. Blank text prints nothing; to drop the whole block,
2867 * disable the MLS Attribution section in the page layout.
2868 *
2869 * @param string $mls_id This listing's MLS number.
2870 * @param int $id Property post ID; 0 leaves the contact variables empty.
2871 * @return string Paragraph markup, already sanitized. Safe to echo unescaped.
2872 */
2873 function mlsimport_property_attribution_text( string $mls_id = '', int $id = 0 ): string {
2874 // The admin-authored template; blank means print nothing.
2875 $raw = (string) mlsimport_standalone_option( 'attribution_text' );
2876 if ( '' === trim( $raw ) ) {
2877 return '';
2878 }
2879
2880 // Contact channels only: the agent's name and the office name already print
2881 // in the block's own courtesy and facts lines, so offering them as variables
2882 // too would just let a disclaimer repeat what is directly above it.
2883 // These read the property's OWN feed meta, exactly like that courtesy line
2884 // (#169) — never the company contacts #181 substitutes on the agent card, and
2885 // never a manually picked agent, so a board's mandated wording always reaches
2886 // the agent the MLS actually sent. A field that was never ticked for import
2887 // resolves to '', the same way %mls_id% already does for a listing with no
2888 // MLS number.
2889 $feed = static function ( string $key ) use ( $id ) {
2890 return $id ? (string) get_post_meta( $id, 'mlsimport_' . $key, true ) : '';
2891 };
2892
2893 // Substitute every placeholder the admin may have used. The office channels
2894 // carry no #181 privacy question — a brokerage line is a business contact, not
2895 // a person's — and AttributionContact is the RESO field a board names when it
2896 // mandates one specific display contact, so it gets its own token rather than
2897 // silently standing in for an empty %office_phone%.
2898 $text = strtr(
2899 $raw,
2900 array(
2901 '%mls_id%' => $mls_id,
2902 '%year%' => date_i18n( 'Y' ),
2903 '%agent_phone%' => $feed( 'ListAgentPreferredPhone' ),
2904 '%agent_email%' => $feed( 'ListAgentEmail' ),
2905 '%office_phone%' => $feed( 'ListOfficePhone' ),
2906 '%office_email%' => $feed( 'ListOfficeEmail' ),
2907 '%attribution_contact%' => $feed( 'AttributionContact' ),
2908 )
2909 );
2910
2911 /** Filter the resolved MLS disclaimer text (pre-markup). @since 6.4 */
2912 $text = (string) apply_filters( 'mlsimport_property_attribution_text', $text, $mls_id );
2913
2914 // Sanitize then paragraph-wrap for output.
2915 return wpautop( wp_kses_post( $text ) );
2916 }
2917
2918 /**
2919 * MLS / IDX attribution — the listing-courtesy line plus the admin's mandated
2920 * disclaimer.
2921 *
2922 * @param int $id Property post ID.
2923 * @param array $args Behavioral options.
2924 * @return string
2925 */
2926 function mlsimport_property_attribution( int $id = 0, array $args = array() ): string {
2927 $data = mlsimport_property_data( $id );
2928 if ( empty( $data ) ) {
2929 return '';
2930 }
2931
2932 // Legal attribution names the FEED listing office/agent, never the (possibly
2933 // manually overridden) display agent — feed_* is the property's own meta (#169).
2934 $agent = ! empty( $data['agent'] ) ? $data['agent'] : array();
2935 $office = isset( $agent['feed_office'] ) ? (string) $agent['feed_office'] : '';
2936 $name = isset( $agent['feed_name'] ) ? (string) $agent['feed_name'] : '';
2937 $phone = isset( $agent['feed_phone'] ) ? (string) $agent['feed_phone'] : '';
2938
2939 // "Listing courtesy of <office>" line, when the feed office is known.
2940 $courtesy = '';
2941 if ( '' !== $office ) {
2942 $courtesy = sprintf( /* translators: %s: listing office name. */ __( 'Listing courtesy of %s', 'mlsimport' ), $office );
2943 }
2944
2945 // Meta facts: MLS#, feed listing agent, agent preferred phone, last-updated —
2946 // each added when present.
2947 $facts = array();
2948 if ( '' !== (string) $data['mls_id'] ) {
2949 $facts[] = sprintf( /* translators: %s: MLS id. */ __( 'MLS# %s', 'mlsimport' ), $data['mls_id'] );
2950 }
2951 if ( '' !== $name ) {
2952 $facts[] = sprintf( /* translators: %s: listing agent name. */ __( 'Listing agent %s', 'mlsimport' ), $name );
2953 }
2954 // The feed agent's preferred phone (RESO ListAgentPreferredPhone), straight
2955 // after the name so the line reads "Listing agent <name> · Phone <phone>".
2956 if ( '' !== $phone ) {
2957 $facts[] = sprintf( /* translators: %s: listing agent phone number. */ __( 'Phone %s', 'mlsimport' ), $phone );
2958 }
2959 if ( '' !== (string) $data['updated'] ) {
2960 $facts[] = sprintf( /* translators: %s: date. */ __( 'Data last updated %s', 'mlsimport' ), $data['updated'] );
2961 }
2962
2963 // Neither a courtesy line nor any facts → no section.
2964 if ( '' === $courtesy && empty( $facts ) ) {
2965 return '';
2966 }
2967
2968 // The mandated disclaimer (already markup) and the optional MLS logo.
2969 $disclaimer = mlsimport_property_attribution_text( (string) $data['mls_id'], (int) $data['id'] );
2970 $logo = function_exists( 'mlsimport_standalone_mls_logo_url' ) ? mlsimport_standalone_mls_logo_url() : '';
2971
2972 // The section wrapper already carries the .mlsimport-property-attribution
2973 // class (via the 'attribution' slug), which supplies the box chrome. A
2974 // second inner wrapper with the same class produced a border-in-a-border,
2975 // so the content sits directly in the section body.
2976 $html = mlsimport_property_section_open( 'attribution' );
2977 // Head row (logo + courtesy line), only when either is present.
2978 if ( '' !== $logo || '' !== $courtesy ) {
2979 $html .= '<div class="mlsimport-property-attribution__head">';
2980 if ( '' !== $logo ) {
2981 $html .= '<img class="mlsimport-property-attribution__logo" src="' . esc_url( $logo ) . '" alt="' . esc_attr__( 'MLS', 'mlsimport' ) . '" />';
2982 }
2983 if ( '' !== $courtesy ) {
2984 $html .= '<span class="mlsimport-property-attribution__courtesy">' . esc_html( $courtesy ) . '</span>';
2985 }
2986 $html .= '</div>';
2987 }
2988 // The facts joined into one meta line.
2989 $html .= '<p class="mlsimport-property-attribution__meta">' . esc_html( implode( ' · ', $facts ) ) . '</p>';
2990 // The mandated disclaimer, when set.
2991 if ( '' !== $disclaimer ) {
2992 // Already wp_kses_post'd in the resolver; escaping again would print the tags.
2993 $html .= '<div class="mlsimport-property-attribution__disclaimer">' . $disclaimer . '</div>';
2994 }
2995 $html .= mlsimport_property_section_close();
2996 return $html;
2997 }
2998