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-ajax.php

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

222 lines 8.1 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) AJAX filter/paginate endpoint (M6).
4 *
5 * The half-map / grid filters POST here; the handler verifies a nonce and returns
6 * the rendered cards + the full total so the JS can repaint the results column
7 * and pager without a page reload. The payload builder is separated from the
8 * nonce/JSON shell so it can be exercised in isolation.
9 *
10 * @package Mlsimport
11 */
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 require_once __DIR__ . '/class-mlsimport-standalone-render.php';
18 require_once __DIR__ . '/class-mlsimport-standalone-shortcodes.php';
19
20 /**
21 * Handles the nonce-protected listings filter AJAX request.
22 */
23 class Mlsimport_Standalone_Ajax {
24
25 const ACTION = 'mlsimport_filter';
26 const MARKERS_ACTION = 'mlsimport_markers';
27 const LOCATIONS_ACTION = 'mlsimport_locations';
28
29 /** How many suggestions one Location autocomplete response may carry. */
30 const LOCATION_LIMIT = 10;
31
32 /**
33 * Register the logged-in and logged-out AJAX handlers.
34 *
35 * @return void
36 */
37 public static function register(): void {
38 add_action( 'wp_ajax_' . self::ACTION, array( __CLASS__, 'handle' ) );
39 add_action( 'wp_ajax_nopriv_' . self::ACTION, array( __CLASS__, 'handle' ) );
40 add_action( 'wp_ajax_' . self::MARKERS_ACTION, array( __CLASS__, 'handle_markers' ) );
41 add_action( 'wp_ajax_nopriv_' . self::MARKERS_ACTION, array( __CLASS__, 'handle_markers' ) );
42 add_action( 'wp_ajax_' . self::LOCATIONS_ACTION, array( __CLASS__, 'handle_locations' ) );
43 add_action( 'wp_ajax_nopriv_' . self::LOCATIONS_ACTION, array( __CLASS__, 'handle_locations' ) );
44 }
45
46 /**
47 * AJAX entry point: verify nonce, send the payload as JSON.
48 *
49 * @return void
50 */
51 public static function handle(): void {
52 check_ajax_referer( self::ACTION, 'nonce' );
53 // Request is whitelisted+cast in payload(); unslash for mapping only.
54 $request = isset( $_POST ) ? wp_unslash( $_POST ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing
55 wp_send_json_success( self::payload( (array) $request ) );
56 }
57
58 /**
59 * Build the filter response: rendered cards + the full match total. Only
60 * whitelisted filter keys are honoured (via the shortcode att mapping); the
61 * query layer binds every value, so raw request input is injection-safe.
62 *
63 * @param array $request Raw request params.
64 * @return array{html:string,total:int}
65 */
66 public static function payload( array $request ): array {
67 $args = Mlsimport_Standalone_Shortcodes::atts_to_args( $request );
68 $args['limit'] = Mlsimport_Standalone_Render::grid_limit( $args );
69 $page = isset( $args['page'] ) ? max( 1, (int) $args['page'] ) : 1;
70 $args['page'] = $page;
71 $data = Mlsimport_Standalone_Render::prepare( $args );
72
73 return array(
74 'html' => Mlsimport_Standalone_Render::render_cards( $data ),
75 'total' => (int) $data['total'],
76 'pager' => Mlsimport_Pagination::render( (int) $data['total'], (int) $args['limit'], $page ),
77 );
78 }
79
80 /**
81 * AJAX entry point for map-pan: verify nonce, send markers as JSON.
82 *
83 * @return void
84 */
85 public static function handle_markers(): void {
86 check_ajax_referer( self::MARKERS_ACTION, 'nonce' );
87 $request = isset( $_POST ) ? wp_unslash( $_POST ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing
88 wp_send_json_success( self::markers_payload( (array) $request ) );
89 }
90
91 /**
92 * Build the viewport map response for the (whitelisted) filter request. The
93 * request carries the current map bbox (lat_min/lat_max/lng_min/lng_max, part
94 * of the filter att whitelist) and zoom; the render layer returns individual
95 * markers when the in-view count is small, or grid clusters when it is large.
96 *
97 * @param array $request Raw request params.
98 * @return array{type:string,total:int,markers?:array,clusters?:array}
99 */
100 public static function markers_payload( array $request ): array {
101 $args = Mlsimport_Standalone_Shortcodes::atts_to_args( $request );
102 $zoom = isset( $request['zoom'] ) ? (int) $request['zoom'] : 0;
103
104 return Mlsimport_Standalone_Render::map_payload( $args, $zoom );
105 }
106
107 /**
108 * AJAX entry point for the Location field's autocomplete.
109 *
110 * @return void
111 */
112 public static function handle_locations(): void {
113 check_ajax_referer( self::LOCATIONS_ACTION, 'nonce' );
114 $term = isset( $_GET['term'] ) ? sanitize_text_field( wp_unslash( $_GET['term'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified above.
115 wp_send_json_success( self::locations_payload( $term ) );
116 }
117
118 /**
119 * Suggestions for a partially typed location: the cities and ZIPs the fast
120 * table holds, plus the area and county terms — the same four places the
121 * WHERE-builder's location clause searches, so nothing is ever suggested that
122 * the search cannot then match.
123 *
124 * @param string $term Partial input; under two characters returns nothing.
125 * @return array<int,array{value:string,type:string}>
126 */
127 public static function locations_payload( string $term ): array {
128 $term = trim( $term );
129 // One or zero characters would match most of the index — not a suggestion.
130 if ( mb_strlen( $term ) < 2 ) {
131 return array();
132 }
133
134 $out = array_merge(
135 self::location_column_matches( 'city', $term, __( 'City', 'mlsimport' ) ),
136 self::location_term_matches( $term ),
137 self::location_column_matches( 'zip', $term, __( 'ZIP', 'mlsimport' ) )
138 );
139
140 /** Filter the Location autocomplete suggestions. @since 6.4 */
141 $out = (array) apply_filters( 'mlsimport_location_suggestions', $out, $term );
142
143 return array_slice( array_values( $out ), 0, self::LOCATION_LIMIT );
144 }
145
146 /**
147 * Distinct values of one fast-table location column starting with the typed
148 * text. Joined to wp_posts because the index table outlives its posts, and a
149 * suggestion drawn from an orphaned row would return zero results when picked.
150 *
151 * @param string $column 'city' or 'zip' — a fixed literal, never request input.
152 * @param string $term Partial input.
153 * @param string $type Human label for the suggestion's tag.
154 * @return array<int,array{value:string,type:string}>
155 */
156 private static function location_column_matches( string $column, string $term, string $type ): array {
157 global $wpdb;
158
159 $table = $wpdb->prefix . 'mlsimport_listings';
160 $like = $wpdb->esc_like( $term ) . '%';
161
162 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
163 $rows = $wpdb->get_col(
164 $wpdb->prepare(
165 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $column is a fixed literal from the caller.
166 "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",
167 $like,
168 self::LOCATION_LIMIT
169 )
170 );
171
172 $out = array();
173 foreach ( (array) $rows as $value ) {
174 if ( '' === (string) $value ) {
175 continue;
176 }
177 $out[] = array( 'value' => (string) $value, 'type' => $type );
178 }
179 return $out;
180 }
181
182 /**
183 * Area and county terms matching the typed text — the two location taxonomies
184 * with no fast column of their own.
185 *
186 * @param string $term Partial input.
187 * @return array<int,array{value:string,type:string}>
188 */
189 private static function location_term_matches( string $term ): array {
190 if ( ! class_exists( 'Mlsimport_Page_Block_Search_Fields' ) ) {
191 return array();
192 }
193 $terms = get_terms(
194 array(
195 'taxonomy' => Mlsimport_Page_Block_Search_Fields::location_taxonomies(),
196 'hide_empty' => true,
197 'search' => $term,
198 'number' => self::LOCATION_LIMIT,
199 )
200 );
201 if ( is_wp_error( $terms ) ) {
202 return array();
203 }
204
205 // The tag shows which taxonomy the term came from, so "Collier" reads as a
206 // county and not as another city.
207 $labels = array(
208 'mlsimport_area' => __( 'Area', 'mlsimport' ),
209 'mlsimport_county' => __( 'County', 'mlsimport' ),
210 );
211
212 $out = array();
213 foreach ( (array) $terms as $t ) {
214 $out[] = array(
215 'value' => (string) $t->name,
216 'type' => isset( $labels[ $t->taxonomy ] ) ? $labels[ $t->taxonomy ] : (string) $t->taxonomy,
217 );
218 }
219 return $out;
220 }
221 }
222