'cancel_requested', 'count' => true ) ); $bubble = $requests ? ' ' . $requests . '' : ''; add_submenu_page( 'woocommerce', __( 'Pre-orders', 'shipclock-pre-orders' ), __( 'Pre-orders', 'shipclock-pre-orders' ) . $bubble, 'manage_woocommerce', self::SLUG, array( __CLASS__, 'render' ) ); } /** * Admin CSS. * * @param string $hook Hook. */ public static function assets( $hook ) { wp_enqueue_style( 'pokit-admin', POKIT_URL . 'assets/css/admin.css', array(), POKIT_VERSION ); } /** * URL helper. * * @param array $args Args. * @return string */ public static function url( $args = array() ) { return add_query_arg( array_merge( array( 'page' => self::SLUG ), $args ), admin_url( 'admin.php' ) ); } /** * Notice after redirects. */ private static function notice() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $msg = isset( $_GET['pokit_msg'] ) ? sanitize_text_field( wp_unslash( $_GET['pokit_msg'] ) ) : ''; if ( $msg ) { echo '

' . esc_html( $msg ) . '

'; } } /** * Render page. */ public static function render() { if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'overview'; $tabs = array( 'overview' => __( 'Overview', 'shipclock-pre-orders' ), 'list' => __( 'All pre-orders', 'shipclock-pre-orders' ), ); echo '

' . esc_html__( 'Pre-orders', 'shipclock-pre-orders' ) . '

'; echo ' ' . esc_html__( 'Settings', 'shipclock-pre-orders' ) . ''; /** * Extra buttons next to the page title. */ do_action( 'pokit_admin_header_buttons' ); if ( ! POKIT_License::is_pro() ) { echo '' . esc_html__( 'Upgrade to Pro – deposits, pay later, stock queue & more', 'shipclock-pre-orders' ) . ''; } echo '
'; self::notice(); if ( 'action' !== $tab ) { echo ''; } switch ( $tab ) { case 'list': self::render_list(); break; case 'action': self::render_action_form(); break; default: self::render_overview(); } echo '
'; } /** * Overview: one row per pre-order product/variation. */ private static function render_overview() { $rows = POKIT_Records::summary(); $targets = array(); foreach ( $rows as $row ) { $targets[ (int) $row->target_id ] = $row; } // Include enabled products with no pre-orders yet. $enabled = get_posts( array( 'post_type' => 'product', 'post_status' => array( 'publish', 'draft', 'private', 'future', 'pending' ), 'posts_per_page' => 200, 'fields' => 'ids', 'meta_key' => '_pokit_has_preorder', // phpcs:ignore WordPress.DB.SlowDBQuery 'meta_value' => 'yes', // phpcs:ignore WordPress.DB.SlowDBQuery ) ); foreach ( $enabled as $pid ) { $p = wc_get_product( $pid ); if ( ! $p ) { continue; } $ids = array( $pid ); if ( $p->is_type( 'variable' ) && 'yes' !== $p->get_meta( '_pokit_enabled' ) ) { $ids = array_filter( $p->get_children(), static function ( $c ) { return 'yes' === get_post_meta( $c, '_pokit_enabled', true ); } ); } foreach ( $ids as $id ) { if ( ! isset( $targets[ $id ] ) ) { $targets[ $id ] = (object) array( 'target_id' => $id, 'open_units' => 0, 'active_count' => 0, 'awaiting_count' => 0, 'request_count' => 0, 'released_units' => 0, 'collected' => 0, 'outstanding' => 0, 'customers' => 0, ); } } } $balances = has_filter( 'pokit_create_balance_order' ); // Pro columns. $totals = array( 'units' => 0, 'collected' => 0, 'outstanding' => 0, 'requests' => 0, ); foreach ( $targets as $t ) { $totals['units'] += (int) $t->open_units; $totals['collected'] += (float) $t->collected; $totals['outstanding'] += (float) $t->outstanding; $totals['requests'] += (int) $t->request_count; } ?>
$t ) : $p = wc_get_product( $id ); $config = $p ? pokit_get_config( $p, true ) : null; $name = $p ? $p->get_formatted_name() : '#' . $id; $edit = $p ? get_edit_post_link( $p->is_type( 'variation' ) ? $p->get_parent_id() : $id ) : ''; $cap = $config && $config['max_qty'] ? ' / ' . (int) $config['max_qty'] : ''; $trig = $config ? array( 'date' => __( 'auto on date', 'shipclock-pre-orders' ), 'stock' => __( 'when stock arrives', 'shipclock-pre-orders' ), 'manual' => __( 'manual', 'shipclock-pre-orders' ), )[ $config['trigger'] ] : __( 'ended', 'shipclock-pre-orders' ); ?>

