Mlsimport_Standalone_Render::render_cards( $data ), 'total' => (int) $data['total'], 'pager' => Mlsimport_Pagination::render( (int) $data['total'], (int) $args['limit'], $page ), ); } /** * AJAX entry point for map-pan: verify nonce, send markers as JSON. * * @return void */ public static function handle_markers(): void { check_ajax_referer( self::MARKERS_ACTION, 'nonce' ); $request = isset( $_POST ) ? wp_unslash( $_POST ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing wp_send_json_success( self::markers_payload( (array) $request ) ); } /** * Build the viewport map response for the (whitelisted) filter request. The * request carries the current map bbox (lat_min/lat_max/lng_min/lng_max, part * of the filter att whitelist) and zoom; the render layer returns individual * markers when the in-view count is small, or grid clusters when it is large. * * @param array $request Raw request params. * @return array{type:string,total:int,markers?:array,clusters?:array} */ public static function markers_payload( array $request ): array { $args = Mlsimport_Standalone_Shortcodes::atts_to_args( $request ); $zoom = isset( $request['zoom'] ) ? (int) $request['zoom'] : 0; return Mlsimport_Standalone_Render::map_payload( $args, $zoom ); } /** * AJAX entry point for the Location field's autocomplete. * * @return void */ public static function handle_locations(): void { check_ajax_referer( self::LOCATIONS_ACTION, 'nonce' ); $term = isset( $_GET['term'] ) ? sanitize_text_field( wp_unslash( $_GET['term'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified above. wp_send_json_success( self::locations_payload( $term ) ); } /** * Suggestions for a partially typed location: the cities and ZIPs the fast * table holds, plus the area and county terms — the same four places the * WHERE-builder's location clause searches, so nothing is ever suggested that * the search cannot then match. * * @param string $term Partial input; under two characters returns nothing. * @return array */ public static function locations_payload( string $term ): array { $term = trim( $term ); // One or zero characters would match most of the index — not a suggestion. if ( mb_strlen( $term ) < 2 ) { return array(); } $out = array_merge( self::location_column_matches( 'city', $term, __( 'City', 'mlsimport' ) ), self::location_term_matches( $term ), self::location_column_matches( 'zip', $term, __( 'ZIP', 'mlsimport' ) ) ); /** Filter the Location autocomplete suggestions. @since 6.4 */ $out = (array) apply_filters( 'mlsimport_location_suggestions', $out, $term ); return array_slice( array_values( $out ), 0, self::LOCATION_LIMIT ); } /** * Distinct values of one fast-table location column starting with the typed * text. Joined to wp_posts because the index table outlives its posts, and a * suggestion drawn from an orphaned row would return zero results when picked. * * @param string $column 'city' or 'zip' — a fixed literal, never request input. * @param string $term Partial input. * @param string $type Human label for the suggestion's tag. * @return array */ private static function location_column_matches( string $column, string $term, string $type ): array { global $wpdb; $table = $wpdb->prefix . 'mlsimport_listings'; $like = $wpdb->esc_like( $term ) . '%'; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $column is a fixed literal from the caller. "SELECT DISTINCT L.{$column} FROM {$table} L INNER JOIN {$wpdb->posts} P ON P.ID = L.post_id AND P.post_status = 'publish' AND P.post_type = 'mlsimport_property' WHERE L.{$column} LIKE %s ORDER BY L.{$column} LIMIT %d", $like, self::LOCATION_LIMIT ) ); $out = array(); foreach ( (array) $rows as $value ) { if ( '' === (string) $value ) { continue; } $out[] = array( 'value' => (string) $value, 'type' => $type ); } return $out; } /** * Area and county terms matching the typed text — the two location taxonomies * with no fast column of their own. * * @param string $term Partial input. * @return array */ private static function location_term_matches( string $term ): array { if ( ! class_exists( 'Mlsimport_Page_Block_Search_Fields' ) ) { return array(); } $terms = get_terms( array( 'taxonomy' => Mlsimport_Page_Block_Search_Fields::location_taxonomies(), 'hide_empty' => true, 'search' => $term, 'number' => self::LOCATION_LIMIT, ) ); if ( is_wp_error( $terms ) ) { return array(); } // The tag shows which taxonomy the term came from, so "Collier" reads as a // county and not as another city. $labels = array( 'mlsimport_area' => __( 'Area', 'mlsimport' ), 'mlsimport_county' => __( 'County', 'mlsimport' ), ); $out = array(); foreach ( (array) $terms as $t ) { $out[] = array( 'value' => (string) $t->name, 'type' => isset( $labels[ $t->taxonomy ] ) ? $labels[ $t->taxonomy ] : (string) $t->taxonomy, ); } return $out; } }