PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.1.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.1.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 6.0.5 All 36 releases
mlsimport / includes / standalone / property-sections.php

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

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