prefix . 'mlsimport_listings'; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE post_id = %d", $id ) ); // The WP post backs title/permalink/body/excerpt (may be null in live mode). $post = get_post( $id ); // One primed read of all post meta; a closure pulls mlsimport_ scalars. $all_meta = get_post_meta( $id ); $meta = static function ( $key ) use ( $all_meta ) { return isset( $all_meta[ 'mlsimport_' . $key ][0] ) ? $all_meta[ 'mlsimport_' . $key ][0] : ''; }; // Flat-table column => RESO meta-key fallback. The search row is canonical for // filtering/sorting, but listings imported before the row was populated keep // their values only in post meta — so each scalar falls back to meta when the // row value is absent (one rule that keeps display working whether or not the // search row has been (re)built). $col_meta = array( 'price' => 'ListPrice', 'bedrooms' => 'BedroomsTotal', 'bathrooms' => 'BathroomsTotalDecimal', 'living_area' => 'LivingArea', 'year_built' => 'YearBuilt', 'days_on_market' => 'DaysOnMarket', 'latitude' => 'Latitude', 'longitude' => 'Longitude', 'city' => 'City', 'state' => 'StateOrProvince', 'zip' => 'PostalCode', 'subdivision' => 'SubdivisionName', 'property_type' => 'PropertyType', ); // Numeric scalar: flat row first, then the mapped meta key, else null. $num = static function ( $col ) use ( $row, $meta, $col_meta ) { if ( $row && isset( $row->$col ) && null !== $row->$col && '' !== $row->$col ) { return (float) $row->$col; } if ( isset( $col_meta[ $col ] ) ) { $m = $meta( $col_meta[ $col ] ); if ( '' !== $m ) { return (float) $m; } } return null; }; // String scalar: flat row first, then the mapped meta key, else ''. $str = static function ( $col ) use ( $row, $meta, $col_meta ) { if ( $row && isset( $row->$col ) && '' !== (string) $row->$col ) { return (string) $row->$col; } return isset( $col_meta[ $col ] ) ? (string) $meta( $col_meta[ $col ] ) : ''; }; // Assemble the normalized view model — the only shape any section reads. $vm = array( 'id' => $id, 'title' => $post ? get_the_title( $id ) : '', 'permalink' => (string) get_permalink( $id ), 'content' => $post ? (string) $post->post_content : '', 'excerpt' => $post ? (string) $post->post_excerpt : '', // Price + variants. 'price' => $num( 'price' ), 'price_per_sqft' => ( null !== $num( 'price' ) && $num( 'living_area' ) ) ? (int) round( $num( 'price' ) / $num( 'living_area' ) ) : null, 'original_price' => '' !== $meta( 'OriginalListPrice' ) ? (float) $meta( 'OriginalListPrice' ) : null, 'close_price' => '' !== $meta( 'ClosePrice' ) ? (float) $meta( 'ClosePrice' ) : null, 'previous_price' => '' !== $meta( 'PreviousListPrice' ) ? (float) $meta( 'PreviousListPrice' ) : null, 'hoa_fee' => $num( 'hoa_fee' ), 'hoa_frequency' => (string) $meta( 'AssociationFeeFrequency' ), // Structure / facts. 'bedrooms' => $num( 'bedrooms' ), 'bathrooms' => $num( 'bathrooms' ), 'living_area' => $num( 'living_area' ), 'lot_size' => $num( 'lot_size' ), 'year_built' => null !== $num( 'year_built' ) ? (int) $num( 'year_built' ) : null, 'garage' => null !== $num( 'garage_spaces' ) ? (int) $num( 'garage_spaces' ) : null, 'stories' => null !== $num( 'stories' ) ? (int) $num( 'stories' ) : null, 'days_on_market' => null !== $num( 'days_on_market' ) ? (int) $num( 'days_on_market' ) : null, // Location. 'street' => mlsimport_property_street_line( $meta ), 'city' => $str( 'city' ), 'state' => $str( 'state' ), 'zip' => $str( 'zip' ), 'subdivision' => $str( 'subdivision' ), 'county' => (string) $meta( 'CountyOrParish' ), 'country' => 'US' === (string) $meta( 'Country' ) ? __( 'United States', 'mlsimport' ) : (string) $meta( 'Country' ), 'latitude' => $num( 'latitude' ), 'longitude' => $num( 'longitude' ), 'address' => mlsimport_property_build_address( $meta, $str ), // Type / status. 'property_type' => $str( 'property_type' ), 'property_sub_type' => (string) $meta( 'PropertySubType' ), 'listing_type' => $str( 'listing_type' ), 'status' => '' !== $str( 'status' ) ? $str( 'status' ) : (string) $meta( 'MlsStatus' ), // Provenance / freshness (display-only). 'mls_id' => '' !== (string) $meta( 'ListingId' ) ? (string) $meta( 'ListingId' ) : (string) $meta( 'ListingKey' ), 'updated' => mlsimport_property_format_date( (string) $meta( 'ModificationTimestamp' ) ), // Raw (unformatted) listing date for machine consumers such as JSON-LD // datePosted. The ListingContractDate/OnMarketDate preference lives in // Mlsimport_Standalone_Derive so there is one rule, not two. 'list_date' => (string) Mlsimport_Standalone_Derive::derive_list_date( array( 'ListingContractDate' => $meta( 'ListingContractDate' ), 'OnMarketDate' => $meta( 'OnMarketDate' ), ) ), // Media. 'thumbnail_id' => (int) get_post_thumbnail_id( $id ), 'image_url' => (string) ( get_post_thumbnail_id( $id ) ? wp_get_attachment_image_url( get_post_thumbnail_id( $id ), 'large' ) : '' ), 'gallery_ids' => mlsimport_property_gallery_ids( $id ), 'virtual_tour' => (string) $meta( 'virtual_tour' ), 'video_url' => (string) $meta( 'VideoURL' ), // Features (amenity terms). 'features' => mlsimport_property_feature_names( $id ), // Resolved agent (linked post preferred, property meta fallback). 'agent' => mlsimport_property_agent( $id, $meta ), ); /** Filter the property view model — the single value every section reads. @since 6.3 */ $vm = (array) apply_filters( 'mlsimport_property_data', $vm, $id ); // Memoize and hand back the assembled model. $cache[ $id ] = $vm; return $vm; } /** * Assemble a one-line street address from RESO parts (UnparsedAddress wins). * * @param callable $meta Meta reader: ( string $key ) => string. * @param callable $str Row string reader: ( string $col ) => string. * @return string */ function mlsimport_property_build_address( callable $meta, callable $str ): string { // RESO's pre-composed UnparsedAddress wins outright when the feed carries it. $unparsed = trim( (string) $meta( 'UnparsedAddress' ) ); if ( '' !== $unparsed ) { return $unparsed; } // Otherwise stitch street + city + state + zip, dropping any empty part. $tail = array_filter( array( mlsimport_property_street_line( $meta ), $str( 'city' ), $str( 'state' ), $str( 'zip' ) ), 'strlen' ); return implode( ', ', $tail ); } /** * The street line ("123 Main St #4B") from RESO street parts. Shared by the * one-line address builder and the Address section's field grid. * * @param callable $meta Meta reader: ( string $key ) => string. * @return string */ function mlsimport_property_street_line( callable $meta ): string { // Base line is number + name ("123 Main St"). $street = trim( $meta( 'StreetNumber' ) . ' ' . $meta( 'StreetName' ) ); // Append the unit as "#4B" only when the feed supplies one. $unit = trim( (string) $meta( 'UnitNumber' ) ); if ( '' !== $unit ) { $street = trim( $street . ' #' . $unit ); } return $street; } /** * Gallery attachment IDs for a property (mlsimport_gallery meta), capped by the * editable Photos Count field. * * Photos Count (mlsimport_PhotosCount) arrives from the MLS as the feed's own photo * count, but the editor may lower it to publish fewer images. It caps every gallery * surface — metabox tiles, single-property gallery/slider, print — because this is * the one function they all read. An empty or zero count means "no cap"; the stored * attachments are never modified. * * @param int $id Property post ID. * @return int[] */ function mlsimport_property_gallery_ids( int $id ): array { // The gallery meta stores an ordered array of attachment IDs. $ids = get_post_meta( $id, 'mlsimport_gallery', true ); // Nothing usable when the meta is absent or not an array. if ( ! is_array( $ids ) ) { return array(); } // Cast to ints, drop zeros/empties, and re-index. $ids = array_values( array_filter( array_map( 'intval', $ids ) ) ); // A positive Photos Count trims the list; blank/0/negative leaves it whole. $limit = (int) get_post_meta( $id, 'mlsimport_PhotosCount', true ); if ( $limit > 0 && count( $ids ) > $limit ) { $ids = array_slice( $ids, 0, $limit ); } return $ids; } /** * Amenity feature term names for a property. * * @param int $id Property post ID. * @return string[] */ function mlsimport_property_feature_names( int $id ): array { // Amenities live in the mlsimport_feature taxonomy. $terms = get_the_terms( $id, 'mlsimport_feature' ); // No terms (or a WP_Error): no features. if ( ! is_array( $terms ) ) { return array(); } // Term names can be packed RESO values ("BambooFloor,Quartz,TileFloor"); split // each into its own amenity so chips stay short and never overflow the grid. $names = array(); foreach ( wp_list_pluck( $terms, 'name' ) as $name ) { // Break one packed term into its comma-separated parts. foreach ( explode( ',', $name ) as $part ) { $part = trim( $part ); // Keep only non-empty parts. if ( '' !== $part ) { $names[] = $part; } } } // De-dupe and re-index so each amenity chip appears once. return array_values( array_unique( $names ) ); } /** * Resolve the listing agent: linked mlsimport_agent post meta preferred, with a * fallback to the property's own ListAgent* meta. * * @param int $id Property post ID. * @param callable $meta Property meta reader. * @return array|null { id, name, email, phone, office, feed_name, feed_office, … } or null when unknown. */ function mlsimport_property_agent( int $id, callable $meta ): ?array { // The agent post the import task linked (0 when none was picked). $agent_id = (int) $meta( 'list_agent_id' ); // Whether the task opted to attribute the MLS feed's own listing agent instead. $use_mls = (bool) intval( $meta( 'use_mls_agent' ) ); // The agent picked in the import task wins, unless that task opted to use the // MLS feed's own listing agent (mlsimport_use_mls_agent). In feed mode the // linked agent post is ignored entirely; otherwise it is the only source and // the property's own ListAgent* feed meta is not consulted. $use_selected = $agent_id > 0 && ! $use_mls; $post_id = $use_selected ? $agent_id : 0; // Reader that pulls each agent field from the linked post (selected mode) or // from the property's own feed meta (MLS-agent mode). $ameta = static function ( $key ) use ( $use_selected, $agent_id, $meta ) { if ( $use_selected ) { return (string) get_post_meta( $agent_id, 'mlsimport_' . $key, true ); } return (string) $meta( $key ); }; // Core contact fields, resolved through the mode-aware reader. $name = $ameta( 'ListAgentFullName' ); $email = $ameta( 'ListAgentEmail' ); $phone = $ameta( 'ListAgentPreferredPhone' ); $office = $ameta( 'ListOfficeName' ); // A linked agent post's title is its display name when no name meta is set. if ( '' === $name && $post_id ) { $name = (string) get_the_title( $post_id ); } // No name, email or phone means there is no agent worth rendering. if ( '' === $name && '' === $email && '' === $phone ) { return null; } // A feed-sourced agent (no local agent post) may not have their personal // contact channels displayed or used — MLS display rules (#181). The company // contacts from the Social & Contact settings take their place. $is_feed = ! $use_selected; if ( $is_feed ) { $email = (string) mlsimport_standalone_option( 'lead_recipient', '' ); $phone = (string) mlsimport_standalone_option( 'company_phone', '' ); } // A linked agent post's body doubles as the bio when no explicit bio meta exists. $bio = $ameta( 'ListAgentBio' ); if ( '' === $bio && $post_id ) { $bio = (string) get_post_field( 'post_content', $post_id ); } // Resolved agent shape consumed by the agent card, booking rail and attribution. return array( 'id' => $post_id, 'is_feed' => $is_feed, 'name' => $name, 'email' => $email, 'phone' => $phone, 'office_phone' => $ameta( 'ListOfficePhone' ), 'office' => $office, // The property's own feed values, untouched by the manual-agent override — // the MLS attribution must always name the FEED listing agent/office (#169). 'feed_name' => (string) $meta( 'ListAgentFullName' ), 'feed_office' => (string) $meta( 'ListOfficeName' ), 'license' => $ameta( 'ListAgentStateLicense' ), 'agent_mls_id' => $ameta( 'ListAgentMlsId' ), 'office_mls_id' => $ameta( 'ListOfficeMlsId' ), 'bio' => trim( wp_strip_all_tags( $bio ) ), 'photo_id' => $post_id ? (int) get_post_thumbnail_id( $post_id ) : 0, ); } /** * Format an ISO/MySQL timestamp to the site's date format. '' when unparseable. * * Pure-ish (uses WP date settings); DB-free so the view model stays cheap. * * @param string $ts Timestamp string (e.g. RESO ModificationTimestamp). * @return string */ function mlsimport_property_format_date( string $ts ): string { // Empty in, empty out. $ts = trim( $ts ); if ( '' === $ts ) { return ''; } // Parse the timestamp to epoch seconds. $time = strtotime( $ts ); if ( false === $time ) { // Already a human display string (e.g. "June 5, 2026 at 02:10pm") — keep it. return $ts; } // Use the site's configured date format when WP is loaded, else a sane default. $format = function_exists( 'get_option' ) ? (string) get_option( 'date_format', 'F j, Y' ) : 'F j, Y'; // Localized date when available; plain UTC gmdate() as the DB-free fallback. return function_exists( 'date_i18n' ) ? (string) date_i18n( $format, $time ) : gmdate( $format, $time ); } /** * Open a section: the single source of the section container + title markup. * * Emits a stable anchor id (mlsimport-section-) so the in-page sub-nav can * jump to it, and an optional icon chip beside the title to match the design. * * @param string $slug Section slug (e.g. 'price'); used in the BEM class. * @param string $title Optional heading. * @param string $icon Optional icon name for mlsimport_property_icon(). * @return string */ function mlsimport_property_section_open( string $slug, string $title = '', string $icon = '' ): string { // Anchor id uses the first space-delimited token of the slug (drops modifiers). $anchor = sanitize_html_class( 'mlsimport-section-' . strtok( $slug, ' ' ) ); // Open the section wrapper carrying the anchor and the slug-derived BEM class. $html = '
'; // Header (icon chip + heading) is emitted only when a title was passed. if ( '' !== $title ) { $html .= '
'; // Optional leading icon chip. if ( '' !== $icon ) { $html .= ''; } $html .= '