open_units . $cap ); ?> customers ); ?> awaiting_count ); ?> collected ) ); ?> outstanding ) ); ?> __( 'Release now', 'shipclock-pre-orders' ), 'date' => __( 'Change date', 'shipclock-pre-orders' ), 'email' => __( 'Email customers', 'shipclock-pre-orders' ), 'cancel' => __( 'Cancel all', 'shipclock-pre-orders' ), ); if ( ! $config && ! (int) $t->open_units ) { $actions = array(); } elseif ( ! (int) $t->open_units ) { unset( $actions['release'], $actions['email'], $actions['cancel'] ); } foreach ( $actions as $do => $label ) { printf( '%s ', esc_url( self::url( array( 'tab' => 'action', 'do' => $do, 'target' => $id ) ) ), esc_html( $label ) ); } /** * Extra per-product buttons on the overview. * * @param int $id Product or variation ID. */ do_action( 'pokit_overview_row_buttons', $id ); ?>
prepare_items(); echo '
'; echo ''; // phpcs:disable WordPress.Security.NonceVerification.Recommended foreach ( array( 'status', 'product' ) as $keep ) { if ( ! empty( $_GET[ $keep ] ) ) { echo ''; } } if ( ! empty( $_GET['product'] ) ) { $p = wc_get_product( absint( $_GET['product'] ) ); echo '

' . esc_html__( 'Filtered by product:', 'shipclock-pre-orders' ) . ' ' . wp_kses_post( $p ? $p->get_formatted_name() : '' ) . ' ' . esc_html__( 'Clear', 'shipclock-pre-orders' ) . '

'; } // phpcs:enable $table->views(); $table->search_box( __( 'Search email or order #', 'shipclock-pre-orders' ), 'pokit' ); echo '
'; echo '
'; echo ''; wp_nonce_field( 'pokit_bulk', '_pokit_bulk_nonce' ); echo ''; $table->display(); echo '
'; } /** * Per-product action form (release / change date / email / cancel). */ private static function render_action_form() { // phpcs:disable WordPress.Security.NonceVerification.Recommended $do = isset( $_GET['do'] ) ? sanitize_key( $_GET['do'] ) : ''; $target = isset( $_GET['target'] ) ? absint( $_GET['target'] ) : 0; // phpcs:enable $p = wc_get_product( $target ); if ( ! $p || ! in_array( $do, array( 'release', 'date', 'email', 'cancel' ), true ) ) { echo '

' . esc_html__( 'Nothing to do.', 'shipclock-pre-orders' ) . '

'; return; } $config = pokit_get_config( $p, true ); $open = POKIT_Records::query( array( 'target_id' => $target, 'status' => pokit_open_statuses(), 'count' => true ) ); $titles = array( 'release' => __( 'Release pre-orders', 'shipclock-pre-orders' ), 'date' => __( 'Change release date', 'shipclock-pre-orders' ), 'email' => __( 'Email pre-order customers', 'shipclock-pre-orders' ), 'cancel' => __( 'Cancel all pre-orders', 'shipclock-pre-orders' ), ); echo '

' . esc_html( $titles[ $do ] ) . ': ' . wp_kses_post( $p->get_formatted_name() ) . '

'; /* translators: %d: count */ echo '

' . esc_html( sprintf( _n( '%d open pre-order.', '%d open pre-orders.', $open, 'shipclock-pre-orders' ), $open ) ) . '

'; echo '
'; wp_nonce_field( 'pokit_target_action' ); echo ''; echo ''; if ( 'release' === $do ) { echo ''; echo ''; echo ''; } if ( 'date' === $do ) { echo ''; } if ( 'email' === $do ) { echo ''; } if ( 'cancel' === $do && has_filter( 'pokit_refund_record' ) ) { echo ''; } if ( 'cancel' === $do ) { echo ''; } if ( 'email' !== $do ) { echo ''; } echo ''; echo '
' . esc_html__( 'What happens', 'shipclock-pre-orders' ) . '

' . esc_html__( 'Fully paid pre-orders are marked ready to ship. Pre-orders with a balance get a balance order and a payment link by email. Orders are processed oldest first.', 'shipclock-pre-orders' ) . '

' . esc_html__( 'Got a partial shipment? Enter how many units arrived; the queue is released in order.', 'shipclock-pre-orders' ) . '

' . esc_html__( 'Afterwards', 'shipclock-pre-orders' ) . '

' . esc_html__( 'Leave empty for "Coming soon".', 'shipclock-pre-orders' ) . '

