$address, 'country' => $country, ) ); if ( is_wp_error( $res ) ) { return $res; } if ( ! isset( $res['lat'], $res['lon'] ) || ! is_numeric( $res['lat'] ) || ! is_numeric( $res['lon'] ) ) { return new \WP_Error( 'reachzone_bad_response', 'Unexpected geocode response.' ); } return array( 'lat' => (float) $res['lat'], 'lon' => (float) $res['lon'], 'confidence' => isset( $res['confidence'] ) ? (float) $res['confidence'] : 0.0, ); } /** * Fetch a drive-time area. * * @param float $lat Latitude. * @param float $lon Longitude. * @param int $minutes Minutes. * @param string $profile driving|cycling|walking. * @return array|\WP_Error GeoJSON geometry. */ public static function isochrone( $lat, $lon, $minutes, $profile ) { $res = self::post( '/v1/isochrone', array( 'lat' => (float) $lat, 'lon' => (float) $lon, 'minutes' => (int) $minutes, 'profile' => $profile, ) ); if ( is_wp_error( $res ) ) { return $res; } if ( ! Geometry::is_valid_area( $res ) ) { return new \WP_Error( 'reachzone_bad_response', 'The API returned no usable area.' ); } return $res; } /** * Send unserved-demand points (Business plan, opt-in). Each event is a rounded position, the source and the UTC day: * no address, postcode, IP or shopper id. * * @param array[] $events 1 to 500 arrays of lat, lon, source (checker|checkout) and day (YYYY-MM-DD). * @return array|\WP_Error array( accepted, dropped ). */ public static function demand_events( array $events ) { $res = self::post( '/v1/demand/events', array( 'events' => array_values( $events ) ) ); if ( is_wp_error( $res ) ) { return $res; } if ( ! isset( $res['accepted'] ) || ! is_numeric( $res['accepted'] ) ) { return new \WP_Error( 'reachzone_bad_response', 'Unexpected demand response.' ); } return array( 'accepted' => (int) $res['accepted'], 'dropped' => isset( $res['dropped'] ) ? (int) $res['dropped'] : 0, ); } /** * Fetch the unserved-demand cells for the admin map (Business plan). * * @param int $days Look-back window, 1 to 180. * @return array|\WP_Error array( days, total, cells ) with cells as array( lat, lon, count ). */ public static function demand_cells( $days = 90 ) { $days = max( 1, min( 180, (int) $days ) ); $res = self::call( 'GET', '/v1/demand/cells?days=' . $days, null ); if ( is_wp_error( $res ) ) { return $res; } if ( ! isset( $res['cells'], $res['total'] ) || ! is_array( $res['cells'] ) || ! is_numeric( $res['total'] ) ) { return new \WP_Error( 'reachzone_bad_response', 'Unexpected demand response.' ); } $cells = array(); foreach ( $res['cells'] as $cell ) { if ( is_array( $cell ) && 3 === count( $cell ) && is_numeric( $cell[0] ) && is_numeric( $cell[1] ) && is_numeric( $cell[2] ) ) { $cells[] = array( (float) $cell[0], (float) $cell[1], (int) $cell[2] ); } } return array( 'days' => $days, 'total' => (int) $res['total'], 'cells' => $cells, ); } /** * Ask the licence server for the status of the saved key on this site. * * @return array|\WP_Error */ public static function license_status() { $settings = Settings::get(); return self::request( 'GET', '/v1/license/status?site_url=' . rawurlencode( self::site_url() ), null, $settings['license_key'] ); } /** * Activate a key for this site (idempotent). * * @param string $key Licence key. * @return array|\WP_Error */ public static function license_activate( $key ) { return self::request( 'POST', '/v1/license/activate', array( 'key' => $key, 'site_url' => self::site_url(), ), $key ); } /** * Free this site's activation of a key. * * @param string $key Licence key. * @return array|\WP_Error */ public static function license_deactivate( $key ) { return self::request( 'POST', '/v1/license/deactivate', array( 'key' => $key, 'site_url' => self::site_url(), ), $key ); } /** * The address of this shop as the licence server should know it (it stores only a keyed hash). * * @return string */ private static function site_url() { return home_url(); } /** * POST JSON to the API with the site's licence. * * @param string $path Path starting with a slash. * @param array $body Body. * @return array|\WP_Error */ private static function post( $path, array $body ) { return self::call( 'POST', $path, $body ); } /** * Call the API with the site's licence, for the services that need a paid plan. * * @param string $method GET or POST. * @param string $path Path (with query string for GET) starting with a slash. * @param array|null $body Body for POST. * @return array|\WP_Error */ private static function call( $method, $path, $body ) { if ( ! self::enabled() ) { return new \WP_Error( 'reachzone_no_license', 'No licence key is set.' ); } $settings = Settings::get(); $result = self::request( $method, $path, $body, $settings['license_key'] ); if ( is_wp_error( $result ) && in_array( $result->get_error_code(), array( 'reachzone_bad_key', 'reachzone_payment_required', 'reachzone_forbidden' ), true ) ) { // The licence may have changed (cancelled, expired): look again soon. delete_transient( License::TRANSIENT ); License::forget(); } return $result; } /** * One HTTP call to the API. Sends the licence key and the site address; never logs or returns the request or response body. * * @param string $method GET or POST. * @param string $path Path (with query string for GET), starting with a slash. * @param array|null $body JSON body for POST. * @param string $key Licence key. * @return array|\WP_Error */ private static function request( $method, $path, $body, $key ) { $settings = Settings::get(); if ( '' === $key || '' === $settings['api_url'] ) { return new \WP_Error( 'reachzone_no_license', 'No licence key is set.' ); } $args = array( 'method' => $method, 'timeout' => 10, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'X-ReachZone-License' => $key, 'X-ReachZone-Site' => self::site_url(), ), 'blocking' => true, ); if ( null !== $body ) { $args['body'] = wp_json_encode( $body ); } // wp_remote_request, not the "safe" variant: shops may point this at a private host while testing, and the // URL is set by an administrator only. $response = wp_remote_request( untrailingslashit( $settings['api_url'] ) . $path, $args ); if ( is_wp_error( $response ) ) { return new \WP_Error( 'reachzone_unreachable', 'The ReachZone API could not be reached.' ); } $code = (int) wp_remote_retrieve_response_code( $response ); $errs = array( 401 => array( 'reachzone_bad_key', 'The ReachZone API rejected the licence key.' ), 402 => array( 'reachzone_payment_required', 'The licence has expired or the monthly quota is used up.' ), 403 => array( 'reachzone_forbidden', 'The plan does not allow this request.' ), 404 => array( 'reachzone_not_found', 'Address not found.' ), 409 => array( 'reachzone_site_limit', 'The licence is active on too many sites.' ), 429 => array( 'reachzone_quota', 'The monthly quota of the plan is used up.' ), ); if ( isset( $errs[ $code ] ) ) { return new \WP_Error( $errs[ $code ][0], $errs[ $code ][1] ); } if ( 200 !== $code ) { /* The status code only: never echo the request or the response body into logs or notices. */ return new \WP_Error( 'reachzone_api_error', 'The ReachZone API returned HTTP ' . $code . '.' ); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); return is_array( $data ) ? $data : new \WP_Error( 'reachzone_bad_response', 'Unreadable API response.' ); } }