meta as a string. $meta = static function ( $key ) use ( $id ) { return (string) get_post_meta( $id, 'mlsimport_' . $key, true ); }; // Title, its first word (used for "Call "), and raw post body. $name = (string) get_the_title( $id ); $first = trim( (string) strtok( $name, ' ' ) ); $content = (string) get_post_field( 'post_content', $id ); // Assemble the view model — meta plus derived/formatted display values. $vm = array( 'id' => $id, 'name' => $name, 'first' => '' !== $first ? $first : $name, 'permalink' => (string) get_permalink( $id ), 'photo_id' => (int) get_post_thumbnail_id( $id ), 'bio_html' => '' !== $content ? (string) apply_filters( 'the_content', $content ) : '', // Hero teaser: the operator's dedicated Teaser field when set, else a 42-word // trim of the full bio so agents saved before the field existed still read well. 'bio_teaser' => '' !== $meta( 'teaser' ) ? $meta( 'teaser' ) : ( '' !== $content ? wp_trim_words( wp_strip_all_tags( $content ), 42 ) : '' ), 'email' => $meta( 'ListAgentEmail' ), 'phone' => $meta( 'ListAgentPreferredPhone' ), 'office' => $meta( 'ListOfficeName' ), 'mls_id' => $meta( 'ListAgentMlsId' ), /** Filter the agent's eyebrow/title label — the metabox's JobTitle, else a default. @since 6.4 */ 'title' => (string) apply_filters( 'mlsimport_agent_title', '' !== $meta( 'JobTitle' ) ? $meta( 'JobTitle' ) : __( 'Real Estate Agent', 'mlsimport' ), $id ), // Operator ticked the "Verified agent" checkbox (stored as mlsimport_featured); // gates the "Verified Agent" hero badge. 'verified' => '1' === $meta( 'featured' ), ); // The agent's published listings (ids; count derives from it). $vm['listing_ids'] = get_posts( array( 'post_type' => 'mlsimport_property', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'no_found_rows' => true, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- agent's own listings. 'meta_key' => 'mlsimport_list_agent_id', 'meta_value' => $id, ) ); /** Filter the agent view model. @since 6.4 */ $vm = (array) apply_filters( 'mlsimport_agent_data', $vm, $id ); // Cache and return. $cache[ $id ] = $vm; return $vm; } /** * Enqueue the agent profile's assets: the shared design tokens + section shell, * the agent stylesheet, and the reused sub-nav + lead scripts. Card/grid styles * ride along on the always-enqueued mlsimport-listings stylesheet. * * @return void */ function mlsimport_agent_enqueue(): void { // Guard for non-WP contexts. if ( ! function_exists( 'wp_enqueue_style' ) ) { return; } // Make sure the shared section design tokens + shell are registered. Mlsimport_Property_Section_Assets::ensure_registered(); // Shared base style, then the agent stylesheet if it was registered. wp_enqueue_style( Mlsimport_Property_Section_Assets::BASE_STYLE ); if ( wp_style_is( 'mlsimport-agent', 'registered' ) ) { wp_enqueue_style( 'mlsimport-agent' ); } // Reuse the property sub-nav (scrollspy) and lead-form scripts. wp_enqueue_script( 'mlsimport-property-subnav' ); wp_enqueue_script( 'mlsimport-property-lead' ); } /** * Pre-enqueue the agent assets into the on a single agent page. The * template prints the header before any section renders, so the on-demand * enqueue at render time lands in the footer and flashes unstyled (issue #188). * The in-template call stays and no-ops for the already-queued handles. * * @return void */ function mlsimport_agent_enqueue_for_single(): void { if ( ! function_exists( 'is_singular' ) || ! is_singular( 'mlsimport_agent' ) ) { return; } mlsimport_agent_enqueue(); } /** * One icon + label + value contact row used in the hero's direct-contact block. * * @param string $icon Icon name (mlsimport_property_icon). * @param string $label Field label. * @param string $value Display value. * @param string $href Link target (tel:/mailto:/https:). * @return string HTML, or '' when the value is empty. */ function mlsimport_agent_contact_line( string $icon, string $label, string $value, string $href ): string { // Omit the row entirely when there's no value to show. if ( '' === $value ) { return ''; } // Icon + label + value wrapped in a single tel:/mailto: link. return '' . '' . '' . '' . esc_html( $label ) . '' . '' . esc_html( $value ) . '' . ''; } /** * A wa.me link that opens a chat already addressed to this agent, so they get * "Hi Dana, I saw your profile ..." instead of a bare "hi". The listing-page * equivalent is mlsimport_property_whatsapp_link(), which talks about a property * rather than an agent. * * @param string $tel Agent phone, as entered (may hold spaces, +, punctuation). * @param string $first Agent first name, used to open the message. * @return string wa.me URL, or '' when the phone holds no digits. */ function mlsimport_agent_whatsapp_link( string $tel, string $first ): string { // wa.me wants the number bare: digits only, no +, no spaces. $number = preg_replace( '/[^0-9]/', '', $tel ); // No digits → no link. if ( '' === $number ) { return ''; } // Opening line, mirroring the contact rail's pre-filled message. $message = sprintf( /* translators: %s: agent first name. */ __( "Hi %s, I saw your profile and I'd like to talk about ", 'mlsimport' ), $first ); /** Filter the WhatsApp message a visitor sends from an agent profile. @since 6.4 */ $message = (string) apply_filters( 'mlsimport_agent_whatsapp_message', $message, $first ); // wa.me deep link with the pre-filled, URL-encoded message. return 'https://wa.me/' . $number . '?text=' . rawurlencode( $message ); } /** * Editorial hero — tall portrait beside the agent's identity, bio teaser, the * direct-contact block (email visible) and the primary call/email actions. * * @param int $id Agent post ID. * @return string */ function mlsimport_agent_hero( int $id = 0 ): string { // Load the view model; nothing to render without one. $a = mlsimport_agent_data( $id ); if ( empty( $a ) ) { return ''; } // Portrait (featured image) or a neutral placeholder. $portrait = $a['photo_id'] ? get_the_post_thumbnail( $a['id'], 'large', array( 'class' => 'mlsimport-agent-hero__img' ) ) : ''; // Office + (optional) MLS id meta line. $meta_bits = ''; if ( '' !== $a['office'] ) { $meta_bits .= '' . mlsimport_property_icon( 'building' ) . esc_html( $a['office'] ) . ''; } if ( '' !== $a['mls_id'] ) { $meta_bits .= ''; $meta_bits .= '' . mlsimport_property_icon( 'badge' ) . esc_html__( 'MLS', 'mlsimport' ) . ' ' . esc_html( $a['mls_id'] ) . ''; } // Direct-contact rows (only those with a value render). Office already shows // in the meta line above, so the grid carries the actionable phone + email. $contacts = mlsimport_agent_contact_line( 'phone', __( 'Phone', 'mlsimport' ), $a['phone'], 'tel:' . preg_replace( '/[^0-9+]/', '', $a['phone'] ) ); $contacts .= mlsimport_agent_contact_line( 'mail', __( 'Email', 'mlsimport' ), $a['email'], 'mailto:' . $a['email'] ); // CTAs. $ctas = ''; if ( '' !== $a['phone'] ) { $ctas .= '' . mlsimport_property_icon( 'phone' ) . esc_html( sprintf( /* translators: %s: agent first name. */ __( 'Call %s', 'mlsimport' ), $a['first'] ) ) . ''; } if ( '' !== $a['email'] ) { $ctas .= '' . mlsimport_property_icon( 'mail' ) . esc_html__( 'Email', 'mlsimport' ) . ''; } // WhatsApp opens a chat already addressed to the agent by name. Same phone as // the Call CTA, so it only renders when there's a number with digits in it. $whatsapp = mlsimport_agent_whatsapp_link( $a['phone'], $a['first'] ); if ( '' !== $whatsapp ) { $ctas .= '' . mlsimport_property_icon( 'whatsapp' ) . esc_html__( 'WhatsApp', 'mlsimport' ) . ''; } // Assemble the hero: portrait column, then the identity column. $html = '
'; $html .= '
' . $portrait; // Only a verified agent (operator-ticked) wears the badge. if ( ! empty( $a['verified'] ) ) { $html .= '' . mlsimport_property_icon( 'check' ) . esc_html__( 'Verified Agent', 'mlsimport' ) . ''; } $html .= '
'; $html .= '
'; $html .= '' . esc_html( $a['title'] ) . ''; $html .= '

