| 1 |
<?php |
| 2 |
/** |
| 3 |
* Standalone (theme_id 990) single-agent profile sections. |
| 4 |
* |
| 5 |
* The agent page mirrors the single-property page's section principles: a view |
| 6 |
* model read once (mlsimport_agent_data), section render fns that return one |
| 7 |
* HTML string each, the shared section-card shell (mlsimport_property_section_open/ |
| 8 |
* close) and icon set, and on-demand asset enqueueing. The page itself is a fixed |
| 9 |
* editorial layout — an Editorial hero, a sticky sub-nav, and a content column |
| 10 |
* (About, Listings, Credentials) beside a sticky contact rail — so unlike the |
| 11 |
* property page there is no reorderable registry here. |
| 12 |
* |
| 13 |
* Data degrades gracefully: agents carry only the meta the importer stores |
| 14 |
* (name, photo, bio, email, preferred phone, office, agent MLS id), so any block |
| 15 |
* with no value is omitted rather than rendered empty. |
| 16 |
* |
| 17 |
* @package Mlsimport |
| 18 |
*/ |
| 19 |
|
| 20 |
if ( ! defined( 'ABSPATH' ) ) { |
| 21 |
exit; |
| 22 |
} |
| 23 |
|
| 24 |
require_once __DIR__ . '/property-sections.php'; |
| 25 |
require_once __DIR__ . '/class-mlsimport-property-lead.php'; |
| 26 |
|
| 27 |
/** |
| 28 |
* The agent view model — the single value every agent section reads. Cached |
| 29 |
* per request so repeated section calls don't re-query. |
| 30 |
* |
| 31 |
* @param int $id Agent post ID (0 = current loop post). |
| 32 |
* @return array |
| 33 |
*/ |
| 34 |
function mlsimport_agent_data( int $id = 0 ): array { |
| 35 |
// Per-request memoisation keyed by agent id. |
| 36 |
static $cache = array(); |
| 37 |
|
| 38 |
// Default to the current loop post; bail unless it's a real agent post. |
| 39 |
$id = $id ? $id : (int) get_the_ID(); |
| 40 |
if ( ! $id || 'mlsimport_agent' !== get_post_type( $id ) ) { |
| 41 |
return array(); |
| 42 |
} |
| 43 |
// Return the cached view model when already built this request. |
| 44 |
if ( isset( $cache[ $id ] ) ) { |
| 45 |
return $cache[ $id ]; |
| 46 |
} |
| 47 |
|
| 48 |
// Local reader for the agent's mlsimport_<key> meta as a string. |
| 49 |
$meta = static function ( $key ) use ( $id ) { |
| 50 |
return (string) get_post_meta( $id, 'mlsimport_' . $key, true ); |
| 51 |
}; |
| 52 |
|
| 53 |
// Title, its first word (used for "Call <first>"), and raw post body. |
| 54 |
$name = (string) get_the_title( $id ); |
| 55 |
$first = trim( (string) strtok( $name, ' ' ) ); |
| 56 |
$content = (string) get_post_field( 'post_content', $id ); |
| 57 |
|
| 58 |
// Assemble the view model — meta plus derived/formatted display values. |
| 59 |
$vm = array( |
| 60 |
'id' => $id, |
| 61 |
'name' => $name, |
| 62 |
'first' => '' !== $first ? $first : $name, |
| 63 |
'permalink' => (string) get_permalink( $id ), |
| 64 |
'photo_id' => (int) get_post_thumbnail_id( $id ), |
| 65 |
'bio_html' => '' !== $content ? (string) apply_filters( 'the_content', $content ) : '', |
| 66 |
// Hero teaser: the operator's dedicated Teaser field when set, else a 42-word |
| 67 |
// trim of the full bio so agents saved before the field existed still read well. |
| 68 |
'bio_teaser' => '' !== $meta( 'teaser' ) ? $meta( 'teaser' ) : ( '' !== $content ? wp_trim_words( wp_strip_all_tags( $content ), 42 ) : '' ), |
| 69 |
'email' => $meta( 'ListAgentEmail' ), |
| 70 |
'phone' => $meta( 'ListAgentPreferredPhone' ), |
| 71 |
'office' => $meta( 'ListOfficeName' ), |
| 72 |
'mls_id' => $meta( 'ListAgentMlsId' ), |
| 73 |
/** Filter the agent's eyebrow/title label — the metabox's JobTitle, else a default. @since 6.4 */ |
| 74 |
'title' => (string) apply_filters( 'mlsimport_agent_title', '' !== $meta( 'JobTitle' ) ? $meta( 'JobTitle' ) : __( 'Real Estate Agent', 'mlsimport' ), $id ), |
| 75 |
// Operator ticked the "Verified agent" checkbox (stored as mlsimport_featured); |
| 76 |
// gates the "Verified Agent" hero badge. |
| 77 |
'verified' => '1' === $meta( 'featured' ), |
| 78 |
); |
| 79 |
|
| 80 |
// The agent's published listings (ids; count derives from it). |
| 81 |
$vm['listing_ids'] = get_posts( |
| 82 |
array( |
| 83 |
'post_type' => 'mlsimport_property', |
| 84 |
'post_status' => 'publish', |
| 85 |
'posts_per_page' => -1, |
| 86 |
'fields' => 'ids', |
| 87 |
'no_found_rows' => true, |
| 88 |
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- agent's own listings. |
| 89 |
'meta_key' => 'mlsimport_list_agent_id', |
| 90 |
'meta_value' => $id, |
| 91 |
) |
| 92 |
); |
| 93 |
|
| 94 |
/** Filter the agent view model. @since 6.4 */ |
| 95 |
$vm = (array) apply_filters( 'mlsimport_agent_data', $vm, $id ); |
| 96 |
|
| 97 |
// Cache and return. |
| 98 |
$cache[ $id ] = $vm; |
| 99 |
return $vm; |
| 100 |
} |
| 101 |
|
| 102 |
/** |
| 103 |
* Enqueue the agent profile's assets: the shared design tokens + section shell, |
| 104 |
* the agent stylesheet, and the reused sub-nav + lead scripts. Card/grid styles |
| 105 |
* ride along on the always-enqueued mlsimport-listings stylesheet. |
| 106 |
* |
| 107 |
* @return void |
| 108 |
*/ |
| 109 |
function mlsimport_agent_enqueue(): void { |
| 110 |
// Guard for non-WP contexts. |
| 111 |
if ( ! function_exists( 'wp_enqueue_style' ) ) { |
| 112 |
return; |
| 113 |
} |
| 114 |
// Make sure the shared section design tokens + shell are registered. |
| 115 |
Mlsimport_Property_Section_Assets::ensure_registered(); |
| 116 |
|
| 117 |
// Shared base style, then the agent stylesheet if it was registered. |
| 118 |
wp_enqueue_style( Mlsimport_Property_Section_Assets::BASE_STYLE ); |
| 119 |
if ( wp_style_is( 'mlsimport-agent', 'registered' ) ) { |
| 120 |
wp_enqueue_style( 'mlsimport-agent' ); |
| 121 |
} |
| 122 |
// Reuse the property sub-nav (scrollspy) and lead-form scripts. |
| 123 |
wp_enqueue_script( 'mlsimport-property-subnav' ); |
| 124 |
wp_enqueue_script( 'mlsimport-property-lead' ); |
| 125 |
} |
| 126 |
|
| 127 |
/** |
| 128 |
* Pre-enqueue the agent assets into the <head> on a single agent page. The |
| 129 |
* template prints the header before any section renders, so the on-demand |
| 130 |
* enqueue at render time lands in the footer and flashes unstyled (issue #188). |
| 131 |
* The in-template call stays and no-ops for the already-queued handles. |
| 132 |
* |
| 133 |
* @return void |
| 134 |
*/ |
| 135 |
function mlsimport_agent_enqueue_for_single(): void { |
| 136 |
if ( ! function_exists( 'is_singular' ) || ! is_singular( 'mlsimport_agent' ) ) { |
| 137 |
return; |
| 138 |
} |
| 139 |
mlsimport_agent_enqueue(); |
| 140 |
} |
| 141 |
|
| 142 |
/** |
| 143 |
* One icon + label + value contact row used in the hero's direct-contact block. |
| 144 |
* |
| 145 |
* @param string $icon Icon name (mlsimport_property_icon). |
| 146 |
* @param string $label Field label. |
| 147 |
* @param string $value Display value. |
| 148 |
* @param string $href Link target (tel:/mailto:/https:). |
| 149 |
* @return string HTML, or '' when the value is empty. |
| 150 |
*/ |
| 151 |
function mlsimport_agent_contact_line( string $icon, string $label, string $value, string $href ): string { |
| 152 |
// Omit the row entirely when there's no value to show. |
| 153 |
if ( '' === $value ) { |
| 154 |
return ''; |
| 155 |
} |
| 156 |
// Icon + label + value wrapped in a single tel:/mailto: link. |
| 157 |
return '<a class="mlsimport-agent-contact" href="' . esc_url( $href ) . '">' |
| 158 |
. '<span class="mlsimport-agent-contact__icon" aria-hidden="true">' . mlsimport_property_icon( $icon ) . '</span>' |
| 159 |
. '<span class="mlsimport-agent-contact__text">' |
| 160 |
. '<span class="mlsimport-agent-contact__label">' . esc_html( $label ) . '</span>' |
| 161 |
. '<span class="mlsimport-agent-contact__value">' . esc_html( $value ) . '</span>' |
| 162 |
. '</span></a>'; |
| 163 |
} |
| 164 |
|
| 165 |
/** |
| 166 |
* A wa.me link that opens a chat already addressed to this agent, so they get |
| 167 |
* "Hi Dana, I saw your profile ..." instead of a bare "hi". The listing-page |
| 168 |
* equivalent is mlsimport_property_whatsapp_link(), which talks about a property |
| 169 |
* rather than an agent. |
| 170 |
* |
| 171 |
* @param string $tel Agent phone, as entered (may hold spaces, +, punctuation). |
| 172 |
* @param string $first Agent first name, used to open the message. |
| 173 |
* @return string wa.me URL, or '' when the phone holds no digits. |
| 174 |
*/ |
| 175 |
function mlsimport_agent_whatsapp_link( string $tel, string $first ): string { |
| 176 |
// wa.me wants the number bare: digits only, no +, no spaces. |
| 177 |
$number = preg_replace( '/[^0-9]/', '', $tel ); |
| 178 |
// No digits → no link. |
| 179 |
if ( '' === $number ) { |
| 180 |
return ''; |
| 181 |
} |
| 182 |
|
| 183 |
// Opening line, mirroring the contact rail's pre-filled message. |
| 184 |
$message = sprintf( /* translators: %s: agent first name. */ __( "Hi %s, I saw your profile and I'd like to talk about ", 'mlsimport' ), $first ); |
| 185 |
|
| 186 |
/** Filter the WhatsApp message a visitor sends from an agent profile. @since 6.4 */ |
| 187 |
$message = (string) apply_filters( 'mlsimport_agent_whatsapp_message', $message, $first ); |
| 188 |
|
| 189 |
// wa.me deep link with the pre-filled, URL-encoded message. |
| 190 |
return 'https://wa.me/' . $number . '?text=' . rawurlencode( $message ); |
| 191 |
} |
| 192 |
|
| 193 |
/** |
| 194 |
* Editorial hero — tall portrait beside the agent's identity, bio teaser, the |
| 195 |
* direct-contact block (email visible) and the primary call/email actions. |
| 196 |
* |
| 197 |
* @param int $id Agent post ID. |
| 198 |
* @return string |
| 199 |
*/ |
| 200 |
function mlsimport_agent_hero( int $id = 0 ): string { |
| 201 |
// Load the view model; nothing to render without one. |
| 202 |
$a = mlsimport_agent_data( $id ); |
| 203 |
if ( empty( $a ) ) { |
| 204 |
return ''; |
| 205 |
} |
| 206 |
|
| 207 |
// Portrait (featured image) or a neutral placeholder. |
| 208 |
$portrait = $a['photo_id'] |
| 209 |
? get_the_post_thumbnail( $a['id'], 'large', array( 'class' => 'mlsimport-agent-hero__img' ) ) |
| 210 |
: '<span class="mlsimport-agent-hero__img mlsimport-agent-hero__img--empty" aria-hidden="true">' . mlsimport_property_icon( 'user' ) . '</span>'; |
| 211 |
|
| 212 |
// Office + (optional) MLS id meta line. |
| 213 |
$meta_bits = ''; |
| 214 |
if ( '' !== $a['office'] ) { |
| 215 |
$meta_bits .= '<span class="mlsimport-agent-hero__meta-item">' . mlsimport_property_icon( 'building' ) . esc_html( $a['office'] ) . '</span>'; |
| 216 |
} |
| 217 |
if ( '' !== $a['mls_id'] ) { |
| 218 |
$meta_bits .= '<span class="mlsimport-agent-hero__meta-dot" aria-hidden="true"></span>'; |
| 219 |
$meta_bits .= '<span class="mlsimport-agent-hero__meta-item">' . mlsimport_property_icon( 'badge' ) . esc_html__( 'MLS', 'mlsimport' ) . ' ' . esc_html( $a['mls_id'] ) . '</span>'; |
| 220 |
} |
| 221 |
|
| 222 |
// Direct-contact rows (only those with a value render). Office already shows |
| 223 |
// in the meta line above, so the grid carries the actionable phone + email. |
| 224 |
$contacts = mlsimport_agent_contact_line( 'phone', __( 'Phone', 'mlsimport' ), $a['phone'], 'tel:' . preg_replace( '/[^0-9+]/', '', $a['phone'] ) ); |
| 225 |
$contacts .= mlsimport_agent_contact_line( 'mail', __( 'Email', 'mlsimport' ), $a['email'], 'mailto:' . $a['email'] ); |
| 226 |
|
| 227 |
// CTAs. |
| 228 |
$ctas = ''; |
| 229 |
if ( '' !== $a['phone'] ) { |
| 230 |
$ctas .= '<a class="mlsimport-agent-btn mlsimport-agent-btn--primary" href="tel:' . esc_attr( preg_replace( '/[^0-9+]/', '', $a['phone'] ) ) . '">' |
| 231 |
. mlsimport_property_icon( 'phone' ) . esc_html( sprintf( /* translators: %s: agent first name. */ __( 'Call %s', 'mlsimport' ), $a['first'] ) ) . '</a>'; |
| 232 |
} |
| 233 |
if ( '' !== $a['email'] ) { |
| 234 |
$ctas .= '<a class="mlsimport-agent-btn mlsimport-agent-btn--outline" href="mailto:' . esc_attr( $a['email'] ) . '">' |
| 235 |
. mlsimport_property_icon( 'mail' ) . esc_html__( 'Email', 'mlsimport' ) . '</a>'; |
| 236 |
} |
| 237 |
// WhatsApp opens a chat already addressed to the agent by name. Same phone as |
| 238 |
// the Call CTA, so it only renders when there's a number with digits in it. |
| 239 |
$whatsapp = mlsimport_agent_whatsapp_link( $a['phone'], $a['first'] ); |
| 240 |
if ( '' !== $whatsapp ) { |
| 241 |
$ctas .= '<a class="mlsimport-agent-btn mlsimport-agent-btn--whatsapp" href="' . esc_url( $whatsapp ) . '" target="_blank" rel="noopener noreferrer">' |
| 242 |
. mlsimport_property_icon( 'whatsapp' ) . esc_html__( 'WhatsApp', 'mlsimport' ) . '</a>'; |
| 243 |
} |
| 244 |
|
| 245 |
// Assemble the hero: portrait column, then the identity column. |
| 246 |
$html = '<section id="mlsimport-section-hero" class="mlsimport-agent-hero">'; |
| 247 |
$html .= '<div class="mlsimport-agent-hero__portrait">' . $portrait; |
| 248 |
// Only a verified agent (operator-ticked) wears the badge. |
| 249 |
if ( ! empty( $a['verified'] ) ) { |
| 250 |
$html .= '<span class="mlsimport-agent-hero__verified">' . mlsimport_property_icon( 'check' ) . esc_html__( 'Verified Agent', 'mlsimport' ) . '</span>'; |
| 251 |
} |
| 252 |
$html .= '</div>'; |
| 253 |
|
| 254 |
$html .= '<div class="mlsimport-agent-hero__identity">'; |
| 255 |
$html .= '<span class="mlsimport-agent-hero__eyebrow">' . esc_html( $a['title'] ) . '</span>'; |
| 256 |
$html .= '<h1 class="mlsimport-agent-hero__name">' . esc_html( $a['name'] ) . '</h1>'; |
| 257 |
if ( '' !== $meta_bits ) { |
| 258 |
$html .= '<div class="mlsimport-agent-hero__meta">' . $meta_bits . '</div>'; |
| 259 |
} |
| 260 |
if ( '' !== $a['bio_teaser'] ) { |
| 261 |
$html .= '<p class="mlsimport-agent-hero__lead">' . esc_html( $a['bio_teaser'] ) . '</p>'; |
| 262 |
} |
| 263 |
if ( '' !== $contacts ) { |
| 264 |
$html .= '<div class="mlsimport-agent-hero__contact">'; |
| 265 |
$html .= '<div class="mlsimport-agent-hero__contact-label">' . esc_html__( 'Direct contact', 'mlsimport' ) . '</div>'; |
| 266 |
$html .= '<div class="mlsimport-agent-hero__contact-grid">' . $contacts . '</div>'; |
| 267 |
$html .= '</div>'; |
| 268 |
} |
| 269 |
if ( '' !== $ctas ) { |
| 270 |
$html .= '<div class="mlsimport-agent-hero__cta">' . $ctas . '</div>'; |
| 271 |
} |
| 272 |
$html .= '</div>'; // identity |
| 273 |
$html .= '</section>'; |
| 274 |
|
| 275 |
return $html; |
| 276 |
} |
| 277 |
|
| 278 |
/** |
| 279 |
* Sticky sub-nav — reuses the property sub-nav markup so its CSS + scrollspy JS |
| 280 |
* apply unchanged. Links whose target section isn't on the page hide themselves. |
| 281 |
* |
| 282 |
* @param int $id Agent post ID. |
| 283 |
* @return string |
| 284 |
*/ |
| 285 |
function mlsimport_agent_subnav( int $id = 0 ): string { |
| 286 |
// Load the view model; nothing to render without one. |
| 287 |
$a = mlsimport_agent_data( $id ); |
| 288 |
if ( empty( $a ) ) { |
| 289 |
return ''; |
| 290 |
} |
| 291 |
|
| 292 |
// Nav items as label => anchor-id of the target section. Links whose target |
| 293 |
// section isn't on the page hide themselves, so About drops out for a bio-less agent. |
| 294 |
$items = array( |
| 295 |
__( 'About', 'mlsimport' ) => 'mlsimport-section-about', |
| 296 |
__( 'Listings', 'mlsimport' ) => 'mlsimport-section-listings', |
| 297 |
__( 'Credentials', 'mlsimport' ) => 'mlsimport-section-credentials', |
| 298 |
__( 'Contact', 'mlsimport' ) => 'mlsimport-section-contact', |
| 299 |
); |
| 300 |
/** Filter the agent sub-nav items (label => anchor id). @since 6.4 */ |
| 301 |
$items = (array) apply_filters( 'mlsimport_agent_subnav_items', $items, $id ); |
| 302 |
|
| 303 |
// Build one sub-nav link per item. |
| 304 |
$links = ''; |
| 305 |
foreach ( $items as $label => $target ) { |
| 306 |
$links .= '<a class="mlsimport-property-subnav__link" href="#' . esc_attr( $target ) . '" data-target="' . esc_attr( $target ) . '">' . esc_html( $label ) . '</a>'; |
| 307 |
} |
| 308 |
|
| 309 |
// Wrap the links in the property sub-nav markup so its CSS/JS applies. |
| 310 |
return '<div class="mlsimport-agent-subnav">' |
| 311 |
. '<nav class="mlsimport-property-subnav" data-mlsimport-subnav aria-label="' . esc_attr__( 'Agent sections', 'mlsimport' ) . '">' . $links . '</nav>' |
| 312 |
. '</div>'; |
| 313 |
} |
| 314 |
|
| 315 |
/** |
| 316 |
* Render one reorderable agent content-column section by slug. The single source |
| 317 |
* of truth for slug => section fn, mirroring mlsimport_standalone_agent_section_catalog(). |
| 318 |
* The hero, sub-nav and contact rail are fixed and not routed here. |
| 319 |
* |
| 320 |
* @param string $slug Section slug (listings|credentials). |
| 321 |
* @param int $id Agent post ID. |
| 322 |
* @return string Section HTML, or '' for an unknown slug. |
| 323 |
*/ |
| 324 |
function mlsimport_render_agent_section( string $slug, int $id = 0 ): string { |
| 325 |
// Route the slug to its section renderer; unknown slugs return ''. |
| 326 |
switch ( $slug ) { |
| 327 |
case 'about': |
| 328 |
return mlsimport_agent_about( $id ); |
| 329 |
case 'listings': |
| 330 |
return mlsimport_agent_listings( $id ); |
| 331 |
case 'credentials': |
| 332 |
return mlsimport_agent_credentials( $id ); |
| 333 |
} |
| 334 |
return ''; |
| 335 |
} |
| 336 |
|
| 337 |
/** |
| 338 |
* About — the agent's full bio (the post body) in the shared section-card shell. |
| 339 |
* The hero shows only the short teaser; this is where the complete description |
| 340 |
* lives. Omitted when the agent has no bio. |
| 341 |
* |
| 342 |
* @param int $id Agent post ID. |
| 343 |
* @return string |
| 344 |
*/ |
| 345 |
function mlsimport_agent_about( int $id = 0 ): string { |
| 346 |
// Load the view model; nothing to render without a bio. |
| 347 |
$a = mlsimport_agent_data( $id ); |
| 348 |
if ( empty( $a ) || '' === $a['bio_html'] ) { |
| 349 |
return ''; |
| 350 |
} |
| 351 |
|
| 352 |
// Section-card shell wrapping the rendered bio. |
| 353 |
$html = mlsimport_property_section_open( 'about', sprintf( /* translators: %s: agent first name. */ __( 'About %s', 'mlsimport' ), $a['first'] ), 'user' ); |
| 354 |
$html .= '<div class="mlsimport-agent-about">' . wp_kses_post( $a['bio_html'] ) . '</div>'; |
| 355 |
$html .= mlsimport_property_section_close(); |
| 356 |
return $html; |
| 357 |
} |
| 358 |
|
| 359 |
/** |
| 360 |
* Listings — the agent's active listings as the shared listing cards, with a |
| 361 |
* count line. Reuses Mlsimport_Standalone_Render::cards_for_posts (and so card.php). |
| 362 |
* |
| 363 |
* @param int $id Agent post ID. |
| 364 |
* @return string |
| 365 |
*/ |
| 366 |
function mlsimport_agent_listings( int $id = 0 ): string { |
| 367 |
// Load the view model; nothing to render without one. |
| 368 |
$a = mlsimport_agent_data( $id ); |
| 369 |
if ( empty( $a ) ) { |
| 370 |
return ''; |
| 371 |
} |
| 372 |
|
| 373 |
// The agent's full listing-id set and its total count. |
| 374 |
$ids = (array) $a['listing_ids']; |
| 375 |
$count = count( $ids ); |
| 376 |
|
| 377 |
// Open the section-card shell. |
| 378 |
$html = mlsimport_property_section_open( 'listings', sprintf( /* translators: %s: agent first name. */ __( "%s's Listings", 'mlsimport' ), $a['first'] ), 'grid' ); |
| 379 |
|
| 380 |
// Render the paged card grid, or an empty-state line. |
| 381 |
if ( $count ) { |
| 382 |
// GET-based paging: slice the agent's full ID set to the current page, then emit |
| 383 |
// the shared pager. The agent page is a SINGULAR post, where WP's redirect_canonical |
| 384 |
// strips a bare ?page= (a reserved var for <!--nextpage--> content) — so this surface |
| 385 |
// pages on its own ?agent_page= key instead, while reusing the identical pager markup. |
| 386 |
// Per-page size (min 1), current page from ?agent_page=, and this page's id slice. |
| 387 |
$per_page = max( 1, (int) mlsimport_standalone_option( 'agent_listings_per_page', 12 ) ); |
| 388 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only GET paging of the agent's own listings. |
| 389 |
$current = isset( $_GET['agent_page'] ) ? max( 1, (int) $_GET['agent_page'] ) : 1; |
| 390 |
$page_ids = array_slice( $ids, ( $current - 1 ) * $per_page, $per_page ); |
| 391 |
|
| 392 |
// Count line above the grid. |
| 393 |
$html .= '<p class="mlsimport-agent-listings__count">' |
| 394 |
. esc_html( sprintf( /* translators: %s: number of listings. */ _n( '%s listing', '%s listings', $count, 'mlsimport' ), number_format_i18n( $count ) ) ) |
| 395 |
. '</p>'; |
| 396 |
|
| 397 |
// Cards per row: --mli-cols drives the grid, so the narrow-screen media |
| 398 |
// queries (2 then 1 across) still override it. |
| 399 |
$per_row = (int) mlsimport_standalone_option( 'agent_listings_per_row', 3 ); |
| 400 |
$per_row = max( 2, min( 4, $per_row ) ); |
| 401 |
|
| 402 |
$html .= '<div class="mlsimport-results__grid mlsimport-agent-listings__grid" style="--mli-cols:' . esc_attr( (string) $per_row ) . '">' . Mlsimport_Standalone_Render::cards_for_posts( $page_ids ) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- card.php escapes at source. |
| 403 |
// Same pager markup (the <nav> inside .mlsimport-results__pager) as every other listing surface. |
| 404 |
$html .= '<div class="mlsimport-results__pager">' . Mlsimport_Pagination::render( $count, $per_page, $current, array( 'param' => 'agent_page' ) ) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pager returns escaped markup. |
| 405 |
} else { |
| 406 |
// Empty state — the agent has no active listings. |
| 407 |
$html .= '<p class="mlsimport-results__empty">' . esc_html__( 'No active listings.', 'mlsimport' ) . '</p>'; |
| 408 |
} |
| 409 |
|
| 410 |
// Close the section-card shell. |
| 411 |
$html .= mlsimport_property_section_close(); |
| 412 |
return $html; |
| 413 |
} |
| 414 |
|
| 415 |
/** |
| 416 |
* Credentials & Licenses — the RESO Member/Office fields the importer stored, |
| 417 |
* each carrying its standard field name as provenance. Empty rows are omitted. |
| 418 |
* |
| 419 |
* @param int $id Agent post ID. |
| 420 |
* @return string |
| 421 |
*/ |
| 422 |
function mlsimport_agent_credentials( int $id = 0 ): string { |
| 423 |
// Load the view model; nothing to render without one. |
| 424 |
$a = mlsimport_agent_data( $id ); |
| 425 |
if ( empty( $a ) ) { |
| 426 |
return ''; |
| 427 |
} |
| 428 |
|
| 429 |
// label => [ value, RESO field name ]. |
| 430 |
$rows = array( |
| 431 |
__( 'Agent MLS ID', 'mlsimport' ) => array( $a['mls_id'], 'MemberMlsId' ), |
| 432 |
__( 'Brokerage', 'mlsimport' ) => array( $a['office'], 'OfficeName' ), |
| 433 |
__( 'Email', 'mlsimport' ) => array( $a['email'], 'MemberEmail' ), |
| 434 |
__( 'Direct Phone', 'mlsimport' ) => array( $a['phone'], 'MemberPreferredPhone' ), |
| 435 |
); |
| 436 |
/** Filter the agent credential rows (label => [value, reso]). @since 6.4 */ |
| 437 |
$rows = (array) apply_filters( 'mlsimport_agent_credentials', $rows, $id ); |
| 438 |
|
| 439 |
// Build a credential cell per row, skipping rows with no value. |
| 440 |
$cells = ''; |
| 441 |
foreach ( $rows as $label => $row ) { |
| 442 |
// Omit an empty-value credential. |
| 443 |
if ( '' === (string) $row[0] ) { |
| 444 |
continue; |
| 445 |
} |
| 446 |
$cells .= '<div class="mlsimport-agent-cred">' |
| 447 |
. '<span class="mlsimport-agent-cred__label">' . esc_html( $label ) . '</span>' |
| 448 |
. '<div class="mlsimport-agent-cred__value">' . esc_html( $row[0] ) . '</div>' |
| 449 |
. '</div>'; |
| 450 |
} |
| 451 |
// No populated credentials — omit the whole section. |
| 452 |
if ( '' === $cells ) { |
| 453 |
return ''; |
| 454 |
} |
| 455 |
|
| 456 |
// Section-card shell wrapping the credential grid. |
| 457 |
$html = mlsimport_property_section_open( 'credentials', __( 'Credentials & Licenses', 'mlsimport' ), 'badge' ); |
| 458 |
$html .= '<div class="mlsimport-agent-cred-grid">' . $cells . '</div>'; |
| 459 |
|
| 460 |
// Close the section-card shell. |
| 461 |
$html .= mlsimport_property_section_close(); |
| 462 |
return $html; |
| 463 |
} |
| 464 |
|
| 465 |
/** |
| 466 |
* Sticky contact rail — the solid-clay lead card: an agent mini-header and a |
| 467 |
* lead form that posts to the shared lead endpoint, routed to this agent. |
| 468 |
* |
| 469 |
* @param int $id Agent post ID. |
| 470 |
* @return string |
| 471 |
*/ |
| 472 |
function mlsimport_agent_contact_rail( int $id = 0 ): string { |
| 473 |
// Load the view model; nothing to render without one. |
| 474 |
$a = mlsimport_agent_data( $id ); |
| 475 |
if ( empty( $a ) ) { |
| 476 |
return ''; |
| 477 |
} |
| 478 |
|
| 479 |
// Lead-form nonce and the mini-header avatar (thumbnail or placeholder). |
| 480 |
$nonce = wp_create_nonce( Mlsimport_Property_Lead::NONCE ); |
| 481 |
$mini = $a['photo_id'] |
| 482 |
? get_the_post_thumbnail( $a['id'], 'thumbnail', array( 'class' => 'mlsimport-agent-rail__avatar-img' ) ) |
| 483 |
: '<span class="mlsimport-agent-rail__avatar-img mlsimport-agent-rail__avatar-img--empty" aria-hidden="true">' . mlsimport_property_icon( 'user' ) . '</span>'; |
| 484 |
|
| 485 |
// Pre-filled opening line for the message textarea. |
| 486 |
$prefill = sprintf( /* translators: %s: agent first name. */ __( "Hi %s, I'd like to talk about ", 'mlsimport' ), $a['first'] ); |
| 487 |
|
| 488 |
// Build the rail: mini-header, lead form, and the hidden success panel. |
| 489 |
$html = '<aside id="mlsimport-section-contact" class="mlsimport-agent-rail">'; |
| 490 |
$html .= '<div class="mlsimport-agent-rail__card">'; |
| 491 |
|
| 492 |
$html .= '<div class="mlsimport-agent-rail__head">'; |
| 493 |
$html .= '<span class="mlsimport-agent-rail__avatar">' . $mini . '</span>'; |
| 494 |
$html .= '<span class="mlsimport-agent-rail__who">'; |
| 495 |
$html .= '<span class="mlsimport-agent-rail__name">' . esc_html( $a['name'] ) . '</span>'; |
| 496 |
$html .= '<span class="mlsimport-agent-rail__title">' . esc_html( $a['title'] ) . '</span>'; |
| 497 |
$html .= '</span></div>'; |
| 498 |
|
| 499 |
$html .= '<form class="mlsimport-property-lead-form mlsimport-agent-rail__form" data-mlsimport-lead method="post">'; |
| 500 |
$html .= '<div class="mlsimport-agent-rail__form-title">' . esc_html( sprintf( /* translators: %s: agent first name. */ __( 'Contact %s', 'mlsimport' ), $a['first'] ) ) . '</div>'; |
| 501 |
$html .= '<input type="text" name="mlsimport_name" required placeholder="' . esc_attr__( 'Full name', 'mlsimport' ) . '" />'; |
| 502 |
$html .= '<input type="email" name="mlsimport_email" required placeholder="' . esc_attr__( 'Email', 'mlsimport' ) . '" />'; |
| 503 |
$html .= '<input type="tel" name="mlsimport_phone" placeholder="' . esc_attr__( 'Phone', 'mlsimport' ) . '" />'; |
| 504 |
$html .= '<textarea name="mlsimport_message" rows="3" placeholder="' . esc_attr__( 'Message', 'mlsimport' ) . '">' . esc_textarea( $prefill ) . '</textarea>'; |
| 505 |
// Honeypot — bots fill it, real users don't. |
| 506 |
$html .= '<input type="text" name="mlsimport_hp" class="mlsimport-property-lead-form__hp" tabindex="-1" autocomplete="off" aria-hidden="true" />'; |
| 507 |
$html .= '<input type="hidden" name="agent_id" value="' . esc_attr( (string) $a['id'] ) . '" />'; |
| 508 |
$html .= '<input type="hidden" name="nonce" value="' . esc_attr( $nonce ) . '" />'; |
| 509 |
// Same markup/classes as the property page's lead-form Send Message button |
| 510 |
// (property-sections.php), so both inherit .mlsimport-property-lead-form |
| 511 |
// button[type=submit] and look identical instead of the agent "light" variant. |
| 512 |
$html .= '<button type="submit">' . esc_html__( 'Send Message', 'mlsimport' ) . '</button>'; |
| 513 |
$html .= '<p class="mlsimport-property-lead-form__status" role="status" aria-live="polite"></p>'; |
| 514 |
$html .= '</form>'; |
| 515 |
|
| 516 |
$html .= '<div class="mlsimport-agent-rail__success" data-lead-success hidden>'; |
| 517 |
$html .= '<span class="mlsimport-agent-rail__success-icon" aria-hidden="true">' . mlsimport_property_icon( 'check' ) . '</span>'; |
| 518 |
$html .= '<div class="mlsimport-agent-rail__success-title">' . esc_html__( 'Message sent', 'mlsimport' ) . '</div>'; |
| 519 |
$html .= '<div class="mlsimport-agent-rail__success-text">' . esc_html( sprintf( /* translators: %s: agent name. */ __( '%s will get back to you shortly.', 'mlsimport' ), $a['name'] ) ) . '</div>'; |
| 520 |
$html .= '</div>'; |
| 521 |
|
| 522 |
$html .= '</div>'; // card |
| 523 |
$html .= '</aside>'; |
| 524 |
return $html; |
| 525 |
} |
| 526 |
|