| 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()">×</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' ) . '">×</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 |
|