PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2
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 6.0.7 All 35 releases
mlsimport / includes / standalone / property-sections.php

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

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