' . esc_html__( 'Refunds', 'shipclock-pre-orders' ) . '
' . esc_html__( 'Afterwards', 'shipclock-pre-orders' ) . '
' . esc_html__( 'Notify customers', 'shipclock-pre-orders' ) . '
'; submit_button( $titles[ $do ], 'cancel' === $do ? 'delete' : 'primary' ); echo ' ' . esc_html__( 'Back', 'shipclock-pre-orders' ) . '
'; } /** * Redirect back with a message. * * @param string $msg Message. * @param string $back URL. */ private static function back( $msg, $back = '' ) { $back = $back ? $back : self::url(); wp_safe_redirect( add_query_arg( 'pokit_msg', rawurlencode( $msg ), $back ) ); exit; } /** * Handle per-product actions. */ public static function target_action() { if ( ! current_user_can( 'manage_woocommerce' ) || ! check_admin_referer( 'pokit_target_action' ) ) { wp_die( esc_html__( 'Not allowed.', 'shipclock-pre-orders' ) ); } // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $do = sanitize_key( $_POST['do'] ?? '' ); $target = absint( $_POST['target'] ?? 0 ); $message = isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : ''; $notify = ! empty( $_POST['notify'] ); $end = ! empty( $_POST['end'] ); // phpcs:enable switch ( $do ) { case 'release': $units = absint( $_POST['units'] ?? 0 ); $n = POKIT_Release::release_target( $target, array( 'units' => $units ? $units : null, 'end_preorder' => $end, 'message' => $message, 'notify' => $notify, ) ); /* translators: %d: count */ self::back( sprintf( _n( '%d pre-order released.', '%d pre-orders released.', $n, 'shipclock-pre-orders' ), $n ) ); break; case 'date': $ts = pokit_local_to_ts( sanitize_text_field( wp_unslash( $_POST['date'] ?? '' ) ) ); $n = POKIT_Release::change_date( $target, $ts, $notify, $message ); /* translators: %d: count */ self::back( sprintf( _n( 'Release date updated for %d pre-order.', 'Release date updated for %d pre-orders.', $n, 'shipclock-pre-orders' ), $n ) ); break; case 'email': $subject = sanitize_text_field( wp_unslash( $_POST['subject'] ?? '' ) ); $sent = 0; $groups = array(); foreach ( POKIT_Records::query( array( 'target_id' => $target, 'status' => pokit_open_statuses() ) ) as $r ) { $groups[ $r->order_id ][] = (int) $r->id; } foreach ( $groups as $order_id => $ids ) { do_action( 'pokit_email_customer_message', $order_id, $ids, $subject, $message ); ++$sent; } /* translators: %d: count */ self::back( sprintf( _n( 'Email sent to %d customer.', 'Email sent to %d customers.', $sent, 'shipclock-pre-orders' ), $sent ) ); break; case 'cancel': $ids = wp_list_pluck( POKIT_Records::query( array( 'target_id' => $target, 'status' => pokit_open_statuses() ) ), 'id' ); $n = POKIT_Release::cancel_records( $ids, array( 'message' => $message, 'notify' => $notify, 'refund' => ! empty( $_POST['refund'] ), ) ); if ( $end ) { POKIT_Release::end_preorder( $target ); } /* translators: %d: count */ self::back( sprintf( _n( '%d pre-order cancelled.', '%d pre-orders cancelled.', $n, 'shipclock-pre-orders' ), $n ) ); break; } self::back( __( 'Nothing to do.', 'shipclock-pre-orders' ) ); } /** * Apply an action to records. * * @param string $do Action. * @param int[] $ids Record IDs. * @return string Message. */ private static function apply( $do, $ids ) { $ids = array_map( 'absint', (array) $ids ); switch ( $do ) { case 'release': $n = POKIT_Release::release_records( $ids ); /* translators: %d: count */ return sprintf( _n( '%d pre-order released.', '%d pre-orders released.', $n, 'shipclock-pre-orders' ), $n ); case 'cancel': case 'cancel_norefund': $n = POKIT_Release::cancel_records( $ids, array( 'refund' => 'cancel' === $do ? null : false ) ); /* translators: %d: count */ return sprintf( _n( '%d pre-order cancelled.', '%d pre-orders cancelled.', $n, 'shipclock-pre-orders' ), $n ); } /** * Handle other record actions. Return a message, or '' if unhandled. * * @param string $message Message. * @param string $do Action. * @param int[] $ids Record IDs. */ $msg = (string) apply_filters( 'pokit_admin_record_action', '', $do, $ids ); return '' !== $msg ? $msg : __( 'Nothing to do.', 'shipclock-pre-orders' ); } /** * Nonce-protected URL for a single-record action. * * @param string $do Action. * @param object $r Record. * @return string */ public static function record_action_url( $do, $r ) { return wp_nonce_url( admin_url( 'admin-post.php?action=pokit_record_action&do=' . rawurlencode( $do ) . '&id=' . (int) $r->id ), 'pokit_record_action' ); } /** * Row action. */ public static function record_action() { if ( ! current_user_can( 'manage_woocommerce' ) || ! check_admin_referer( 'pokit_record_action' ) ) { wp_die( esc_html__( 'Not allowed.', 'shipclock-pre-orders' ) ); } $msg = self::apply( sanitize_key( $_GET['do'] ?? '' ), array( absint( $_GET['id'] ?? 0 ) ) ); $back = wp_get_referer(); self::back( $msg, $back ? remove_query_arg( 'pokit_msg', $back ) : self::url( array( 'tab' => 'list' ) ) ); } /** * Bulk action. */ public static function bulk_action() { if ( empty( $_POST['pokit_bulk'] ) ) { return; } if ( ! current_user_can( 'manage_woocommerce' ) || ! check_admin_referer( 'pokit_bulk', '_pokit_bulk_nonce' ) ) { wp_die( esc_html__( 'Not allowed.', 'shipclock-pre-orders' ) ); } // phpcs:disable WordPress.Security.ValidatedSanitizedInput $do = sanitize_key( $_POST['action'] ?? '' ); if ( ! $do || '-1' === $do ) { $do = sanitize_key( $_POST['action2'] ?? '' ); } $ids = isset( $_POST['ids'] ) ? array_map( 'absint', (array) $_POST['ids'] ) : array(); $back = isset( $_POST['_pokit_back'] ) ? esc_url_raw( wp_unslash( $_POST['_pokit_back'] ) ) : self::url( array( 'tab' => 'list' ) ); // phpcs:enable self::back( $ids ? self::apply( $do, $ids ) : __( 'No pre-orders selected.', 'shipclock-pre-orders' ), $back ); } /** * Pre-order box on the order edit screen. */ public static function order_meta_box() { $screen = function_exists( 'wc_get_page_screen_id' ) ? wc_get_page_screen_id( 'shop-order' ) : 'shop_order'; add_meta_box( 'pokit-order-box', __( 'Pre-orders', 'shipclock-pre-orders' ), array( __CLASS__, 'order_box' ), $screen, 'side', 'high' ); } /** * Box content. * * @param WP_Post|WC_Order $post_or_order Post or order. */ public static function order_box( $post_or_order ) { $order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order( $post_or_order->ID ); if ( ! $order ) { return; } if ( pokit_is_balance_order( $order ) ) { $parent = wc_get_order( $order->get_meta( '_pokit_parent_order' ) ); /* translators: %s: order link */ echo '

