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;
}
}