| 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 |
|