array( __CLASS__, 'render_block' ) ) ); } /** * Block render callback. * * @param array $attributes Block attributes. * @return string */ public static function render_block( $attributes ) { return self::render( isset( $attributes['title'] ) ? (string) $attributes['title'] : '' ); } /** * Shortcode handler. * * @param array|string $atts Attributes. * @return string */ public static function shortcode( $atts ) { $atts = shortcode_atts( array( 'title' => '' ), (array) $atts, self::SHORTCODE ); return self::render( $atts['title'] ); } /** * Which input does the checker offer? * * @return string address|postcode|none. */ public static function input_mode() { if ( Api_Client::enabled() ) { return 'address'; } foreach ( Settings::flat_zones() as $zone ) { if ( isset( $zone['type'] ) && Zones::TYPE_POSTCODES === $zone['type'] ) { return 'postcode'; } } return 'none'; } /** * Markup of the checker. * * @param string $title Optional heading. * @return string */ public static function render( $title = '' ) { if ( ! Settings::has_zones() ) { return current_user_can( 'manage_woocommerce' ) ? '

' . esc_html__( 'ReachZone: add a delivery zone under WooCommerce > ReachZone to show the checker.', 'reachzone' ) . '

' : ''; } wp_enqueue_style( 'reachzone-checker' ); wp_enqueue_script( 'reachzone-checker' ); if ( ! self::$printed ) { self::$printed = true; wp_add_inline_script( 'reachzone-checker', 'window.reachzoneChecker = ' . wp_json_encode( self::client_data() ) . ';', 'before' ); } $mode = self::input_mode(); $uid = wp_unique_id( 'reachzone-input-' ); $label = 'address' === $mode ? __( 'Your delivery address', 'reachzone' ) : __( 'Your postcode', 'reachzone' ); ob_start(); ?>

isset( $zone['id'] ) ? $zone['id'] : '', 'label' => isset( $zone['label'] ) ? $zone['label'] : '', 'type' => isset( $zone['type'] ) ? $zone['type'] : '', 'priority' => isset( $zone['priority'] ) ? (int) $zone['priority'] : 10, 'active' => ! empty( $zone['active'] ), 'eta' => isset( $zone['eta'] ) ? $zone['eta'] : '', 'fee_text' => self::fee_text( $zone ), 'area_km2' => Zones::area_km2( $zone ), 'center_lat' => isset( $zone['center_lat'] ) ? $zone['center_lat'] : null, 'center_lon' => isset( $zone['center_lon'] ) ? $zone['center_lon'] : null, 'radius_km' => Zones::TYPE_RADIUS === $zone['type'] ? Zones::radius_km( $zone ) : 0, 'geojson' => ( Zones::TYPE_POLYGON === $zone['type'] || Zones::TYPE_DRIVE_TIME === $zone['type'] ) && ! empty( $zone['geojson'] ) ? $zone['geojson'] : null, 'postcodes' => Zones::TYPE_POSTCODES === $zone['type'] && ! empty( $zone['postcodes'] ) ? array_values( $zone['postcodes'] ) : array(), ); $zones[] = $item; } return array( 'restUrl' => esc_url_raw( rest_url( self::REST_NS . '/check' ) ), 'mode' => self::input_mode(), 'zones' => $zones, 'messages' => array( 'in' => Settings::message( 'msg_in' ), 'out' => Settings::message( 'msg_out' ), 'unknown' => __( 'Please enter your postcode or use your current location so we can check our delivery area.', 'reachzone' ), 'notFound' => __( 'We could not find that address. Please check it and try again.', 'reachzone' ), 'error' => __( 'Sorry, the check did not work. Please try again in a moment.', 'reachzone' ), 'checking' => __( 'Checking...', 'reachzone' ), 'geoDenied' => __( 'We could not get your location. You can type your address or postcode instead.', 'reachzone' ), 'geoMissing' => __( 'Your browser does not support location sharing.', 'reachzone' ), 'empty' => __( 'Please enter an address or postcode first.', 'reachzone' ), ), ); } /** * REST routes. */ public static function register_routes() { register_rest_route( self::REST_NS, '/check', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'rest_check' ), // Public on purpose: shoppers are not logged in. It is read-only apart from remembering the shopper's own // coordinates in their session, is rate limited, and carries no nonce so that cached pages keep working. 'permission_callback' => '__return_true', 'args' => array( 'mode' => array( 'type' => 'string', 'enum' => array( 'address', 'postcode', 'coords' ), 'required' => true, ), 'address' => array( 'type' => 'string', 'maxLength' => 300, 'sanitize_callback' => 'sanitize_text_field', ), 'postcode' => array( 'type' => 'string', 'maxLength' => 12, 'sanitize_callback' => 'sanitize_text_field', ), 'country' => array( 'type' => 'string', 'maxLength' => 2, 'sanitize_callback' => 'sanitize_text_field', ), 'lat' => array( 'type' => 'number' ), 'lon' => array( 'type' => 'number' ), ), ) ); } /** * Handle a check. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public static function rest_check( $request ) { if ( self::rate_limited() ) { return new \WP_Error( 'reachzone_rate_limited', __( 'Too many checks. Please wait a few minutes.', 'reachzone' ), array( 'status' => 429 ) ); } $mode = $request->get_param( 'mode' ); if ( ! self::text_params_valid( array( 'address' => $request->get_param( 'address' ), 'postcode' => $request->get_param( 'postcode' ), 'country' => $request->get_param( 'country' ) ) ) ) { return new \WP_Error( 'reachzone_bad_input', __( 'Invalid input.', 'reachzone' ), array( 'status' => 400 ) ); } $lat = null; $lon = null; $pc = ''; if ( function_exists( 'wc_load_cart' ) && ( ! function_exists( 'WC' ) || ! WC()->session ) ) { wc_load_cart(); } if ( 'address' === $mode ) { if ( ! Api_Client::enabled() ) { return new \WP_Error( 'reachzone_address_unavailable', __( 'Address lookup is not available in this shop.', 'reachzone' ), array( 'status' => 400 ) ); } $address = trim( (string) $request->get_param( 'address' ) ); if ( strlen( $address ) < 3 ) { return new \WP_Error( 'reachzone_bad_address', __( 'Please enter an address.', 'reachzone' ), array( 'status' => 400 ) ); } $geo = Api_Client::geocode( $address, Api_Client::country( (string) $request->get_param( 'country' ) ) ); if ( is_wp_error( $geo ) ) { return 'reachzone_not_found' === $geo->get_error_code() ? self::response( 'not_found', __( 'We could not find that address. Please check it and try again.', 'reachzone' ) ) : new \WP_Error( 'reachzone_lookup_failed', __( 'Sorry, the check did not work. Please try again in a moment.', 'reachzone' ), array( 'status' => 502 ) ); } $lat = $geo['lat']; $lon = $geo['lon']; } elseif ( 'coords' === $mode ) { $lat = $request->get_param( 'lat' ); $lon = $request->get_param( 'lon' ); if ( ! is_numeric( $lat ) || ! is_numeric( $lon ) || abs( $lat ) > 90 || abs( $lon ) > 180 ) { return new \WP_Error( 'reachzone_bad_coords', __( 'Invalid position.', 'reachzone' ), array( 'status' => 400 ) ); } $lat = (float) $lat; $lon = (float) $lon; } else { $pc = trim( (string) $request->get_param( 'postcode' ) ); } if ( null !== $lat ) { // Only coordinates are remembered for the shopper's session. The typed address is dropped here. Resolver::set_session_point( $lat, $lon ); if ( function_exists( 'WC' ) && WC()->session && ! WC()->session->has_session() ) { WC()->session->set_customer_session_cookie( true ); } } $decision = Zones::decide( Settings::flat_zones(), $lat, $lon, $pc ); if ( 'out' === $decision['status'] && null !== $lat ) { Demand::record( $lat, $lon, 'checker' ); } return self::response( $decision['status'], '', $decision['zone'] ); } /** * Are the text inputs strings within their limits? The route schema states the same limits, but older WordPress * versions skip maxLength, so they are enforced here too, before anything is geocoded or looked up. * * @param array $params address, postcode and country values (null when absent). * @return bool */ public static function text_params_valid( array $params ) { $max = array( 'address' => 300, 'postcode' => 12, 'country' => 2, ); foreach ( $max as $name => $limit ) { if ( isset( $params[ $name ] ) && ( ! is_string( $params[ $name ] ) || strlen( $params[ $name ] ) > $limit ) ) { return false; } } return true; } /** * Build the REST response for a decision. * * @param string $status in|out|unknown|none|not_found. * @param string $message Override message. * @param array|null $zone Matching zone. * @return \WP_REST_Response */ private static function response( $status, $message = '', $zone = null ) { $data = array( 'status' => $status, 'zone' => null, ); if ( 'in' === $status && is_array( $zone ) ) { $eta = isset( $zone['eta'] ) ? $zone['eta'] : ''; $fee = self::fee_text( $zone ); $data = array( 'status' => 'in', 'message' => Zones::format_message( Settings::message( 'msg_in' ), $eta, $fee ), 'zone' => array( 'label' => isset( $zone['label'] ) ? $zone['label'] : '', 'eta' => $eta, 'fee_text' => $fee, ), ); } elseif ( 'out' === $status || 'none' === $status ) { $data['message'] = Settings::message( 'msg_out' ); } elseif ( 'unknown' === $status ) { $data['message'] = __( 'Please enter your postcode or use your current location so we can check our delivery area.', 'reachzone' ); } else { $data['message'] = $message; } return new \WP_REST_Response( $data, 200 ); } /** * Simple per-visitor rate limit. The transient key is a keyed hash of the IP address; nothing else is kept and the * entry expires after ten minutes. * * @return bool True when the visitor is over the limit. */ private static function rate_limited() { $ip = Client_Ip::current(); $key = 'rz_rl_' . substr( hash_hmac( 'sha256', $ip, wp_salt( 'nonce' ) ), 0, 32 ); $limit = (int) apply_filters( 'reachzone_rate_limit', self::RATE_LIMIT ); $count = (int) get_transient( $key ); if ( $count >= $limit ) { return true; } set_transient( $key, $count + 1, self::RATE_WINDOW ); return false; } }