' . esc_html( $a['name'] ) . '

'; if ( '' !== $meta_bits ) { $html .= '
' . $meta_bits . '
'; } if ( '' !== $a['bio_teaser'] ) { $html .= '

' . esc_html( $a['bio_teaser'] ) . '

'; } if ( '' !== $contacts ) { $html .= '
'; $html .= '
' . esc_html__( 'Direct contact', 'mlsimport' ) . '
'; $html .= '
' . $contacts . '
'; $html .= '
'; } if ( '' !== $ctas ) { $html .= '
' . $ctas . '
'; } $html .= '
'; // identity $html .= '
'; return $html; } /** * Sticky sub-nav — reuses the property sub-nav markup so its CSS + scrollspy JS * apply unchanged. Links whose target section isn't on the page hide themselves. * * @param int $id Agent post ID. * @return string */ function mlsimport_agent_subnav( int $id = 0 ): string { // Load the view model; nothing to render without one. $a = mlsimport_agent_data( $id ); if ( empty( $a ) ) { return ''; } // Nav items as label => anchor-id of the target section. Links whose target // section isn't on the page hide themselves, so About drops out for a bio-less agent. $items = array( __( 'About', 'mlsimport' ) => 'mlsimport-section-about', __( 'Listings', 'mlsimport' ) => 'mlsimport-section-listings', __( 'Credentials', 'mlsimport' ) => 'mlsimport-section-credentials', __( 'Contact', 'mlsimport' ) => 'mlsimport-section-contact', ); /** Filter the agent sub-nav items (label => anchor id). @since 6.4 */ $items = (array) apply_filters( 'mlsimport_agent_subnav_items', $items, $id ); // Build one sub-nav link per item. $links = ''; foreach ( $items as $label => $target ) { $links .= '' . esc_html( $label ) . ''; } // Wrap the links in the property sub-nav markup so its CSS/JS applies. return '
' . '' . '
'; } /** * Render one reorderable agent content-column section by slug. The single source * of truth for slug => section fn, mirroring mlsimport_standalone_agent_section_catalog(). * The hero, sub-nav and contact rail are fixed and not routed here. * * @param string $slug Section slug (listings|credentials). * @param int $id Agent post ID. * @return string Section HTML, or '' for an unknown slug. */ function mlsimport_render_agent_section( string $slug, int $id = 0 ): string { // Route the slug to its section renderer; unknown slugs return ''. switch ( $slug ) { case 'about': return mlsimport_agent_about( $id ); case 'listings': return mlsimport_agent_listings( $id ); case 'credentials': return mlsimport_agent_credentials( $id ); } return ''; } /** * About — the agent's full bio (the post body) in the shared section-card shell. * The hero shows only the short teaser; this is where the complete description * lives. Omitted when the agent has no bio. * * @param int $id Agent post ID. * @return string */ function mlsimport_agent_about( int $id = 0 ): string { // Load the view model; nothing to render without a bio. $a = mlsimport_agent_data( $id ); if ( empty( $a ) || '' === $a['bio_html'] ) { return ''; } // Section-card shell wrapping the rendered bio. $html = mlsimport_property_section_open( 'about', sprintf( /* translators: %s: agent first name. */ __( 'About %s', 'mlsimport' ), $a['first'] ), 'user' ); $html .= '
' . wp_kses_post( $a['bio_html'] ) . '
'; $html .= mlsimport_property_section_close(); return $html; } /** * Listings — the agent's active listings as the shared listing cards, with a * count line. Reuses Mlsimport_Standalone_Render::cards_for_posts (and so card.php). * * @param int $id Agent post ID. * @return string */ function mlsimport_agent_listings( int $id = 0 ): string { // Load the view model; nothing to render without one. $a = mlsimport_agent_data( $id ); if ( empty( $a ) ) { return ''; } // The agent's full listing-id set and its total count. $ids = (array) $a['listing_ids']; $count = count( $ids ); // Open the section-card shell. $html = mlsimport_property_section_open( 'listings', sprintf( /* translators: %s: agent first name. */ __( "%s's Listings", 'mlsimport' ), $a['first'] ), 'grid' ); // Render the paged card grid, or an empty-state line. if ( $count ) { // GET-based paging: slice the agent's full ID set to the current page, then emit // the shared pager. The agent page is a SINGULAR post, where WP's redirect_canonical // strips a bare ?page= (a reserved var for content) — so this surface // pages on its own ?agent_page= key instead, while reusing the identical pager markup. // Per-page size (min 1), current page from ?agent_page=, and this page's id slice. $per_page = max( 1, (int) mlsimport_standalone_option( 'agent_listings_per_page', 12 ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only GET paging of the agent's own listings. $current = isset( $_GET['agent_page'] ) ? max( 1, (int) $_GET['agent_page'] ) : 1; $page_ids = array_slice( $ids, ( $current - 1 ) * $per_page, $per_page ); // Count line above the grid. $html .= '

' . esc_html( sprintf( /* translators: %s: number of listings. */ _n( '%s listing', '%s listings', $count, 'mlsimport' ), number_format_i18n( $count ) ) ) . '

'; // Cards per row: --mli-cols drives the grid, so the narrow-screen media // queries (2 then 1 across) still override it. $per_row = (int) mlsimport_standalone_option( 'agent_listings_per_row', 3 ); $per_row = max( 2, min( 4, $per_row ) ); $html .= '
' . Mlsimport_Standalone_Render::cards_for_posts( $page_ids ) . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- card.php escapes at source. // Same pager markup (the