PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / trunk
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings vtrunk
7.2.2 7.2.1 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 All 37 releases
mlsimport / includes / standalone / class-mlsimport-standalone-render.php

class-mlsimport-standalone-render.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings trunk, at includes/standalone/class-mlsimport-standalone-render.php

529 lines 22.3 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) render orchestrator.
4 *
5 * Bridges the listings search to the front-end: runs one flat-table query, then
6 * primes the matching posts with a single WP_Query(post__in, orderby=post__in)
7 * so order is preserved and get_permalink()/thumbnail hit cache. Cards read
8 * scalars from the listings row (returned keyed by post_id) — display-only meta
9 * stays in postmeta. Markup lives in templates that consume this data.
10 *
11 * @package Mlsimport
12 */
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 require_once __DIR__ . '/class-mlsimport-standalone-listings-query.php';
19 require_once __DIR__ . '/class-mlsimport-standalone-template.php';
20 require_once __DIR__ . '/class-mlsimport-standalone-settings.php';
21 require_once __DIR__ . '/class-mlsimport-pagination.php';
22 require_once __DIR__ . '/class-mlsimport-map-clusterer.php';
23 require_once __DIR__ . '/listing-card.php';
24
25 /**
26 * Prepares ordered, cache-primed listing data for the front-end templates.
27 */
28 class Mlsimport_Standalone_Render {
29
30 /**
31 * Default page size for the full listings grid UI (the search-form + results
32 * surface). Page blocks that select a fixed set pass their own count.
33 */
34 const DEFAULT_PER_PAGE = 12;
35
36 /**
37 * Resolve the page size for the listings grid: an explicit positive limit wins,
38 * otherwise the (filterable) default. Shared by render_grid and the AJAX repaint
39 * so the first paint and every subsequent page use the same size.
40 *
41 * @param array $args Render args.
42 * @return int
43 */
44 public static function grid_limit( array $args ): int {
45 $limit = isset( $args['limit'] ) ? (int) $args['limit'] : 0;
46 if ( $limit > 0 ) {
47 return $limit;
48 }
49 // Design Settings → General → "No. of Properties per Page" is the site default;
50 // DEFAULT_PER_PAGE only applies when that option is unset or non-positive.
51 $configured = function_exists( 'mlsimport_standalone_option' ) ? (int) mlsimport_standalone_option( 'properties_per_page', 0 ) : 0;
52 $default = $configured > 0 ? $configured : self::DEFAULT_PER_PAGE;
53
54 /** Filter the default listings grid page size. @since 6.4 */
55 return max( 1, (int) apply_filters( 'mlsimport_listings_default_per_page', $default ) );
56 }
57
58 /**
59 * Resolve the grid's cards-per-row: an explicit columns arg (2/3/4) wins,
60 * otherwise 3. Emitted as the --mli-cols custom property on the grid wrapper
61 * (see render_grid). The archive template passes the site's "Cards per row"
62 * setting here; page-builder blocks pass nothing and stay at the default 3.
63 *
64 * @param array $args Render args.
65 * @return int One of 2, 3, 4.
66 */
67 public static function grid_columns( array $args ): int {
68 $columns = isset( $args['columns'] ) ? (int) $args['columns'] : 0;
69 return in_array( $columns, array( 2, 3, 4 ), true ) ? $columns : 3;
70 }
71
72 /**
73 * Options for the results toolbar's Sort select: value => label. Sourced from the
74 * settings registry's "Order by" field so the visitor-facing list and the admin's
75 * default-order list can never drift apart. The registry's 'default' becomes the
76 * empty value, i.e. "no explicit sort" — which lets the site default apply.
77 *
78 * @return array<string,string>
79 */
80 private static function sort_options(): array {
81 $registry = function_exists( 'mlsimport_standalone_field_registry' ) ? mlsimport_standalone_field_registry() : array();
82 $options = isset( $registry['order_by']['options'] ) ? (array) $registry['order_by']['options'] : array();
83
84 $result = array();
85 foreach ( $options as $value => $label ) {
86 $result[ 'default' === $value ? '' : (string) $value ] = (string) $label;
87 }
88
89 return $result;
90 }
91
92 /**
93 * The search form's visible field keys for $args, or null when every field
94 * shows (the default, and backward compatible). The listings block/shortcode
95 * pass search_fields as a comma list (or array) of the field keys to keep — an
96 * unset/empty value means "show all". search-form.php reads this to render only
97 * the enabled fields; it is pure display config and never touches the query.
98 *
99 * @param array $args Render args.
100 * @return string[]|null Enabled field keys, or null for "all".
101 */
102 public static function visible_fields( array $args ): ?array {
103 $result = null;
104 if ( isset( $args['search_fields'] ) && '' !== $args['search_fields'] && array() !== $args['search_fields'] ) {
105 // A non-empty selection lists exactly the fields to keep. Keys that match no
106 // real field (e.g. the "none" marker the block stores when every field is
107 // switched off) drop out, leaving an empty allow-list — i.e. show no fields.
108 $raw = is_array( $args['search_fields'] ) ? $args['search_fields'] : explode( ',', (string) $args['search_fields'] );
109 $result = array_values( array_filter( array_map( 'sanitize_key', array_map( 'strval', $raw ) ), 'strlen' ) );
110 }
111 /** Filter the search form's visible field keys; null shows every field. @since 6.4 */
112 $result = apply_filters( 'mlsimport_listings_visible_fields', $result, $args );
113 return is_array( $result ) ? $result : null;
114 }
115
116 /**
117 * Resolve filter args into the data a card grid renders.
118 *
119 * @param array $args Consumer filter params (see Mlsimport_Standalone_Query).
120 * @return array{posts:WP_Post[],rows:array<int,object>,total:int}
121 */
122 public static function prepare( array $args ): array {
123 /** Filter the inbound render args. @since 6.3 */
124 $args = (array) apply_filters( 'mlsimport_render_args', $args );
125 $result = Mlsimport_Standalone_Listings_Query::search( $args );
126
127 // Collect the matched post IDs (in row order) and index each row by post_id so
128 // the card template can look up its scalars without another query.
129 $ids = array();
130 $rows_by_id = array();
131 foreach ( $result['rows'] as $row ) {
132 $post_id = (int) $row->post_id;
133 $ids[] = $post_id;
134 $rows_by_id[ $post_id ] = $row;
135 }
136
137 // Prime the posts in one query, preserving the flat-table order via post__in.
138 $posts = array();
139 if ( $ids ) {
140 $query = new WP_Query(
141 array(
142 'post_type' => 'mlsimport_property',
143 'post__in' => $ids,
144 'orderby' => 'post__in',
145 'posts_per_page' => count( $ids ),
146 'no_found_rows' => true,
147 )
148 );
149 $posts = $query->posts;
150 }
151
152 $payload = array(
153 'posts' => $posts,
154 'rows' => $rows_by_id,
155 'total' => (int) $result['total'],
156 );
157
158 /** Filter the prepared listings payload. @since 6.3 */
159 return (array) apply_filters( 'mlsimport_prepared_listings', $payload, $args );
160 }
161
162 /**
163 * Render the listings grid HTML for $args by including the (theme-overridable)
164 * card template once per matching post.
165 *
166 * @param array $args Consumer filter params.
167 * @return string Concatenated card HTML, or '' when there are no matches.
168 */
169 public static function render( array $args ): string {
170 return self::render_cards( self::prepare( $args ) );
171 }
172
173 /**
174 * Render the full listings UI: search form + results grid (cards) — the
175 * droppable surface for the shortcode/block/widget. AJAX repaints the grid.
176 *
177 * @param array $args Consumer filter params.
178 * @return string
179 */
180 public static function render_grid( array $args ): string {
181 // Page the grid by default (a listings surface should never dump every row).
182 // The resolved limit is written back into $args so the search-form carries it
183 // (hidden field) and the AJAX repaint keeps the same page size.
184 $args['limit'] = self::grid_limit( $args );
185 $page = isset( $args['page'] ) ? max( 1, (int) $args['page'] ) : 1;
186 $args['page'] = $page;
187 $cols = self::grid_columns( $args );
188
189 $data = self::prepare( $args );
190 $cards = self::render_cards( $data );
191 $search_template = Mlsimport_Standalone_Template::locate( 'search-form.php' );
192 $total = (int) $data['total'];
193
194 // The form is always rendered (it carries the filters for the AJAX repaint);
195 // hide_search_form only adds a modifier the CSS uses to visually hide it, so a
196 // "results only" surface still paginates the same search without a reload.
197 $wrap_class = 'mlsimport-listings' . ( ! empty( $args['hide_search_form'] ) ? ' mlsimport-listings--no-form' : '' );
198
199 ob_start();
200 echo '<div class="' . esc_attr( $wrap_class ) . '">';
201 do_action( 'mlsimport_before_listings', $args, $total );
202 do_action( 'mlsimport_before_search_form', $args );
203 include $search_template; // Uses $args.
204 do_action( 'mlsimport_after_search_form', $args );
205 echo '<div class="mlsimport-results">';
206 // Count + sort share one toolbar row above the grid: count left, sort right.
207 // The Sort control lives HERE, not among the search filters, but still drives the
208 // same AJAX repaint — mlsimport-listings.js reads select[name="orderby"] from this
209 // toolbar (it is outside form.mlsimport-search, so FormData never sees it).
210 echo '<div class="mlsimport-results__toolbar">';
211 echo '<div class="mlsimport-results__meta">' . esc_html( $total . ' ' . _n( 'result', 'results', $total, 'mlsimport' ) ) . '</div>';
212 $visible = self::visible_fields( $args );
213 if ( null === $visible || in_array( 'sort', $visible, true ) ) {
214 // selected() on server render so the DOM value matches the preset; otherwise the
215 // select shows its first option and the next AJAX repaint silently sorts by that.
216 $orderby = Mlsimport_Standalone_Query::sort_token( $args );
217 // A DIV, not a LABEL: mlsimport-property-interest.js replaces the select with
218 // a <button>, and a button inside a label swallows its own clicks to the
219 // label's activation behaviour. The label text becomes a plain span instead.
220 //
221 // The .mlsimport-interest wrapper is what that enhancer looks for. It hides
222 // the native select (only once enhanced) and builds a button + listbox from
223 // the options, so Sort gets the same dropdown as the property page's
224 // "I'm interested in" — including a styled option list, which a native
225 // select can never have. The select stays in the DOM and stays the value
226 // mlsimport-listings.js reads, so the AJAX repaint is untouched and a page
227 // with no JS still shows a working control.
228 echo '<div class="mlsimport-results__sort">';
229 echo '<span class="mlsimport-results__sort-label">' . esc_html__( 'Sort by', 'mlsimport' ) . '</span>';
230 echo '<span class="mlsimport-interest mlsimport-results__sort-control">';
231 echo '<select name="orderby">';
232 foreach ( self::sort_options() as $mli_value => $mli_label ) {
233 echo '<option value="' . esc_attr( $mli_value ) . '"' . selected( $orderby, $mli_value, false ) . '>' . esc_html( $mli_label ) . '</option>';
234 }
235 echo '</select>';
236 echo '</span>';
237 echo '</div>';
238 }
239 /** Fires inside the results toolbar, after the Sort control (e.g. the "Save this search" button). @since 7.3 */
240 do_action( 'mlsimport_results_toolbar', $args, $total );
241 echo '</div>';
242 do_action( 'mlsimport_before_results', $args, $total );
243 echo '<div class="mlsimport-results__grid" style="--mli-cols:' . esc_attr( (string) $cols ) . ';">';
244 if ( '' !== $cards ) {
245 echo $cards; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- card.php escapes each field at source.
246 } else {
247 $empty = esc_html__( 'No listings match your search.', 'mlsimport' );
248 /** Filter the empty-results message. @since 6.3 */
249 echo wp_kses_post( apply_filters( 'mlsimport_no_results_message', '<p class="mlsimport-results__empty">' . $empty . '</p>', $args ) );
250 }
251 echo '</div>';
252 // Pager container — the AJAX layer repaints its inner <nav> on filter/paginate.
253 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Mlsimport_Pagination::render returns escaped markup.
254 echo '<div class="mlsimport-results__pager">' . Mlsimport_Pagination::render( $total, (int) $args['limit'], $page ) . '</div>';
255 do_action( 'mlsimport_after_results', $args, $total );
256 echo '</div></div>';
257 return (string) ob_get_clean();
258 }
259
260 /** Default cap on individual markers before the map switches to clusters. */
261 const MAP_MARKER_CAP = 400;
262
263 /**
264 * Map marker data for $args: one entry per matching listing that has
265 * coordinates. Honours every filter (incl. the lat/lng bounding box).
266 *
267 * @param array $args Consumer filter params.
268 * @return array<int,array{post_id:int,lat:float,lng:float,price:float|null,title:string,url:string,image:string,beds:string,baths:string,area:string}>
269 */
270 public static function markers( array $args ): array {
271 /** Filter the map marker set. @since 6.3 */
272 return (array) apply_filters( 'mlsimport_map_markers', self::markers_from_data( self::prepare( $args ) ), $args );
273 }
274
275 /**
276 * Map markers for an explicit, ordered set of property post IDs (the page
277 * block's hand-picked "by IDs" selection). Mirrors cards_for_posts() so a
278 * hand-picked map honours the chosen listings instead of the whole feed.
279 *
280 * @param int[] $ids Property post IDs.
281 * @return array
282 */
283 public static function markers_for_posts( array $ids ): array {
284 global $wpdb;
285
286 $ids = array_values( array_filter( array_map( 'intval', $ids ) ) );
287 if ( empty( $ids ) ) {
288 return array();
289 }
290
291 $table = $wpdb->prefix . 'mlsimport_listings';
292 $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
293 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
294 $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE post_id IN ({$placeholders})", $ids ) );
295
296 $rows_by_id = array();
297 foreach ( $rows as $row ) {
298 $rows_by_id[ (int) $row->post_id ] = $row;
299 }
300
301 $query = new WP_Query(
302 array(
303 'post_type' => 'mlsimport_property',
304 'post__in' => $ids,
305 'orderby' => 'post__in',
306 'posts_per_page' => count( $ids ),
307 'no_found_rows' => true,
308 )
309 );
310
311 return self::markers_from_data(
312 array(
313 'posts' => $query->posts,
314 'rows' => $rows_by_id,
315 )
316 );
317 }
318
319 /**
320 * Build the marker array from an already-prepared { posts, rows } set. One
321 * builder so the query, by-IDs and viewport paths all emit identical markers.
322 *
323 * @param array $data { posts: WP_Post[], rows: array<int,object> }.
324 * @return array
325 */
326 private static function markers_from_data( array $data ): array {
327 $markers = array();
328
329 // Spec formatter — mirrors mlsimport_format_amount() so the info-card
330 // beds/baths/area read the same as the single-property map, without a
331 // cross-file dependency on property-sections.php from this class.
332 $fmt = static function ( $value ) {
333 if ( null === $value || '' === $value ) {
334 return '';
335 }
336 $f = (float) $value;
337 return ( $f === (float) (int) $f ) ? number_format( $f ) : number_format( $f, 1 );
338 };
339
340 foreach ( $data['posts'] as $post ) {
341 $row = isset( $data['rows'][ $post->ID ] ) ? $data['rows'][ $post->ID ] : null;
342 if ( null === $row || null === $row->latitude || null === $row->longitude ) {
343 continue;
344 }
345
346 // Clean RESO address for the info-card heading — same source the
347 // listing cards use (mlsimport_card_view). The raw post title is an
348 // importer concatenation ("indian river 1475 ... ,Eugene,Lane,Residential")
349 // and is only the fallback when no UnparsedAddress is stored.
350 $title = trim( (string) get_post_meta( $post->ID, 'mlsimport_UnparsedAddress', true ) );
351 if ( '' === $title ) {
352 $title = get_the_title( $post->ID );
353 }
354
355 $markers[] = array(
356 'post_id' => (int) $post->ID,
357 'lat' => (float) $row->latitude,
358 'lng' => (float) $row->longitude,
359 'price' => null !== $row->price ? (float) $row->price : null,
360 'title' => $title,
361 'url' => (string) get_permalink( $post->ID ),
362 'image' => (string) ( get_the_post_thumbnail_url( $post->ID, 'medium' ) ?: '' ),
363 'beds' => $fmt( isset( $row->bedrooms ) ? $row->bedrooms : null ),
364 'baths' => $fmt( isset( $row->bathrooms ) ? $row->bathrooms : null ),
365 // Whole-number ft² (like the listing card + single-property popup); $fmt
366 // would leak a fractional area (it keeps a decimal for non-integers).
367 'area' => ( isset( $row->living_area ) && null !== $row->living_area && '' !== $row->living_area ) ? number_format_i18n( (float) $row->living_area ) : '',
368 );
369 }
370
371 return $markers;
372 }
373
374 /**
375 * Viewport map payload: given filter args (carrying the current lat/lng bbox)
376 * and the map zoom, grid-cluster every in-view listing into zoom-sized cells.
377 * A cell holding one listing returns a price pin (full card data); a cell
378 * holding several returns a counted bubble. Both arrays travel together, so
379 * dense areas collapse to bubbles while isolated listings stay pins — at every
380 * zoom, for any catalog size. The cell count is bounded by the viewport area,
381 * so the browser never receives more than a screenful of objects.
382 *
383 * @param array $args Filter args incl. lat_min/lat_max/lng_min/lng_max.
384 * @param int $zoom Leaflet zoom level (grid sizing).
385 * @return array{type:string,total:int,markers:array,clusters:array,bounds:?array}
386 */
387 public static function map_payload( array $args, int $zoom ): array {
388 /** Short-circuit the viewport map payload (live mode answers from the MLS here). @since 6.4 */
389 $pre = apply_filters( 'mlsimport_map_payload_pre', null, $args, $zoom );
390 if ( is_array( $pre ) ) {
391 return $pre;
392 }
393
394 // The map shows the whole in-view set (grid-clustered), never the grid's page
395 // size — drop any limit/page so coords and markers can't disagree on the count.
396 unset( $args['limit'], $args['page'] );
397
398 $coords = Mlsimport_Standalone_Listings_Query::coords( $args );
399 $total = count( $coords );
400
401 // The filter's OVERALL bounds (ignoring the viewport bbox), so a client that
402 // just changed the filter can refit the map to the whole result set. Computed
403 // from the args minus the bbox keys — otherwise bounds() would just echo the
404 // current viewport. Cheap (one indexed MIN/MAX) and ignored on plain panning.
405 $nogeo = $args;
406 unset( $nogeo['lat_min'], $nogeo['lat_max'], $nogeo['lng_min'], $nogeo['lng_max'] );
407 $bounds = Mlsimport_Standalone_Listings_Query::bounds( $nogeo );
408
409 // Split the grid cells: single-occupancy cells become pins (fetch full card
410 // data for just those lone listings), multi-occupancy cells become bubbles.
411 // The "Use the Pin Cluster" + "Maximum zoom" design settings decide whether a
412 // dense cell clusters at all — when off (or zoomed past the cut-over) every
413 // cell's listings render as individual price pins.
414 $cluster = mlsimport_standalone_map_cluster_enabled( $zoom );
415 $single = array();
416 $clusters = array();
417 foreach ( Mlsimport_Map_Clusterer::grid( $coords, $zoom ) as $cell ) {
418 // A crowded cell becomes a counted bubble only when clustering is on;
419 // otherwise every listing in it is drawn as its own pin.
420 if ( $cluster && $cell['count'] > 1 ) {
421 $clusters[] = array(
422 'lat' => $cell['lat'],
423 'lng' => $cell['lng'],
424 'count' => $cell['count'],
425 );
426 } else {
427 foreach ( $cell['ids'] as $id ) {
428 $single[] = (int) $id;
429 }
430 }
431 }
432
433 $markers = $single ? self::markers_for_posts( $single ) : array();
434 /** Filter the viewport map's individual (single-cell) markers. @since 6.3 */
435 $markers = (array) apply_filters( 'mlsimport_map_markers', $markers, $args );
436
437 return array(
438 'type' => $clusters ? 'clusters' : 'markers',
439 'total' => $total,
440 'markers' => $markers,
441 'clusters' => $clusters,
442 'bounds' => $bounds,
443 );
444 }
445
446 /**
447 * Render the card grid from an already-prepared data set (one query reused
448 * by both the page render and the AJAX payload).
449 *
450 * @param array $data Output of prepare().
451 * @param string $slide_class When set, each card is wrapped in a <li> with this
452 * class (e.g. 'splide__slide' for the content slider).
453 * @return string Concatenated card HTML, or '' when there are no posts.
454 */
455 public static function render_cards( array $data, string $slide_class = '' ): string {
456 if ( empty( $data['posts'] ) ) {
457 return '';
458 }
459
460 // One resolver picks the card design (v1/v2/v3) from settings, so every
461 // listing grid that reaches render_cards is styled consistently.
462 $card_name = function_exists( 'mlsimport_standalone_card_template' ) ? mlsimport_standalone_card_template() : 'card.php';
463 $template = Mlsimport_Standalone_Template::locate( $card_name );
464
465 $out = '';
466 foreach ( $data['posts'] as $post ) {
467 $row = isset( $data['rows'][ $post->ID ] ) ? $data['rows'][ $post->ID ] : null;
468 // Buffer each card individually so the before/after action slots and
469 // the per-card filter compose cleanly into one returned string.
470 ob_start();
471 do_action( 'mlsimport_before_listing_card', $post, $row );
472 include $template;
473 do_action( 'mlsimport_after_listing_card', $post, $row );
474 $card_html = (string) ob_get_clean();
475 /** Filter one card's HTML. @since 6.3 */
476 $card_html = (string) apply_filters( 'mlsimport_listing_card_html', $card_html, $post, $row );
477 $out .= '' !== $slide_class ? '<li class="' . esc_attr( $slide_class ) . '">' . $card_html . '</li>' : $card_html;
478 }
479 return $out;
480 }
481
482 /**
483 * Render cards for an explicit, ordered set of property post IDs (e.g. an
484 * agent's listings, linked by meta rather than the flat search). Fetches each
485 * post's listings row so the card shows its scalars.
486 *
487 * @param int[] $ids Property post IDs, in display order.
488 * @param string $slide_class When set, each card is wrapped in a <li> with this class.
489 * @return string
490 */
491 public static function cards_for_posts( array $ids, string $slide_class = '' ): string {
492 global $wpdb;
493
494 $ids = array_values( array_filter( array_map( 'intval', $ids ) ) );
495 if ( empty( $ids ) ) {
496 return '';
497 }
498
499 $table = $wpdb->prefix . 'mlsimport_listings';
500 $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
501 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
502 $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE post_id IN ({$placeholders})", $ids ) );
503
504 $rows_by_id = array();
505 foreach ( $rows as $row ) {
506 $rows_by_id[ (int) $row->post_id ] = $row;
507 }
508
509 $query = new WP_Query(
510 array(
511 'post_type' => 'mlsimport_property',
512 'post__in' => $ids,
513 'orderby' => 'post__in',
514 'posts_per_page' => count( $ids ),
515 'no_found_rows' => true,
516 )
517 );
518
519 return self::render_cards(
520 array(
521 'posts' => $query->posts,
522 'rows' => $rows_by_id,
523 'total' => count( $query->posts ),
524 ),
525 $slide_class
526 );
527 }
528 }
529