PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2
7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 6.0.7 All 35 releases
mlsimport / includes / standalone / agent-sections.php

agent-sections.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.2, at includes/standalone/agent-sections.php

526 lines 23.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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