| 1 |
<?php |
| 2 |
/** |
| 3 |
* Live mode: the virtual single-property page. |
| 4 |
* |
| 5 |
* Live listings have no posts, so they get a virtual route instead of a |
| 6 |
* permalink: /{base}/{ListingKey}/ (base filterable, default "listing"). |
| 7 |
* The route resolves through template_include — fetch the record (cached), |
| 8 |
* 404 when the MLS doesn't know the key, else render the loop-less live |
| 9 |
* template. The property sections then read their view model through seam #2 |
| 10 |
* (mlsimport_property_data_pre), built here from the raw RESO record with the |
| 11 |
* same helpers the stored VM uses. |
| 12 |
* |
| 13 |
* @package Mlsimport |
| 14 |
*/ |
| 15 |
|
| 16 |
if ( ! defined( 'ABSPATH' ) ) { |
| 17 |
exit; |
| 18 |
} |
| 19 |
|
| 20 |
/** |
| 21 |
* The URL base segment for live single pages. |
| 22 |
* |
| 23 |
* @return string |
| 24 |
*/ |
| 25 |
function mlsimport_live_url_base(): string { |
| 26 |
/** Filter the live single-page URL base segment. @since 6.4 */ |
| 27 |
$base = (string) apply_filters( 'mlsimport_live_url_base', 'listing' ); |
| 28 |
return '' !== $base ? $base : 'listing'; |
| 29 |
} |
| 30 |
|
| 31 |
/** |
| 32 |
* The virtual single-page URL for one live listing. |
| 33 |
* |
| 34 |
* @param string $listing_key RESO ListingKey. |
| 35 |
* @return string |
| 36 |
*/ |
| 37 |
function mlsimport_live_url( string $listing_key ): string { |
| 38 |
return home_url( '/' . mlsimport_live_url_base() . '/' . rawurlencode( $listing_key ) . '/' ); |
| 39 |
} |
| 40 |
|
| 41 |
/** |
| 42 |
* Register the live single route (rewrite + query var) while live mode is on. |
| 43 |
* The settings screen flushes rewrite rules when the toggle changes, so the |
| 44 |
* rule appears/disappears with the mode. |
| 45 |
* |
| 46 |
* @return void |
| 47 |
*/ |
| 48 |
function mlsimport_live_register_route(): void { |
| 49 |
if ( ! mlsimport_live_mode_active() ) { |
| 50 |
return; |
| 51 |
} |
| 52 |
add_rewrite_rule( |
| 53 |
'^' . mlsimport_live_url_base() . '/([^/]+)/?$', |
| 54 |
'index.php?mlsimport_live_listing=$matches[1]', |
| 55 |
'top' |
| 56 |
); |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* Expose the live listing query var. |
| 61 |
* |
| 62 |
* @param string[] $vars Public query vars. |
| 63 |
* @return string[] |
| 64 |
*/ |
| 65 |
function mlsimport_live_query_vars( array $vars ): array { |
| 66 |
$vars[] = 'mlsimport_live_listing'; |
| 67 |
return $vars; |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* The raw RESO record behind the page being rendered (set by the route, |
| 72 |
* read by the VM seam). Request-scoped. |
| 73 |
* |
| 74 |
* @param array|null $set When given, becomes the current record. |
| 75 |
* @return array|null |
| 76 |
*/ |
| 77 |
function mlsimport_live_current_record( ?array $set = null ): ?array { |
| 78 |
static $record = null; |
| 79 |
if ( null !== $set ) { |
| 80 |
$record = $set; |
| 81 |
} |
| 82 |
return $record; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Resolve the virtual route: fetch the listing (cached) and serve the live |
| 87 |
* single template, or 404 when the key is unknown/unreachable. |
| 88 |
* |
| 89 |
* @param string $template The template WP resolved. |
| 90 |
* @return string |
| 91 |
*/ |
| 92 |
function mlsimport_live_template_include( $template ) { |
| 93 |
$key = (string) get_query_var( 'mlsimport_live_listing' ); |
| 94 |
if ( '' === $key || ! mlsimport_live_mode_active() ) { |
| 95 |
return $template; |
| 96 |
} |
| 97 |
|
| 98 |
$record = mlsimport_live_get( $key ); |
| 99 |
if ( ! is_array( $record ) ) { |
| 100 |
global $wp_query; |
| 101 |
$wp_query->set_404(); |
| 102 |
status_header( 404 ); |
| 103 |
return get_404_template(); |
| 104 |
} |
| 105 |
|
| 106 |
mlsimport_live_current_record( $record ); |
| 107 |
return MLSIMPORT_PLUGIN_PATH . 'templates/live/single-mlsimport-property-live.php'; |
| 108 |
} |
| 109 |
|
| 110 |
/** |
| 111 |
* Seam #2 callback: answer mlsimport_property_data( 0 ) with the live VM while |
| 112 |
* a live record is being rendered. Real post IDs keep the stored behavior. |
| 113 |
* |
| 114 |
* @param array|null $pre Short-circuit value (null = build normally). |
| 115 |
* @param int $id The requested property post ID. |
| 116 |
* @return array|null |
| 117 |
*/ |
| 118 |
function mlsimport_live_property_data_pre( $pre, int $id ) { |
| 119 |
if ( null !== $pre || 0 !== $id ) { |
| 120 |
return $pre; |
| 121 |
} |
| 122 |
$record = mlsimport_live_current_record(); |
| 123 |
if ( null === $record ) { |
| 124 |
return $pre; |
| 125 |
} |
| 126 |
|
| 127 |
static $vm = null; |
| 128 |
if ( null === $vm ) { |
| 129 |
$vm = mlsimport_live_property_vm( $record ); |
| 130 |
} |
| 131 |
return $vm; |
| 132 |
} |
| 133 |
|
| 134 |
/** |
| 135 |
* Build the property view model from a raw RESO record — the same keys |
| 136 |
* mlsimport_property_data() assembles from the post + flat row, built with |
| 137 |
* the same helpers, so every section renders unchanged. id is 0 (no post); |
| 138 |
* media is URL-based (gallery_urls / image_url from Media[]). |
| 139 |
* |
| 140 |
* @param array $record Raw RESO property record. |
| 141 |
* @return array |
| 142 |
*/ |
| 143 |
function mlsimport_live_property_vm( array $record ): array { |
| 144 |
$row = mlsimport_live_row_from_reso( $record ); |
| 145 |
|
| 146 |
// Same reader contracts the stored assembly uses: $meta by RESO field name |
| 147 |
// (stored meta keys are mlsimport_<Field>, so names line up 1:1), $num/$str |
| 148 |
// from the flat-row columns. |
| 149 |
$meta = static function ( $key ) use ( $record ) { |
| 150 |
return isset( $record[ $key ] ) && is_scalar( $record[ $key ] ) ? (string) $record[ $key ] : ''; |
| 151 |
}; |
| 152 |
$num = static function ( $col ) use ( $row ) { |
| 153 |
return isset( $row->$col ) && null !== $row->$col && '' !== $row->$col ? (float) $row->$col : null; |
| 154 |
}; |
| 155 |
$str = static function ( $col ) use ( $row ) { |
| 156 |
return isset( $row->$col ) ? (string) $row->$col : ''; |
| 157 |
}; |
| 158 |
|
| 159 |
$gallery_urls = mlsimport_live_media_urls( $record ); |
| 160 |
$address = mlsimport_property_build_address( $meta, $str ); |
| 161 |
|
| 162 |
$vm = array( |
| 163 |
'id' => 0, |
| 164 |
'title' => $address, |
| 165 |
'permalink' => mlsimport_live_url( (string) $row->listing_key ), |
| 166 |
'content' => $meta( 'PublicRemarks' ), |
| 167 |
'excerpt' => '', |
| 168 |
|
| 169 |
// Price + variants. |
| 170 |
'price' => $num( 'price' ), |
| 171 |
'price_per_sqft' => ( null !== $num( 'price' ) && $num( 'living_area' ) ) ? (int) round( $num( 'price' ) / $num( 'living_area' ) ) : null, |
| 172 |
'original_price' => '' !== $meta( 'OriginalListPrice' ) ? (float) $meta( 'OriginalListPrice' ) : null, |
| 173 |
'close_price' => '' !== $meta( 'ClosePrice' ) ? (float) $meta( 'ClosePrice' ) : null, |
| 174 |
'previous_price' => '' !== $meta( 'PreviousListPrice' ) ? (float) $meta( 'PreviousListPrice' ) : null, |
| 175 |
'hoa_fee' => '' !== $meta( 'AssociationFee' ) ? (float) $meta( 'AssociationFee' ) : null, |
| 176 |
'hoa_frequency' => $meta( 'AssociationFeeFrequency' ), |
| 177 |
|
| 178 |
// Structure / facts. |
| 179 |
'bedrooms' => $num( 'bedrooms' ), |
| 180 |
'bathrooms' => $num( 'bathrooms' ), |
| 181 |
'living_area' => $num( 'living_area' ), |
| 182 |
'lot_size' => $num( 'lot_size' ), |
| 183 |
'year_built' => null !== $num( 'year_built' ) ? (int) $num( 'year_built' ) : null, |
| 184 |
'garage' => '' !== $meta( 'GarageSpaces' ) ? (int) (float) $meta( 'GarageSpaces' ) : null, |
| 185 |
'stories' => '' !== $meta( 'StoriesTotal' ) ? (int) (float) $meta( 'StoriesTotal' ) : null, |
| 186 |
'days_on_market' => null !== $num( 'days_on_market' ) ? (int) $num( 'days_on_market' ) : null, |
| 187 |
|
| 188 |
// Location. |
| 189 |
'street' => mlsimport_property_street_line( $meta ), |
| 190 |
'city' => $str( 'city' ), |
| 191 |
'state' => $str( 'state' ), |
| 192 |
'zip' => $str( 'zip' ), |
| 193 |
'subdivision' => $str( 'subdivision' ), |
| 194 |
'county' => $meta( 'CountyOrParish' ), |
| 195 |
'country' => 'US' === $meta( 'Country' ) ? __( 'United States', 'mlsimport' ) : $meta( 'Country' ), |
| 196 |
'latitude' => '' !== $meta( 'Latitude' ) ? (float) $meta( 'Latitude' ) : null, |
| 197 |
'longitude' => '' !== $meta( 'Longitude' ) ? (float) $meta( 'Longitude' ) : null, |
| 198 |
'address' => $address, |
| 199 |
|
| 200 |
// Type / status. |
| 201 |
'property_type' => $str( 'property_type' ), |
| 202 |
'property_sub_type' => $meta( 'PropertySubType' ), |
| 203 |
'listing_type' => $str( 'listing_type' ), |
| 204 |
'status' => '' !== $str( 'status' ) ? $str( 'status' ) : $meta( 'MlsStatus' ), |
| 205 |
|
| 206 |
// Provenance / freshness (display-only). |
| 207 |
'mls_id' => '' !== $meta( 'ListingId' ) ? $meta( 'ListingId' ) : $meta( 'ListingKey' ), |
| 208 |
'updated' => mlsimport_property_format_date( $meta( 'ModificationTimestamp' ) ), |
| 209 |
|
| 210 |
// Media: CDN URLs, no attachments. |
| 211 |
'thumbnail_id' => 0, |
| 212 |
'image_url' => array() === $gallery_urls ? '' : $gallery_urls[0], |
| 213 |
'gallery_ids' => array(), |
| 214 |
'gallery_urls' => $gallery_urls, |
| 215 |
'virtual_tour' => $meta( 'VirtualTourURLUnbranded' ), |
| 216 |
'video_url' => $meta( 'VideoURL' ), |
| 217 |
|
| 218 |
// Features: no amenity terms without a post. |
| 219 |
'features' => array(), |
| 220 |
|
| 221 |
// Agent from the record's ListAgent*/ListOffice* fields (id 0 = no linked post). |
| 222 |
'agent' => mlsimport_property_agent( 0, $meta ), |
| 223 |
|
| 224 |
// Live extras: the key for lead/context use + the raw record. |
| 225 |
'listing_key' => (string) $row->listing_key, |
| 226 |
); |
| 227 |
|
| 228 |
/** Filter the property view model — same seam the stored VM passes. @since 6.3 */ |
| 229 |
return (array) apply_filters( 'mlsimport_property_data', $vm, 0 ); |
| 230 |
} |
| 231 |
|
| 232 |
add_action( 'init', 'mlsimport_live_register_route' ); |
| 233 |
add_filter( 'query_vars', 'mlsimport_live_query_vars' ); |
| 234 |
add_filter( 'template_include', 'mlsimport_live_template_include', 20 ); |
| 235 |
add_filter( 'mlsimport_property_data_pre', 'mlsimport_live_property_data_pre', 10, 2 ); |
| 236 |
|