' . wp_kses_post( sprintf( __( 'Balance payment for pre-order %s.', 'shipclock-pre-orders' ), $parent ? '#' . esc_html( $parent->get_order_number() ) . '' : '' ) ) . '

'; return; } $records = POKIT_Records::query( array( 'order_id' => $order->get_id() ) ); if ( ! $records ) { echo '

' . esc_html__( 'No pre-order items.', 'shipclock-pre-orders' ) . '

'; return; } $labels = pokit_record_statuses(); echo ''; } /** * Orders list column. * * @param array $cols Columns. * @return array */ public static function order_columns( $cols ) { $new = array(); foreach ( $cols as $k => $v ) { $new[ $k ] = $v; if ( 'order_status' === $k ) { $new['pokit'] = __( 'Pre-order', 'shipclock-pre-orders' ); } } return $new; } /** * Column (HPOS). * * @param string $col Column. * @param WC_Order $order Order. */ public static function order_column_hpos( $col, $order ) { if ( 'pokit' === $col ) { self::order_badge( $order ); } } /** * Column (legacy). * * @param string $col Column. * @param int $post_id ID. */ public static function order_column_legacy( $col, $post_id ) { if ( 'pokit' === $col ) { self::order_badge( wc_get_order( $post_id ) ); } } /** * Badge. * * @param WC_Order $order Order. */ private static function order_badge( $order ) { if ( ! $order ) { return; } if ( pokit_is_balance_order( $order ) ) { echo '' . esc_html__( 'Balance', 'shipclock-pre-orders' ) . ''; return; } if ( 'yes' !== $order->get_meta( '_pokit_has_preorder' ) ) { echo '–'; return; } $open = POKIT_Orders::open_records( $order->get_id() ); echo '' . esc_html( pokit_order_is_preorder_only( $order ) ? __( 'Pre-order', 'shipclock-pre-orders' ) : __( 'Mixed', 'shipclock-pre-orders' ) ) . ''; } }