ReachZone (settings, locations, zones) and the unserved-demand map. * * @package ReachZone */ namespace ReachZone; defined( 'ABSPATH' ) || exit; /** * Admin. */ final class Admin { const CAP = 'manage_woocommerce'; const PAGE = 'reachzone'; const PAGE_DEMO = 'reachzone-demand'; /** * Screen hook suffixes. * * @var string[] */ private static $screens = array(); /** * Hooks. */ public static function init() { add_action( 'admin_menu', array( __CLASS__, 'menu' ) ); add_action( 'admin_post_reachzone_save', array( __CLASS__, 'handle_save' ) ); add_action( 'wp_ajax_reachzone_geocode', array( __CLASS__, 'ajax_geocode' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'assets' ) ); add_filter( 'plugin_action_links_' . plugin_basename( REACHZONE_FILE ), array( __CLASS__, 'action_links' ) ); } /** * "Settings" link on the plugins screen. * * @param string[] $links Links. * @return string[] */ public static function action_links( $links ) { array_unshift( $links, '' . esc_html__( 'Settings', 'reachzone' ) . '' ); return $links; } /** * Menu entries under WooCommerce. */ public static function menu() { self::$screens[ self::PAGE ] = add_submenu_page( 'woocommerce', __( 'ReachZone delivery zones', 'reachzone' ), __( 'ReachZone', 'reachzone' ), self::CAP, self::PAGE, array( __CLASS__, 'render_settings' ) ); self::$screens[ self::PAGE_DEMO ] = add_submenu_page( 'woocommerce', __( 'ReachZone unserved demand', 'reachzone' ), __( 'Unserved demand', 'reachzone' ), self::CAP, self::PAGE_DEMO, array( __CLASS__, 'render_demand' ) ); } /** * Enqueue the bundled map libraries and the screen scripts. * * @param string $hook Screen hook suffix. */ public static function assets( $hook ) { $is_settings = isset( self::$screens[ self::PAGE ] ) && self::$screens[ self::PAGE ] === $hook; $is_demand = isset( self::$screens[ self::PAGE_DEMO ] ) && self::$screens[ self::PAGE_DEMO ] === $hook; if ( ! $is_settings && ! $is_demand ) { return; } $v = REACHZONE_VERSION; wp_enqueue_style( 'reachzone-leaflet', REACHZONE_URL . 'assets/vendor/leaflet/leaflet.css', array(), '1.9.4' ); wp_enqueue_script( 'reachzone-leaflet', REACHZONE_URL . 'assets/vendor/leaflet/leaflet.js', array(), '1.9.4', true ); wp_enqueue_style( 'reachzone-admin', REACHZONE_URL . 'assets/css/admin.css', array( 'reachzone-leaflet' ), $v ); // Both screens show a map. The vector renderer is only loaded when the tile URL serves vector tiles; a raster // URL entered by the shop owner uses Leaflet's own tile layer. $settings = Settings::get(); $mode = Settings::tile_mode( $settings['tile_url'] ); $map_deps = array( 'reachzone-leaflet' ); if ( 'vector' === $mode ) { wp_enqueue_script( 'reachzone-protomaps-leaflet', REACHZONE_URL . 'assets/vendor/protomaps-leaflet/protomaps-leaflet.js', array( 'reachzone-leaflet' ), '5.1.0', true ); $map_deps[] = 'reachzone-protomaps-leaflet'; } wp_enqueue_script( 'reachzone-basemap', REACHZONE_URL . 'assets/js/basemap.js', $map_deps, $v, true ); $map = array( 'tileUrl' => $settings['tile_url'], 'mode' => $mode, 'flavor' => 'light', 'lang' => strtolower( substr( (string) get_user_locale(), 0, 2 ) ), 'attribution' => wp_kses( $settings['tile_attribution'], array( 'a' => array( 'href' => array() ) ) ), 'center' => self::map_center(), ); if ( $is_settings ) { wp_enqueue_style( 'reachzone-leaflet-draw', REACHZONE_URL . 'assets/vendor/leaflet-draw/leaflet.draw.css', array( 'reachzone-leaflet' ), '1.0.4' ); wp_enqueue_script( 'reachzone-leaflet-draw', REACHZONE_URL . 'assets/vendor/leaflet-draw/leaflet.draw.js', array( 'reachzone-leaflet' ), '1.0.4', true ); wp_enqueue_script( 'reachzone-admin', REACHZONE_URL . 'assets/js/admin.js', array( 'reachzone-leaflet', 'reachzone-leaflet-draw', 'reachzone-basemap' ), $v, true ); $tier = License::tier(); wp_add_inline_script( 'reachzone-admin', 'window.reachzoneAdmin = ' . wp_json_encode( array( 'map' => $map, 'locations' => Settings::locations(), 'limits' => License::current_limits(), 'maxLocations' => Sanitizer::MAX_LOCATIONS, 'tier' => $tier, 'methods' => self::shipping_methods(), 'currency' => html_entity_decode( get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8' ), 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'reachzone_geocode' ), 'canGeocode' => Api_Client::enabled(), 'i18n' => self::admin_strings(), ) ) . ';', 'before' ); } else { wp_enqueue_script( 'reachzone-leaflet-heat', REACHZONE_URL . 'assets/vendor/leaflet-heat/leaflet-heat.js', array( 'reachzone-leaflet' ), '0.2.0', true ); wp_enqueue_script( 'reachzone-demand', REACHZONE_URL . 'assets/js/demand.js', array( 'reachzone-leaflet', 'reachzone-leaflet-heat', 'reachzone-basemap' ), $v, true ); $cells = array(); if ( License::current_limits()['demand_map'] ) { $remote = Demand::remote_cells( 90 ); $cells = is_wp_error( $remote ) ? array() : $remote['cells']; } wp_add_inline_script( 'reachzone-demand', 'window.reachzoneDemand = ' . wp_json_encode( array( 'map' => $map, 'cells' => $cells, ) ) . ';', 'before' ); } } /** * Strings for the admin script. * * @return array */ private static function admin_strings() { return array( 'addLocation' => __( 'Add location', 'reachzone' ), /* translators: %d: maximum number of locations */ 'locationCap' => __( 'You can save up to %d locations.', 'reachzone' ), 'removeLocation' => __( 'Remove this location and its zones?', 'reachzone' ), 'remove' => __( 'Remove', 'reachzone' ), 'name' => __( 'Location name', 'reachzone' ), 'address' => __( 'Address', 'reachzone' ), 'find' => __( 'Find on map', 'reachzone' ), 'finding' => __( 'Searching...', 'reachzone' ), 'notFound' => __( 'Address not found.', 'reachzone' ), 'lookupFailed' => __( 'The address lookup did not work. Click the map to place the pin instead.', 'reachzone' ), 'needLicense' => __( 'Add a licence key to look up addresses, or click the map to place the pin.', 'reachzone' ), 'lat' => __( 'Latitude', 'reachzone' ), 'lon' => __( 'Longitude', 'reachzone' ), 'pinHint' => __( 'Click the map to place the pin, or drag it. Use the shape tools on the map to draw a delivery area.', 'reachzone' ), 'zones' => __( 'Delivery zones', 'reachzone' ), 'noZones' => __( 'No zones yet. Add one below, or draw an area on the map.', 'reachzone' ), 'addRadius' => __( 'Add radius zone', 'reachzone' ), 'addPostcodes' => __( 'Add postcode zone', 'reachzone' ), 'addDrive' => __( 'Add drive-time zone', 'reachzone' ), 'driveLocked' => __( 'Building a drive-time zone needs a Pro licence (the ReachZone API builds the area). Drive-time zones you already have keep working and can be edited and deleted; changing their area or moving their location needs the licence.', 'reachzone' ), 'driveLimit' => __( 'Your plan has reached its drive-time zone limit.', 'reachzone' ), 'driveLocLimit' => __( 'Your plan allows drive-time zones for a limited number of locations. Remove the drive-time zones of another location first, or upgrade.', 'reachzone' ), 'driveFrozen' => __( 'Changing the area needs a licence. You can still edit the label, fee, minimum, delivery time and methods.', 'reachzone' ), 'label' => __( 'Label', 'reachzone' ), 'priority' => __( 'Priority (1 = checked first)', 'reachzone' ), 'fee' => __( 'Delivery fee', 'reachzone' ), 'minOrder' => __( 'Minimum order', 'reachzone' ), 'eta' => __( 'Delivery time (shown to shoppers)', 'reachzone' ), 'methods' => __( 'Allowed shipping methods (none ticked = all)', 'reachzone' ), 'radius' => __( 'Radius', 'reachzone' ), 'unit' => __( 'Unit', 'reachzone' ), 'minutes' => __( 'Minutes', 'reachzone' ), 'profile' => __( 'Travel mode', 'reachzone' ), 'driving' => __( 'Driving', 'reachzone' ), 'cycling' => __( 'Cycling', 'reachzone' ), 'walking' => __( 'Walking', 'reachzone' ), 'postcodes' => __( 'Postcodes (one per line or comma-separated; 606* for a prefix, 60601-60610 for a range)', 'reachzone' ), 'typeRadius' => __( 'Radius', 'reachzone' ), 'typePolygon' => __( 'Drawn area', 'reachzone' ), 'typeDrive' => __( 'Drive time', 'reachzone' ), 'typePostcodes' => __( 'Postcodes', 'reachzone' ), 'driveNote' => __( 'The area is fetched from the ReachZone API when you save and stored on your site.', 'reachzone' ), 'driveComputed' => __( 'Area stored.', 'reachzone' ), 'driveNew' => __( 'Area will be computed on save.', 'reachzone' ), 'confirmRemove' => __( 'Remove this zone?', 'reachzone' ), 'noMethods' => __( 'No shipping methods found. Add them under WooCommerce > Settings > Shipping.', 'reachzone' ), 'drawnZone' => __( 'Drawn area', 'reachzone' ), ); } /** * Initial map centre: the first location, else the WooCommerce store base, else a world view. * * @return array */ private static function map_center() { $locations = Settings::locations(); if ( ! empty( $locations[0]['lat'] ) ) { return array( (float) $locations[0]['lat'], (float) $locations[0]['lon'], 12 ); } return array( 39.0, -98.0, 4 ); } /** * Shipping-method instances across all WooCommerce shipping zones. * * @return array[] Each array( id, label ). */ private static function shipping_methods() { $out = array(); if ( ! class_exists( '\WC_Shipping_Zones' ) ) { return $out; } $wc_zones = \WC_Shipping_Zones::get_zones(); $wc_zones[] = array( 'zone_name' => __( 'Other locations', 'reachzone' ), 'shipping_methods' => ( new \WC_Shipping_Zone( 0 ) )->get_shipping_methods(), ); foreach ( $wc_zones as $wc_zone ) { foreach ( (array) $wc_zone['shipping_methods'] as $method ) { $out[] = array( 'id' => $method->id . ':' . $method->get_instance_id(), 'label' => $wc_zone['zone_name'] . ' - ' . $method->get_title(), ); } } return $out; } /** * Settings page. */ public static function render_settings() { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'reachzone' ) ); } $settings = Settings::get(); if ( '' !== $settings['license_key'] && false === get_transient( License::TRANSIENT ) ) { License::refresh(); // The settings page may wait for the network; shoppers never do. } $license = License::current(); $tier = $license['tier']; $notices = get_transient( 'reachzone_notices_' . get_current_user_id() ); delete_transient( 'reachzone_notices_' . get_current_user_id() ); $tier_names = array( License::FREE => __( 'Free', 'reachzone' ), License::PRO => __( 'Pro', 'reachzone' ), License::BUSINESS => __( 'Business', 'reachzone' ), License::AGENCY => __( 'Agency', 'reachzone' ), ); ?>

' . esc_html__( 'Settings saved.', 'reachzone' ) . '

'; } foreach ( is_array( $notices ) ? $notices : array() as $notice ) { echo '

' . esc_html( $notice ) . '

'; } Review_Prompt::render(); ?>

__( 'Message when we deliver ({eta} and {fee} are replaced)', 'reachzone' ), 'msg_out' => __( 'Message outside the delivery area', 'reachzone' ), 'msg_unverified' => __( 'Message when the address could not be checked', 'reachzone' ), 'msg_min_order' => __( 'Minimum order notice ({zone}, {min}, {missing})', 'reachzone' ), ); foreach ( $fields as $key => $label ) : ?>

403 ) ); } check_admin_referer( 'reachzone_save' ); $san = new Sanitizer(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce checked above; every value is sanitised by ReachZone\Sanitizer. $raw_settings = isset( $_POST['reachzone'] ) && is_array( $_POST['reachzone'] ) ? wp_unslash( $_POST['reachzone'] ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce checked above; decoded JSON is sanitised by ReachZone\Sanitizer. $raw_json = isset( $_POST['reachzone_locations'] ) ? wp_unslash( $_POST['reachzone_locations'] ) : ''; $existing = get_option( Settings::OPTION_SETTINGS, array() ); $settings = $san->settings( $raw_settings, is_array( $existing ) ? $existing : array() ); // Plan limits follow the key that is being saved right now: activate it for this site (and free the old // key's slot) before anything is validated. They only decide what the ReachZone API is asked to do. $old_key = is_array( $existing ) && isset( $existing['license_key'] ) ? (string) $existing['license_key'] : ''; $result = License::apply_key( $old_key, $settings['license_key'] ); $limits = License::resolve( $settings['license_key'], get_transient( License::TRANSIENT ), get_option( License::OPTION, null ), time() )['limits']; $notices = array(); if ( ! $result['ok'] && '' !== $result['message'] ) { $notices[] = $result['message']; } if ( empty( $limits['demand_map'] ) ) { $settings['demand_map'] = 0; // The ReachZone service would refuse the points; keep the opt-in off. } $decoded = json_decode( is_string( $raw_json ) ? $raw_json : '', true ); $locations = $san->locations( is_array( $decoded ) ? $decoded : array(), $limits, Settings::locations() ); $notices = array_merge( $notices, $san->notices() ); // Drive-time areas: fetched once per change, then stored. $settings_for_api = array_merge( Settings::defaults(), $settings ); foreach ( $locations as $li => $location ) { foreach ( $location['zones'] as $zi => $zone ) { if ( Zones::TYPE_DRIVE_TIME !== $zone['type'] || ! empty( $zone['geojson'] ) ) { continue; } $geo = self::fetch_drive_area( $settings_for_api, $location, $zone ); if ( is_wp_error( $geo ) ) { /* translators: 1: zone label, 2: error message */ $notices[] = sprintf( __( 'Drive-time area for "%1$s" could not be fetched: %2$s The zone stays inactive until you save again.', 'reachzone' ), $zone['label'], $geo->get_error_message() ); } else { $locations[ $li ]['zones'][ $zi ]['geojson'] = $geo; } } } update_option( Settings::OPTION_SETTINGS, $settings, false ); update_option( Settings::OPTION_LOCATIONS, $locations, false ); Review_Prompt::record_first_zone( $locations ); if ( $notices ) { set_transient( 'reachzone_notices_' . get_current_user_id(), array_values( array_unique( $notices ) ), 120 ); } wp_safe_redirect( admin_url( 'admin.php?page=' . self::PAGE . '&saved=1' ) ); exit; } /** * Fetch and validate a drive-time polygon using the just-saved settings. * * @param array $settings Settings to use for the call (the option is not written yet). * @param array $location Location. * @param array $zone Zone. * @return array|\WP_Error */ private static function fetch_drive_area( array $settings, array $location, array $zone ) { // The API client reads the stored settings; make the new key and URL visible for this call only. $filter = static function () use ( $settings ) { return $settings; }; add_filter( 'pre_option_' . Settings::OPTION_SETTINGS, $filter ); $result = Api_Client::isochrone( $location['lat'], $location['lon'], $zone['minutes'], $zone['profile'] ); remove_filter( 'pre_option_' . Settings::OPTION_SETTINGS, $filter ); return $result; } /** * Admin AJAX: geocode the shop's own address for the map pin (licensed shops). */ public static function ajax_geocode() { check_ajax_referer( 'reachzone_geocode', 'nonce' ); if ( ! current_user_can( self::CAP ) ) { wp_send_json_error( array( 'message' => __( 'Not allowed.', 'reachzone' ) ), 403 ); } if ( ! Api_Client::enabled() ) { wp_send_json_error( array( 'message' => __( 'Add a licence key to look up addresses.', 'reachzone' ) ), 400 ); } $address = isset( $_POST['address'] ) ? sanitize_text_field( wp_unslash( $_POST['address'] ) ) : ''; if ( strlen( $address ) < 3 ) { wp_send_json_error( array( 'message' => __( 'Enter an address.', 'reachzone' ) ), 400 ); } $country = isset( $_POST['country'] ) ? sanitize_text_field( wp_unslash( $_POST['country'] ) ) : ''; $geo = Api_Client::geocode( $address, Api_Client::country( $country ) ); if ( is_wp_error( $geo ) ) { $not_found = 'reachzone_not_found' === $geo->get_error_code(); wp_send_json_error( array( 'message' => $not_found ? __( 'Address not found.', 'reachzone' ) : __( 'The address lookup did not work.', 'reachzone' ) ), $not_found ? 404 : 502 ); } wp_send_json_success( array( 'lat' => round( $geo['lat'], 6 ), 'lon' => round( $geo['lon'], 6 ), ) ); } }