' . esc_html( $title ) . '

'; $html .= '
'; } // Open the body wrapper; the caller appends content, then section_close() shuts both. $html .= '
'; return $html; } /** * Return an inline stroke SVG for a named icon, or '' for an unknown name. * * Self-contained (no icon-font dependency) so every section/block renders the * same glyph wherever it is placed. currentColor is used so CSS theme tokens * drive the colour. The SVG inherits sizing from .mlsimport-property-icon CSS. * * @param string $name Icon name. * @return string */ function mlsimport_property_icon( string $name ): string { // name => inner SVG path/shape markup for a 24×24 stroke icon. $paths = array( 'info' => '', 'text' => '', 'cube' => '', 'pin' => '', 'list' => '', 'grid' => '', 'video' => '', 'calc' => '', 'user' => '', 'bed' => '', 'bath' => '', 'ruler' => '', 'car' => '', 'calendar' => '', 'building' => '', 'hash' => '', 'badge' => '', 'check' => '', 'phone' => '', 'mail' => '', 'whatsapp' => '', 'globe' => '', 'message' => '', 'share' => '', 'heart' => '', 'print' => '', 'clock' => '', 'arrow-down' => '', 'chevron-left' => '', 'chevron-right' => '', 'tour' => '', ); // Unknown icon name renders nothing rather than a broken glyph. if ( ! isset( $paths[ $name ] ) ) { return ''; } // Wrap the chosen shape in the shared SVG chrome (currentColor lets CSS tint it). return ''; } /** * Close a section opened with mlsimport_property_section_open(). * * @return string */ function mlsimport_property_section_close(): string { return '
'; } /** * Format a numeric price as a US currency string (no decimals). * * Pure + DB-free so it can be unit-tested in isolation. * * @param float|int|string|null $value Raw price. * @return string Formatted price, or '' when there is no usable value. */ function mlsimport_format_price( $value ): string { // No value → no price string (an empty tile/row is dropped upstream). if ( null === $value || '' === $value ) { return ''; } /** Filter the formatted price string. @since 6.3 */ // Thousands-separated dollars with no decimals ("$ 1,250,000"). return (string) apply_filters( 'mlsimport_format_price', '$ ' . number_format( (float) $value ), $value ); } /** * Format a count/measure: whole numbers plain, fractions to one decimal * (so 3 beds reads "3", 2.5 baths reads "2.5"). Pure + DB-free. * * @param float|int|string|null $value Raw amount. * @return string */ function mlsimport_format_amount( $value ): string { // No value → empty string. if ( null === $value || '' === $value ) { return ''; } $f = (float) $value; // Whole numbers print plain; anything with a fraction prints to one decimal. return ( $f === (float) (int) $f ) ? number_format( $f ) : number_format( $f, 1 ); } /** * Price section — the listing's list price. * * @param int $id Property post ID (0 = current loop post). * @param array $args Reserved for behavioral options. * @return string HTML, or '' when the property has no price. */ function mlsimport_property_price( int $id = 0, array $args = array() ): string { // Load the view model and bail when there is no price to show. $data = mlsimport_property_data( $id ); if ( empty( $data ) || null === $data['price'] ) { return ''; } // Section wrapper (no heading) + the formatted price line. $html = mlsimport_property_section_open( 'price' ); $html .= '

' . esc_html( mlsimport_format_price( $data['price'] ) ) . '

'; $html .= mlsimport_property_section_close(); return $html; } /** * Title section — the listing title as a heading. * * @param int $id Property post ID. * @param array $args Behavioral options. * @return string */ function mlsimport_property_title( int $id = 0, array $args = array() ): string { // No title, no section. $data = mlsimport_property_data( $id ); if ( empty( $data ) || '' === $data['title'] ) { return ''; } // Wrapper + the title as an

. $html = mlsimport_property_section_open( 'title' ); $html .= '

' . esc_html( $data['title'] ) . '

'; $html .= mlsimport_property_section_close(); return $html; } /** * Status section — the listing status as a badge. * * @param int $id Property post ID. * @param array $args Behavioral options. * @return string */ function mlsimport_property_status( int $id = 0, array $args = array() ): string { // No status, no section. $data = mlsimport_property_data( $id ); if ( empty( $data ) || '' === $data['status'] ) { return ''; } // Wrapper + the status as a badge. $html = mlsimport_property_section_open( 'status' ); $html .= '' . esc_html( $data['status'] ) . ''; $html .= mlsimport_property_section_close(); return $html; } /** * Address section — the one-line street address. * * @param int $id Property post ID. * @param array $args Behavioral options. * @return string */ function mlsimport_property_address( int $id = 0, array $args = array() ): string { // No address, no section. $data = mlsimport_property_data( $id ); if ( empty( $data ) || '' === $data['address'] ) { return ''; } // Wrapper + the one-line address. $html = mlsimport_property_section_open( 'address' ); $html .= '

' . esc_html( $data['address'] ) . '

'; $html .= mlsimport_property_section_close(); return $html; } /** * The tiles the Overview section knows how to draw: slug => [ icon, label ]. This * is the catalog behind the Overview "Arrange Fields" control in the design * settings — which tiles show, and in what order, is the saved arrangement of * these slugs. Values are resolved per property in mlsimport_property_overview_value(). * * @return array */ function mlsimport_property_overview_fields(): array { return array( 'updated' => array( 'calendar', __( 'Updated', 'mlsimport' ) ), 'sub_type' => array( 'building', __( 'Sub type', 'mlsimport' ) ), 'mls_id' => array( 'hash', __( 'MLS #', 'mlsimport' ) ), 'bedrooms' => array( 'bed', __( 'Bedrooms', 'mlsimport' ) ), 'bathrooms' => array( 'bath', __( 'Bathrooms', 'mlsimport' ) ), 'size' => array( 'ruler', __( 'Size', 'mlsimport' ) ), 'year_built' => array( 'clock', __( 'Year Built', 'mlsimport' ) ), 'garage' => array( 'car', __( 'Garage', 'mlsimport' ) ), ); } /** * One overview tile's display value for a property, or '' when it has none (an * empty tile is skipped, so the grid never shows a blank cell). * * @param string $slug Overview field slug. * @param array $data Property view model. * @return string */ function mlsimport_property_overview_value( string $slug, array $data ): string { // Map each overview slug to its display value from the view model. switch ( $slug ) { case 'updated': // Last-modified date, already formatted. return (string) $data['updated']; case 'sub_type': // RESO PropertySubType. return (string) $data['property_sub_type']; case 'mls_id': // Listing's MLS number. return (string) $data['mls_id']; case 'bedrooms': // Bed count (null → no tile). return null !== $data['bedrooms'] ? mlsimport_format_amount( $data['bedrooms'] ) : ''; case 'bathrooms': // Bath count (fractions allowed, e.g. 2.5). return null !== $data['bathrooms'] ? mlsimport_format_amount( $data['bathrooms'] ) : ''; case 'size': // Living area with a ft² suffix. return null !== $data['living_area'] ? mlsimport_format_amount( $data['living_area'] ) . ' ' . __( 'ft²', 'mlsimport' ) : ''; case 'year_built': // A year is never thousands-separated, so it bypasses mlsimport_format_amount(). return null !== $data['year_built'] ? (string) $data['year_built'] : ''; case 'garage': // Garage spaces (RESO GarageSpaces); 0 spaces is "no garage" — no tile. return ! empty( $data['garage'] ) ? (string) $data['garage'] : ''; } // Unknown slug carries no value. return ''; } /** * Overview section — the headline stat grid (updated · sub type · MLS # · beds · * baths · size). Which tiles appear and their order come from the Overview * "Arrange Fields" design setting; only tiles with a value render. * * @param int $id Property post ID. * @param array $args Behavioral options. * @return string */ function mlsimport_property_overview( int $id = 0, array $args = array() ): string { $data = mlsimport_property_data( $id ); if ( empty( $data ) ) { return ''; } // Tile catalog (slug => [icon, label]); the saved arrangement drives order. $fields = mlsimport_property_overview_fields(); $cells = ''; // Walk the user's chosen tile order. foreach ( mlsimport_standalone_active_overview_fields() as $slug ) { // Skip a saved slug the catalog no longer knows. if ( ! isset( $fields[ $slug ] ) ) { continue; } // Resolve this tile's value; an empty value means no tile. $value = mlsimport_property_overview_value( $slug, $data ); if ( '' === $value ) { continue; } // Icon + label + value tile. $cells .= '
' . '' . '' . esc_html( $fields[ $slug ][1] ) . '' . '' . esc_html( $value ) . '' . '
'; } // No populated tiles → skip the whole section. if ( '' === $cells ) { return ''; } // Titled "Overview" section wrapping the tile grid. $html = mlsimport_property_section_open( 'overview', __( 'Overview', 'mlsimport' ), 'info' ); $html .= '
' . $cells . '
'; $html .= mlsimport_property_section_close(); return $html; } /** * The facts grid as a markup string — shared by details, tabs and accordion. * * @param array $facts [label, value] pairs from mlsimport_property_facts(). * @return string */ function mlsimport_property_facts_grid_html( array $facts, string $modifier = '', int $id = 0 ): string { // Nothing to render when there are no fact rows. if ( empty( $facts ) ) { return ''; } // Grid
    , plus the optional column-count/address modifier class. $html = '
      '; // One label/value
    • per fact; a value naming one of this listing's terms links to it. foreach ( $facts as $fact ) { $html .= '
    • ' . '' . esc_html( $fact[0] ) . '' . '' . mlsimport_property_link_term( $id, (string) $fact[1] ) . '' . '
    • '; } $html .= '
    '; return $html; } /** * The features chip list as a markup string — shared by features, tabs, accordion. * * @param string[] $names Feature term names. * @return string */ function mlsimport_property_features_list_html( array $names, int $id = 0 ): string { // No amenity names → no chip list. if ( empty( $names ) ) { return ''; } // A shared check glyph precedes every chip. $check = ''; // Chip list carries the page-wide column-count class. $html = '
      '; // One chip per amenity, linked to its feature archive. foreach ( $names as $name ) { $html .= '
    • ' . $check . '' . mlsimport_property_link_term( $id, (string) $name ) . '
    • '; } $html .= '
    '; return $html; } /** * The panes behind the Tabs and Accordion containers: each configured section, * rendered through the one dispatcher every builder already uses. * * A container accepts ANY registered section — so Map can sit as a tab next to * Interior. A section that renders nothing is dropped rather than offered as a * dead tab, the same "no data, no section" rule the sections themselves obey. * * The pane carries the heading, so the section inside it is asked to omit its own. * * @param int $id Property post ID. * @param array $args Behavioral options; 'sections' is an ordered list of slugs. * @return array slug => [ title, html ]. */ function mlsimport_property_container_panes( int $id, array $args ): array { // The ordered slug list the container was told to hold. $slugs = isset( $args['sections'] ) ? (array) $args['sections'] : array(); if ( empty( $slugs ) ) { return array(); } // The section registry maps each slug to its render fn + label. $registry = mlsimport_get_property_sections(); $panes = array(); foreach ( $slugs as $slug ) { $slug = (string) $slug; // Skip a slug that isn't a registered section. if ( ! isset( $registry[ $slug ] ) ) { continue; } // Render through the shared dispatcher, asking the section to omit its heading. $html = mlsimport_render_property_section( $slug, $id, array( 'hide_title' => true ) ); // A section that produced nothing is dropped, never offered as a dead tab. if ( '' === trim( $html ) ) { continue; } // Pane = [ registry label, rendered html ]. $panes[ $slug ] = array( (string) $registry[ $slug ]['label'], $html ); } return $panes; } /** * The nine field sections — Interior, Exterior, Structure, Utilities, Financial, * Schools, Location, Listing Info, Other Details. * * One render fn backs all nine; the registry bakes the slug into each. The rows * come from mlsimport_property_section_fields(), which owns the one rule that * governs every section: a field shows when it is ticked for import, not marked * admin-only, and has a value. * * A section with no populated field renders '' — never a bare heading. * * @param int $id Property post ID (0 = current loop post). * @param array $args Behavioral options; 'section' is the section slug. * @return string */ function mlsimport_property_field_section( int $id = 0, array $args = array() ): string { // Resolve the post and which of the nine sections this call renders. $id = $id ? $id : (int) get_the_ID(); $section = isset( $args['section'] ) ? (string) $args['section'] : ''; if ( ! $id || '' === $section ) { return ''; } // Build the facts grid from the section's importable, populated fields. $grid = mlsimport_property_facts_grid_html( mlsimport_property_section_fields( $id, $section ), mlsimport_property_columns_class(), $id ); // No populated field → render '' rather than a bare heading. if ( '' === $grid ) { return ''; } // Inside a tab or an accordion panel the container already shows the heading. $titles = mlsimport_property_field_section_titles(); $title = ( isset( $titles[ $section ] ) && empty( $args['hide_title'] ) ) ? $titles[ $section ] : ''; // Section wrapper + the facts grid. $html = mlsimport_property_section_open( $section, $title, 'list' ); $html .= $grid; $html .= mlsimport_property_section_close(); return $html; } /** * The sub-nav jump links for the nine field sections: label => anchor id. * * A section earns a link only when it has a populated field — the same "no data, * no section" rule the sections themselves obey — so the nav never points at an * anchor that isn't on the page. * * @param int $id Property post ID. * @return array */ function mlsimport_property_subnav_field_items( int $id ): array { $items = array(); // Offer a jump link only for a section that has at least one populated field. foreach ( mlsimport_property_field_section_titles() as $slug => $title ) { if ( ! empty( mlsimport_property_section_fields( $id, $slug ) ) ) { $items[ $title ] = 'mlsimport-section-' . $slug; } } return $items; } /** * How many columns every field section's details grid runs — the Property Page * "Details Columns" setting. 2 or 3; anything else is 3. * * @return int */ function mlsimport_property_details_columns(): int { // Read the "Details Columns" setting; only 2 is honored, everything else is 3. $cols = (int) mlsimport_standalone_option( 'details_columns', 3 ); return 2 === $cols ? 2 : 3; } /** * The column-count class every grid inside a section carries — the details grids, * the Address grid and the amenity list alike. One class rather than a per-block * modifier, because "two columns" is a page-wide choice: a page set to two that * printed its amenities three-up would just look broken. * * @return string */ function mlsimport_property_columns_class(): string { // e.g. "mlsimport-cols-3" — one page-wide column class for every grid. return 'mlsimport-cols-' . mlsimport_property_details_columns(); } /** * The nine field sections, slug => public heading. * * @return array */ function mlsimport_property_field_section_titles(): array { return array( 'interior' => __( 'Interior', 'mlsimport' ), 'exterior' => __( 'Exterior', 'mlsimport' ), 'structure' => __( 'Structure', 'mlsimport' ), 'utilities' => __( 'Utilities', 'mlsimport' ), 'financial' => __( 'Financial', 'mlsimport' ), 'schools' => __( 'Schools', 'mlsimport' ), 'location' => __( 'Location', 'mlsimport' ), 'listing_info' => __( 'Listing Info', 'mlsimport' ), 'other' => __( 'Other Details', 'mlsimport' ), ); } /** * Features section — amenity feature terms as chips. * * @param int $id Property post ID. * @param array $args Behavioral options. * @return string */ function mlsimport_property_features( int $id = 0, array $args = array() ): string { // Build the chip list from the view model's feature names. $data = mlsimport_property_data( $id ); $list = $data ? mlsimport_property_features_list_html( $data['features'], (int) ( $data['id'] ?? 0 ) ) : ''; // No chips → no section. if ( '' === $list ) { return ''; } // Titled section wrapping the amenity chips. $html = mlsimport_property_section_open( 'features', __( 'Features & Amenities', 'mlsimport' ), 'grid' ); $html .= $list; $html .= mlsimport_property_section_close(); return $html; } /** * Details as Tabs — Details + Features in a tabbed panel (mlsimport-property-tabs.js). * * @param int $id Property post ID. * @param array $args Behavioral options. * @return string */ function mlsimport_property_tabs( int $id = 0, array $args = array() ): string { // Resolve the configured section panes; nothing to tab means no section. $panes = mlsimport_property_container_panes( $id, $args ); if ( empty( $panes ) ) { return ''; } // Build the tab buttons and their panels; the first pane is the open one. $nav = ''; $panels = ''; $first = true; foreach ( $panes as $key => $pane ) { // Tab button (aria-selected on the first). $nav .= ''; // Matching panel (hidden on all but the first). $panels .= '
    ' . $pane[1] . '
    '; $first = false; } // Titled "Details" section wrapping the tablist + panels. $html = mlsimport_property_section_open( 'tabs', __( 'Details', 'mlsimport' ), 'list' ); $html .= '
    '; $html .= '
    ' . $nav . '
    '; $html .= $panels; $html .= '
    '; $html .= mlsimport_property_section_close(); return $html; } /** * Details as Accordion — Details + Features in native
    panels (no JS). * * @param int $id Property post ID. * @param array $args Behavioral options. * @return string */ function mlsimport_property_accordion( int $id = 0, array $args = array() ): string { // Resolve the configured section panes; none means no section. $panes = mlsimport_property_container_panes( $id, $args ); if ( empty( $panes ) ) { return ''; } // Native
    per pane; only the first starts open. $items = ''; $open = ' open'; foreach ( $panes as $pane ) { $items .= '
    ' . '' . esc_html( $pane[0] ) . '' . '
    ' . $pane[1] . '
    ' . '
    '; // Subsequent panels render collapsed. $open = ''; } // Titled "Details" section wrapping the accordion. $html = mlsimport_property_section_open( 'accordion', __( 'Details', 'mlsimport' ), 'list' ); $html .= '
    ' . $items . '
    '; $html .= mlsimport_property_section_close(); return $html; } /** * Description section — the listing's public remarks (post body) with a heading. * * @param int $id Property post ID. * @param array $args Behavioral options. * @return string */ function mlsimport_property_description( int $id = 0, array $args = array() ): string { // No body content, no section. $data = mlsimport_property_data( $id ); if ( empty( $data ) || '' === trim( $data['content'] ) ) { return ''; } // Titled "Description" section; body is paragraph-wrapped and sanitized. $html = mlsimport_property_section_open( 'description', __( 'Description', 'mlsimport' ), 'text' ); $html .= '
    ' . wp_kses_post( wpautop( $data['content'] ) ) . '
    '; $html .= mlsimport_property_section_close(); return $html; } /** * Content section — the raw listing body, no heading. * * @param int $id Property post ID. * @param array $args Behavioral options. * @return string */ function mlsimport_property_content( int $id = 0, array $args = array() ): string { // No body content, no section. $data = mlsimport_property_data( $id ); if ( empty( $data ) || '' === trim( $data['content'] ) ) { return ''; } // Headingless wrapper + the paragraph-wrapped, sanitized body. $html = mlsimport_property_section_open( 'content' ); $html .= '
    ' . wp_kses_post( wpautop( $data['content'] ) ) . '
    '; $html .= mlsimport_property_section_close(); return $html; } /** * Excerpt section — a short summary (post excerpt, or trimmed content). * * @param int $id Property post ID. * @param array $args Behavioral options. * @return string */ function mlsimport_property_excerpt( int $id = 0, array $args = array() ): string { // Need a resolved property to have anything to summarize. $data = mlsimport_property_data( $id ); if ( empty( $data ) ) { return ''; } // Prefer the explicit excerpt; else trim the body to 40 words. $text = '' !== trim( $data['excerpt'] ) ? $data['excerpt'] : wp_trim_words( wp_strip_all_tags( $data['content'] ), 40 ); // Nothing to summarize → no section. if ( '' === trim( $text ) ) { return ''; } // Headingless wrapper + the summary paragraph. $html = mlsimport_property_section_open( 'excerpt' ); $html .= '

    ' . esc_html( $text ) . '

    '; $html .= mlsimport_property_section_close(); return $html; } /** * Additional price info — original / previous / close price + HOA. * * @param int $id Property post ID. * @param array $args Behavioral options. * @return string */ function mlsimport_property_price_info( int $id = 0, array $args = array() ): string { $data = mlsimport_property_data( $id ); if ( empty( $data ) ) { return ''; } // Collect label => price rows, each added only when its value is present. $rows = array(); if ( null !== $data['original_price'] ) { $rows[ __( 'Original price', 'mlsimport' ) ] = mlsimport_format_price( $data['original_price'] ); } if ( null !== $data['previous_price'] ) { $rows[ __( 'Previous price', 'mlsimport' ) ] = mlsimport_format_price( $data['previous_price'] ); } if ( null !== $data['close_price'] ) { $rows[ __( 'Sold price', 'mlsimport' ) ] = mlsimport_format_price( $data['close_price'] ); } if ( null !== $data['hoa_fee'] ) { // HOA fee, optionally suffixed with its billing frequency ("... / Monthly"). $hoa = mlsimport_format_price( $data['hoa_fee'] ); if ( '' !== $data['hoa_frequency'] ) { $hoa .= ' / ' . $data['hoa_frequency']; } $rows[ __( 'HOA fee', 'mlsimport' ) ] = $hoa; } // No price rows → no section. if ( empty( $rows ) ) { return ''; } // Titled "Price details" section wrapping a plain facts grid. $html = mlsimport_property_section_open( 'price-info', __( 'Price details', 'mlsimport' ), 'list' ); $html .= '
      '; // One label/value
    • per collected row. foreach ( $rows as $label => $value ) { $html .= '
    • ' . '' . esc_html( $label ) . '' . '' . esc_html( $value ) . '' . '
    • '; } $html .= '
    '; $html .= mlsimport_property_section_close(); return $html; } /** * The breadcrumb trail: Home > County > City > Area > this listing — broadest * geography first, narrowing down to the listing. A rung with no value is * simply skipped; there are no stand-ins. * * Each place links to its taxonomy archive, which is where a visitor climbing the * trail expects to land — "Sarasota" should list Sarasota, not search for it. A * listing imported before the location taxonomies were assigned still names its * places from the flat columns; they just have nowhere to link. * * @param int $id Property post ID. * @param array $data Property view model (mlsimport_property_data()). * @return array [ label, url ] pairs; url '' = not a link. */ function mlsimport_property_breadcrumb_items( int $id, array $data ): array { // A place is its term (linkable) or, failing that, the flat column text. $place = static function ( $taxonomy, $fallback ) use ( $id ) { $term = mlsimport_property_first_term( $id, $taxonomy ); $name = $term ? (string) $term->name : (string) $fallback; if ( '' === $name ) { return null; } $link = $term ? get_term_link( $term ) : ''; return array( $name, is_string( $link ) ? $link : '' ); }; // The trail always starts at Home. $items = array( array( __( 'Home', 'mlsimport' ), (string) home_url( '/' ) ) ); // Geography rungs, broadest first: county → city → area/subdivision. $rungs = array( $place( 'mlsimport_county', $data['county'] ?? '' ), $place( 'mlsimport_city', $data['city'] ?? '' ), $place( 'mlsimport_area', $data['subdivision'] ?? '' ), ); // Append only the rungs that resolved to a value. foreach ( $rungs as $rung ) { if ( $rung ) { $items[] = $rung; } } // The listing itself is the final, non-linking rung. if ( '' !== (string) ( $data['title'] ?? '' ) ) { $items[] = array( (string) $data['title'], '' ); } /** Filter the breadcrumb trail. @since 6.4 */ return (array) apply_filters( 'mlsimport_property_breadcrumb_items', $items, $id, $data ); } /** * Every term this listing carries, indexed by lower-cased display text, mapped * to its public term archive — the lookup behind mlsimport_property_link_term(). * * Feeds pack several values into one term name ("CornerLot,PublicRoad"), and the * page splits those apart before showing them, so each comma-separated part is * indexed to the same archive alongside the whole name. A term whose link can't * be built is left out, so it simply renders as plain text. * * Built once per post per request: the property page asks for these links from * the chips, the facts grid and the amenity list. * * @param int $id Property post ID. * @return array lower-cased term text => term archive URL. */ function mlsimport_property_term_link_map( int $id ): array { static $cache = array(); if ( isset( $cache[ $id ] ) ) { return $cache[ $id ]; } $map = array(); // Walk every taxonomy attached to the property CPT. foreach ( get_object_taxonomies( 'mlsimport_property' ) as $taxonomy ) { $terms = get_the_terms( $id, $taxonomy ); // get_the_terms() returns false/WP_Error when the post has no terms — skip. if ( ! is_array( $terms ) ) { continue; } foreach ( $terms as $term ) { $url = get_term_link( $term ); // An unresolvable link means this term stays plain text. if ( is_wp_error( $url ) ) { continue; } // Index the full name plus each packed part, all pointing at the term. foreach ( array_merge( array( $term->name ), explode( ',', $term->name ) ) as $text ) { $key = strtolower( trim( (string) $text ) ); if ( '' !== $key ) { $map[ $key ] = $url; } } } } $cache[ $id ] = $map; return $map; } /** * A displayed value as a link to its term archive, when the listing actually * carries a term by that name — otherwise the value as plain escaped text. * * Matching on the listing's own terms is what keeps this honest: "Ashland" links * because this listing is filed under Ashland, while "2025" or a street number * matches nothing and is left alone. Nothing is guessed from the text itself. * * @param int $id Property post ID. * @param string $text Display value. * @return string Escaped text, linked when a term matches. */ function mlsimport_property_link_term( int $id, string $text ): string { $map = $id ? mlsimport_property_term_link_map( $id ) : array(); $key = strtolower( trim( $text ) ); // No matching term → the value renders exactly as before. if ( ! isset( $map[ $key ] ) ) { return esc_html( $text ); } return '' . esc_html( $text ) . ''; } /** * The first term a listing carries in a taxonomy, or null. * * @param int $id Property post ID. * @param string $taxonomy Taxonomy slug. * @return object|null */ function mlsimport_property_first_term( int $id, string $taxonomy ) { // No usable terms (missing, empty, or a WP_Error) → null. $terms = get_the_terms( $id, $taxonomy ); if ( ! is_array( $terms ) || empty( $terms ) || is_wp_error( $terms ) ) { return null; } // The first term is the one the trail uses. return reset( $terms ); } /** * The breadcrumb trail as markup. * * @param array $items [ label, url ] pairs from mlsimport_property_breadcrumb_items(). * @return string */ function mlsimport_property_breadcrumbs_html( array $items ): string { // No rungs → no breadcrumb nav. if ( empty( $items ) ) { return ''; } // Open the breadcrumb