prefix . 'storesuit_order_alerts'; } public static function install() : void { if ( get_option( self::SCHEMA ) === '1' ) { return; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$table} (\n id bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n order_id bigint(20) unsigned NOT NULL,\n channel varchar(20) NOT NULL,\n created datetime NOT NULL,\n PRIMARY KEY (id),\n UNIQUE KEY order_id (order_id),\n KEY created (created)\n ) {$charset};" ); if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) === $table ) { update_option( self::SCHEMA, '1', false ); } } public static function hooks() : void { // Untyped arguments: WooCommerce and other plugins call these with whatever they hold (an id as a string). add_action( 'woocommerce_new_order', static function ( $order_id, $order = null ) { $order = ( $order instanceof \WC_Order ? $order : wc_get_order( (int) $order_id ) ); if ( $order instanceof \WC_Order && in_array( $order->get_status(), self::PAID, true ) ) { self::record( $order ); } }, 20, 2 ); add_action( 'woocommerce_order_status_changed', static function ( $order_id, $from, $to, $order = null ) { if ( !in_array( (string) $to, self::PAID, true ) || !in_array( (string) $from, self::UNPAID, true ) ) { return; } $order = ( $order instanceof \WC_Order ? $order : wc_get_order( (int) $order_id ) ); if ( $order instanceof \WC_Order ) { self::record( $order ); } }, 20, 4 ); add_action( 'wp_ajax_storesuit_order_alerts', array(__CLASS__, 'ajax') ); add_action( 'admin_enqueue_scripts', array(__CLASS__, 'assets') ); } /** The channels that alert, key => name: web orders, and in StoreRuler Pro every marketplace. */ public static function channels() : array { $channels = array( 'web' => I18n::t( 'Online store' ), ); return $channels; } /** The channel an order alerts on, or '' when it does not alert. */ public static function channel_of( \WC_Order $order ) : string { if ( $order->get_type() !== 'shop_order' || Orders::store_sale( $order ) ) { return ''; } if ( $order->get_meta( '_storesuit_demo' ) || $order->get_meta( '_storesuit_market_test' ) || $order->get_created_via() === 'storesuit-demo' ) { return ''; } $channel = (string) Orders::channel( $order ); if ( !isset( self::channels()[$channel] ) ) { return ''; } // Entered by the team on the order desk or WooCommerce's own screen: they know about it already. if ( $channel === 'web' && in_array( $order->get_created_via(), array('storesuit', 'admin'), true ) ) { return ''; } $placed = $order->get_date_created(); return ( $placed && $placed->getTimestamp() < time() - self::FRESH ? '' : $channel ); } /** One row per order, once (the unique order id); old rows go when a new one comes. */ public static function record( \WC_Order $order ) : void { if ( get_option( self::SCHEMA ) !== '1' ) { return; } $channel = self::channel_of( $order ); if ( $channel === '' ) { return; } global $wpdb; $table = self::table(); if ( $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (order_id,channel,created) VALUES (%d,%s,%s)', $table, $order->get_id(), $channel, gmdate( 'Y-m-d H:i:s' ) ) ) ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created < %s', $table, gmdate( 'Y-m-d H:i:s', time() - self::KEEP ) ) ); } } /** Who gets alerts: people who may see the orders (the order desk or WooCommerce's order screens). A cashier does not. */ public static function may( ?int $user = null ) : bool { $user = $user ?? get_current_user_id(); return $user > 0 && (user_can( $user, 'storesuit_orders' ) || user_can( $user, 'manage_woocommerce' ) || user_can( $user, 'edit_shop_orders' )); } // ---- Each person's choices ------------------------------------------------------------------------------------------- /** on: popups at all; sound; sticky: the popup stays until closed; channels: those that alert (all by default). */ public static function prefs( int $user ) : array { $saved = get_user_option( self::PREFS, $user ); $saved = ( is_array( $saved ) ? $saved : array() ); $all = array_keys( self::channels() ); $channels = ( is_array( $saved['channels'] ?? null ) ? array_values( array_intersect( $all, array_map( 'strval', $saved['channels'] ) ) ) : $all ); return array( 'on' => ($saved['on'] ?? true) !== false, 'sound' => ($saved['sound'] ?? true) !== false, 'sticky' => !empty( $saved['sticky'] ), 'channels' => $channels, ); } public static function save( array $data ) : array { $user = get_current_user_id(); $all = array_keys( self::channels() ); $next = array( 'on' => !empty( $data['on'] ), 'sound' => !empty( $data['sound'] ), 'sticky' => !empty( $data['sticky'] ), 'channels' => array_values( array_intersect( $all, array_map( 'strval', ( is_array( $data['channels'] ?? null ) ? $data['channels'] : array() ) ) ) ), ); update_user_option( $user, self::SEEN, max( self::seen( $user ), self::last_id() ) ); update_user_option( $user, self::PREFS, $next ); return self::poll( $user ); } private static function last_id() : int { global $wpdb; return ( get_option( self::SCHEMA ) === '1' ? (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COALESCE(MAX(id),0) FROM %i', self::table() ) ) : 0 ); } /** The last alert this person has seen. A first look starts from now: the orders before it are not news to them. */ private static function seen( int $user ) : int { $seen = get_user_option( self::SEEN, $user ); if ( $seen === false ) { $seen = self::last_id(); update_user_option( $user, self::SEEN, $seen ); } return (int) $seen; } /** Seen or closed up to this alert, in every tab and on every device of this person. */ public static function seen_upto( int $upto ) : array { $user = get_current_user_id(); if ( $upto > self::seen( $user ) ) { update_user_option( $user, self::SEEN, min( $upto, self::last_id() ) ); } return self::poll( $user ); } /** The alerts this person has not seen yet, newest last, with what the popup shows. No customer data. */ public static function poll( int $user ) : array { $prefs = self::prefs( $user ); $seen = self::seen( $user ); $alerts = array(); $more = 0; if ( $prefs['on'] && $prefs['channels'] && get_option( self::SCHEMA ) === '1' ) { global $wpdb; $rows = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT id,order_id,channel,created FROM %i WHERE id>%d AND channel IN (' . implode( ',', array_fill( 0, count( $prefs['channels'] ), '%s' ) ) . ') ORDER BY id DESC LIMIT %d', array_merge( array(self::table(), $seen), $prefs['channels'], array(self::BATCH + 1) ) ), ARRAY_A ); if ( count( $rows ) > self::BATCH ) { $more = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE id>%d AND channel IN (' . implode( ',', array_fill( 0, count( $prefs['channels'] ), '%s' ) ) . ')', array_merge( array(self::table(), $seen), $prefs['channels'] ) ) ) - self::BATCH; $rows = array_slice( $rows, 0, self::BATCH ); } // The newest BATCH, oldest first; the rest is counted in "more". $rows = array_reverse( $rows ); $names = self::channels(); foreach ( $rows as $row ) { $order = wc_get_order( (int) $row['order_id'] ); if ( !$order instanceof \WC_Order ) { continue; } $alerts[] = array( 'id' => (int) $row['id'], 'order' => $order->get_id(), 'number' => (string) $order->get_order_number(), 'channel' => (string) $row['channel'], 'channel_label' => $names[$row['channel']] ?? (string) $row['channel'], 'total' => html_entity_decode( wp_strip_all_tags( wc_price( (float) $order->get_total(), array( 'currency' => $order->get_currency(), ) ) ), ENT_QUOTES, 'UTF-8' ), 'items' => (int) $order->get_item_count(), 'url' => admin_url( 'admin.php?page=storesuit-orders&order=' . $order->get_id() ), 'at' => gmdate( 'c', (int) strtotime( $row['created'] . ' UTC' ) ), ); } } return array( 'seen' => $seen, 'alerts' => $alerts, 'more' => max( 0, $more ), 'settings' => $prefs, ); } public static function ajax() : void { if ( !check_ajax_referer( 'storesuit_order_alerts', 'nonce', false ) ) { wp_send_json_error( array( 'code' => 'expired', ), 403 ); return; } if ( !self::may() ) { wp_send_json_error( array( 'code' => 'forbidden', ), 403 ); return; } $op = ( isset( $_POST['op'] ) && is_string( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : 'poll' ); // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON, read field by field below. $raw = ( isset( $_POST['payload'] ) && is_string( $_POST['payload'] ) ? wp_unslash( $_POST['payload'] ) : '{}' ); // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $data = ( strlen( $raw ) <= 2000 ? json_decode( $raw, true ) : null ); $data = ( is_array( $data ) ? $data : array() ); try { if ( $op === 'seen' ) { wp_send_json_success( self::seen_upto( absint( $data['upto'] ?? 0 ) ) ); return; } if ( $op === 'save' ) { wp_send_json_success( self::save( $data ) ); return; } wp_send_json_success( self::poll( get_current_user_id() ) ); } catch ( \Throwable $e ) { wp_send_json_error( array( 'message' => I18n::t( 'The action could not be completed.' ), ), 400 ); } } // ---- The popup ------------------------------------------------------------------------------------------------------- /** Every StoreRuler screen and WooCommerce's order list and order screen, for people who may see the orders. */ private static function screen() : bool { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Only decides whether the popup's script loads. $page = ( isset( $_GET['page'] ) && is_string( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '' ); // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( str_starts_with( $page, 'storesuit' ) ) { return true; } $screen = ( function_exists( 'get_current_screen' ) ? get_current_screen() : null ); return $screen && in_array( (string) $screen->id, array('woocommerce_page_wc-orders', 'edit-shop_order', 'shop_order'), true ); } public static function assets() : void { if ( !self::may() || !self::screen() ) { return; } wp_enqueue_style( 'storesuit-order-alerts', plugins_url( 'assets/order-alerts.css', STORESUIT_FILE ), array(), (string) filemtime( STORESUIT_DIR . 'assets/order-alerts.css' ) ); wp_enqueue_script( 'storesuit-order-alerts', plugins_url( 'assets/order-alerts.js', STORESUIT_FILE ), array(), (string) filemtime( STORESUIT_DIR . 'assets/order-alerts.js' ), true ); // The first answer comes with the page, so a screen asks nothing until its first 20 seconds have passed. The texts come // with it too: WooCommerce's order screens load none of StoreRuler's language files. $first = array( 'seen' => 0, 'alerts' => array(), 'more' => 0, 'settings' => self::prefs( get_current_user_id() ), ); try { $first = self::poll( get_current_user_id() ); } catch ( \Throwable $e ) { $first['error'] = true; } wp_localize_script( 'storesuit-order-alerts', 'StoreSuitOrderAlerts', array( 'url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'storesuit_order_alerts' ), 'user' => get_current_user_id(), 'first' => $first, 'channels' => self::channels(), 'text' => array( 'one' => I18n::t( 'New order' ), 'many' => I18n::t( '%s new orders' ), 'more' => I18n::t( 'and %s more' ), 'open' => I18n::t( 'Open the order' ), 'close' => I18n::t( 'Close' ), 'item' => I18n::t( '1 item' ), 'items' => I18n::t( '%s items' ), 'sound' => I18n::t( 'Turn on sound alerts' ), 'later' => I18n::t( 'Not now' ), 'sound_hint' => I18n::t( 'Your browser plays sounds only after you click on the page once.' ), 'title' => I18n::t( 'Order alerts' ), 'settings' => I18n::t( 'Alert settings' ), 'on' => I18n::t( 'Show new orders as they come in' ), 'play' => I18n::t( 'Play a sound' ), 'sticky' => I18n::t( 'Keep the alert until I close it' ), 'for' => I18n::t( 'Alert me for' ), 'save' => I18n::t( 'Save' ), 'saved' => I18n::t( 'Saved.' ), 'muted' => I18n::t( 'Order alerts are off for you.' ), 'failed' => I18n::t( 'The action could not be completed.' ), ), ) ); } }