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-saved-search-front.php

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

289 lines 12.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Saved Search front end (Standalone mode only): everything a visitor touches.
4 *
5 * - The "Save this search" button + its modal, printed into the results
6 * toolbar of the SEARCH RESULTS block only (next to the Sort control) via
7 * the `mlsimport_results_toolbar` action. Other listing grids (the archive,
8 * the MLS Listings block) fire the same action but never ask for the button.
9 * - The AJAX endpoint the modal posts to (logged-in and anonymous visitors —
10 * no WordPress account is needed). An agent uses the very same button and
11 * types the client's name and email; the client still has to confirm.
12 * - The two emailed links: ?mlsimport_ss=confirm|unsubscribe&token=...
13 *
14 * The HTTP/nonce shells here stay thin; the work is in Mlsimport_Saved_Search
15 * (create/confirm/unsubscribe) so it is testable without a request.
16 *
17 * Extension points: mlsimport_results_toolbar (action, fired by render_grid),
18 * mlsimport_saved_search_button_html, mlsimport_saved_search_link_message.
19 *
20 * @package Mlsimport
21 */
22
23 if ( ! defined( 'ABSPATH' ) ) {
24 exit;
25 }
26
27 /**
28 * Renders the Save button/modal and handles its AJAX + the emailed links.
29 */
30 class Mlsimport_Saved_Search_Front {
31
32 const ACTION = 'mlsimport_save_search';
33
34 /** Query arg the emailed links redirect with, so the site shows the answer. */
35 const NOTICE_ARG = 'mlsimport_ss_notice';
36
37 /** Notice state => [ link action, ok ] it stands for. */
38 const NOTICE_STATES = array(
39 'confirmed' => array( 'confirm', true ),
40 'unsubscribed' => array( 'unsubscribe', true ),
41 'invalid' => array( '', false ),
42 );
43
44 /**
45 * Hook everything up. Nothing is hooked while the feature is unusable
46 * (not Standalone mode, or switched off), except the emailed links: a
47 * Recipient must always be able to unsubscribe.
48 *
49 * @return void
50 */
51 public static function register(): void {
52 add_action( 'template_redirect', array( __CLASS__, 'handle_link' ) );
53 add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_notice' ), 22 );
54 add_action( 'wp_footer', array( __CLASS__, 'print_notice' ) );
55
56 if ( ! Mlsimport_Saved_Search::enabled() ) {
57 return;
58 }
59 add_action( 'wp_ajax_' . self::ACTION, array( __CLASS__, 'handle' ) );
60 add_action( 'wp_ajax_nopriv_' . self::ACTION, array( __CLASS__, 'handle' ) );
61 add_action( 'mlsimport_results_toolbar', array( __CLASS__, 'toolbar_button' ), 10, 1 );
62 add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue' ), 21 );
63 }
64
65 /**
66 * AJAX entry: verify the nonce, run create(), answer JSON.
67 *
68 * @return void
69 */
70 public static function handle(): void {
71 check_ajax_referer( self::ACTION, 'nonce' );
72
73 $result = Mlsimport_Saved_Search::create( wp_unslash( $_POST ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above.
74
75 if ( $result['ok'] ) {
76 wp_send_json_success( array( 'message' => $result['message'] ) );
77 }
78 wp_send_json_error( array( 'message' => $result['message'] ), 400 );
79 }
80
81 /**
82 * Act on an emailed link. Pure of the HTTP layer: takes the query args,
83 * returns what to tell the visitor — or null when the request is not ours.
84 *
85 * @param array $query Request query args (unslashed).
86 * @return array{ok:bool,action:string,message:string}|null
87 */
88 public static function link_result( array $query ) {
89 $action = isset( $query['mlsimport_ss'] ) ? (string) $query['mlsimport_ss'] : '';
90 if ( ! in_array( $action, array( 'confirm', 'unsubscribe' ), true ) ) {
91 return null;
92 }
93 $token = isset( $query['token'] ) ? (string) $query['token'] : '';
94
95 // The link does exactly one thing: confirm, or unsubscribe. Nothing else.
96 $ok = 'confirm' === $action
97 ? Mlsimport_Saved_Search::confirm( $token )
98 : Mlsimport_Saved_Search::unsubscribe( $token );
99
100 return array(
101 'ok' => $ok,
102 'action' => $action,
103 'message' => self::link_message( $action, $ok ),
104 );
105 }
106
107 /**
108 * The words shown after an emailed link is used.
109 *
110 * @param string $action 'confirm' or 'unsubscribe'.
111 * @param bool $ok Whether the link worked.
112 * @return string
113 */
114 public static function link_message( string $action, bool $ok ): string {
115 if ( ! $ok ) {
116 $message = __( 'This link is not valid any more.', 'mlsimport' );
117 } elseif ( 'confirm' === $action ) {
118 $message = __( 'Your saved search is confirmed. You will get one email a day when new or updated listings match it.', 'mlsimport' );
119 } else {
120 $message = __( 'You are unsubscribed. You will not receive any more emails for this saved search.', 'mlsimport' );
121 }
122
123 /** Filter the message shown after an emailed link is used. @since 7.3 */
124 return (string) apply_filters( 'mlsimport_saved_search_link_message', $message, $action, $ok );
125 }
126
127 /**
128 * template_redirect shell for the emailed links: act on the token, then send
129 * the visitor on, where print_notice() shows the answer inside the site's own
130 * layout. A confirmed search lands on the results page it was saved from, with
131 * its criteria applied; an unsubscribe or a dead link lands on the home page.
132 * The token never reaches the landing URL.
133 *
134 * @return void
135 */
136 public static function handle_link(): void {
137 // Step 1 — act on the token; null means the request is not one of our links.
138 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the emailed token is the credential.
139 $query = wp_unslash( $_GET );
140 $result = self::link_result( $query );
141 if ( null === $result ) {
142 return;
143 }
144
145 // Step 2 — the notice state, and where it is shown. Only a successful confirm
146 // goes back to the saved results page (stored at save time, same-host checked
147 // there); the home page is the fallback should that URL be missing.
148 $target = home_url( '/' );
149 if ( ! $result['ok'] ) {
150 $state = 'invalid';
151 } elseif ( 'confirm' === $result['action'] ) {
152 $state = 'confirmed';
153 $saved = Mlsimport_Saved_Search::results_url_for_token( (string) $query['token'] );
154 $target = '' !== $saved ? $saved : $target;
155 } else {
156 $state = 'unsubscribed';
157 }
158
159 // Step 3 — redirect with the state; print_notice() strips it from the address bar.
160 wp_safe_redirect( add_query_arg( self::NOTICE_ARG, $state, $target ) );
161 exit;
162 }
163
164 /**
165 * The notice state in the current request, or '' when there is none.
166 *
167 * @return string
168 */
169 private static function notice_state(): string {
170 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only, whitelisted below.
171 $state = isset( $_GET[ self::NOTICE_ARG ] ) ? sanitize_key( wp_unslash( $_GET[ self::NOTICE_ARG ] ) ) : '';
172 return isset( self::NOTICE_STATES[ $state ] ) ? $state : '';
173 }
174
175 /**
176 * Load the notice style on the landing page. Hooked apart from enqueue() so it
177 * also works when the feature is switched off (unsubscribe must always work).
178 *
179 * @return void
180 */
181 public static function enqueue_notice(): void {
182 if ( '' === self::notice_state() || ! apply_filters( 'mlsimport_standalone_styles', true ) ) {
183 return;
184 }
185 wp_enqueue_style( 'mlsimport-saved-search', MLSIMPORT_PLUGIN_URL . 'public/css/mlsimport-saved-search.css', array(), MLSIMPORT_VERSION );
186 }
187
188 /**
189 * Print the link's answer as a banner over the landing page. Fixed-position
190 * from wp_footer, so it shows on any theme, classic or block.
191 *
192 * @return void
193 */
194 public static function print_notice(): void {
195 $state = self::notice_state();
196 if ( '' === $state ) {
197 return;
198 }
199 list( $action, $ok ) = self::NOTICE_STATES[ $state ];
200 $message = self::link_message( $action, $ok );
201
202 printf(
203 '<div class="mlsimport-ss-notice%1$s" role="status" data-mlsimport-ss-notice><p class="mlsimport-ss-notice__text">%2$s</p><button type="button" class="mlsimport-ss-notice__close" aria-label="%3$s" onclick="this.parentNode.remove()">&times;</button></div>',
204 $ok ? '' : ' is-error',
205 esc_html( $message ),
206 esc_attr__( 'Close', 'mlsimport' )
207 );
208
209 // Drop the arg from the address bar at once (a refresh or a shared link must
210 // not show the message again), then fade the box out after a few seconds.
211 printf(
212 '<script>(function(){var a=%1$s;try{var u=new URL(location.href);if(u.searchParams.has(a)){u.searchParams.delete(a);history.replaceState(null,"",u.pathname+u.search+u.hash);}}catch(e){}setTimeout(function(){var n=document.querySelector("[data-mlsimport-ss-notice]");if(!n){return;}n.classList.add("is-hiding");setTimeout(function(){n.remove();},300);},6000);})();</script>',
213 wp_json_encode( self::NOTICE_ARG )
214 );
215 }
216
217 /**
218 * Print the button + modal into a results toolbar — only when the surface
219 * asked for it ($args['saved_search'], set by the Search Results block).
220 *
221 * @param array $args The grid's render args.
222 * @return void
223 */
224 public static function toolbar_button( $args ): void {
225 // Step 1 — only the Search Results block opts in, and only while enabled
226 // (re-checked here: the setting may be filtered per request).
227 if ( empty( $args['saved_search'] ) || ! Mlsimport_Saved_Search::enabled() ) {
228 return;
229 }
230
231 // Step 2 — a logged-in visitor gets their own name/email prefilled. An agent
232 // saving for a client simply overwrites them.
233 $user = wp_get_current_user();
234 $name = $user->exists() ? (string) $user->display_name : '';
235 $email = $user->exists() ? (string) $user->user_email : '';
236
237 // Step 3 — button + native <dialog> (no modal library; Esc/backdrop close for
238 // free). The JS reads the current filters from the grid's own search form.
239 $html = '<button type="button" class="mlsimport-save-search__open" data-mlsimport-save-search>' . esc_html__( 'Save this search', 'mlsimport' ) . '</button>';
240 $html .= '<dialog class="mlsimport-save-search" data-mlsimport-save-search-dialog>';
241 $html .= '<form class="mlsimport-save-search__form" method="dialog">';
242 $html .= '<h3 class="mlsimport-save-search__title">' . esc_html__( 'Save this search', 'mlsimport' ) . '</h3>';
243 $html .= '<p class="mlsimport-save-search__lead">' . esc_html__( 'Get one email a day with new and updated listings that match.', 'mlsimport' ) . '</p>';
244 $html .= '<input type="text" name="mlsimport_name" required placeholder="' . esc_attr__( 'Name', 'mlsimport' ) . '" value="' . esc_attr( $name ) . '" />';
245 $html .= '<input type="email" name="mlsimport_email" required placeholder="' . esc_attr__( 'Email', 'mlsimport' ) . '" value="' . esc_attr( $email ) . '" />';
246 // Honeypot: hidden from people, irresistible to bots.
247 $html .= '<input type="text" name="mlsimport_hp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true" />';
248 $html .= mlsimport_property_lead_consent_field();
249 $html .= '<p class="mlsimport-save-search__message" role="status" aria-live="polite"></p>';
250 $html .= '<div class="mlsimport-save-search__actions">';
251 $html .= '<button type="button" class="mlsimport-save-search__cancel" data-mlsimport-save-search-cancel>' . esc_html__( 'Cancel', 'mlsimport' ) . '</button>';
252 $html .= '<button type="submit" class="mlsimport-save-search__submit">' . esc_html__( 'Save search', 'mlsimport' ) . '</button>';
253 $html .= '</div>';
254 // Top-right "x", last in the DOM so the dialog still autofocuses the Name field
255 // (CSS pins it to the corner). Shares the Cancel button's data attribute, so the JS closes
256 // the dialog from either one with the same listener.
257 $html .= '<button type="button" class="mlsimport-save-search__close" data-mlsimport-save-search-cancel aria-label="' . esc_attr__( 'Close', 'mlsimport' ) . '">&times;</button>';
258 $html .= '</form></dialog>';
259
260 /** Filter the Save button + modal markup. @since 7.3 */
261 echo apply_filters( 'mlsimport_saved_search_button_html', $html, $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- every dynamic value is escaped above.
262 }
263
264 /**
265 * Enqueue the modal's script + style, with the AJAX envelope.
266 *
267 * @return void
268 */
269 public static function enqueue(): void {
270 $url = MLSIMPORT_PLUGIN_URL;
271 $ver = MLSIMPORT_VERSION;
272
273 if ( apply_filters( 'mlsimport_standalone_styles', true ) ) {
274 wp_enqueue_style( 'mlsimport-saved-search', $url . 'public/css/mlsimport-saved-search.css', array( 'mlsimport-listings' ), $ver );
275 }
276 wp_enqueue_script( 'mlsimport-saved-search', $url . 'public/js/mlsimport-saved-search.js', array(), $ver, true );
277 wp_localize_script(
278 'mlsimport-saved-search',
279 'MLSImportSavedSearch',
280 array(
281 'ajaxurl' => admin_url( 'admin-ajax.php' ),
282 'action' => self::ACTION,
283 'nonce' => wp_create_nonce( self::ACTION ),
284 'error' => __( 'Sorry, your search could not be saved. Please try again.', 'mlsimport' ),
285 )
286 );
287 }
288 }
289