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

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

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