__( 'Your session has expired. Please reload the page and try again.', 'boxcart' ) ) ); } if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to do this.', 'boxcart' ) ) ); } } /** * Wizard step 1: create (or adopt) the storefront pages. */ public function ajax_pages() { $this->verify_ajax(); $result = self::create_pages(); $status = self::get_status(); $pages = array(); foreach ( $status['pages'] as $page ) { $pages[] = array( 'label' => $page['label'], 'done' => $page['done'], 'view_url' => $page['view_url'], ); } wp_send_json_success( array( 'created' => count( $result['created'] ), 'adopted' => count( $result['adopted'] ), 'pages' => $pages, 'done' => $status['pages_done'], ) ); } /** * Wizard step 2: a collection location with its open days and time slots. * * The slots come from a simple pattern (first and last time, how long each * slot is, and how many orders each takes), copied to every open day. */ public function ajax_location() { $this->verify_ajax(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in verify_ajax(). $name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : ''; $address = isset( $_POST['address'] ) ? sanitize_text_field( wp_unslash( $_POST['address'] ) ) : ''; $city = isset( $_POST['city'] ) ? sanitize_text_field( wp_unslash( $_POST['city'] ) ) : ''; $postcode = isset( $_POST['postcode'] ) ? sanitize_text_field( wp_unslash( $_POST['postcode'] ) ) : ''; $days = isset( $_POST['days'] ) && is_array( $_POST['days'] ) ? array_map( 'absint', wp_unslash( $_POST['days'] ) ) : array(); $starts = isset( $_POST['slot_start'] ) && is_array( $_POST['slot_start'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['slot_start'] ) ) : array(); $ends = isset( $_POST['slot_end'] ) && is_array( $_POST['slot_end'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['slot_end'] ) ) : array(); $capacities = isset( $_POST['slot_capacity'] ) && is_array( $_POST['slot_capacity'] ) ? array_map( 'absint', wp_unslash( $_POST['slot_capacity'] ) ) : array(); // phpcs:enable WordPress.Security.NonceVerification.Missing // Days of the week, 0 (Sunday) to 6. $days = array_values( array_unique( array_filter( $days, function ( $day ) { return $day <= 6; } ) ) ); if ( '' === $name ) { wp_send_json_error( array( 'message' => __( 'Give your collection point a name, such as "The Farm Shop".', 'boxcart' ) ) ); } if ( empty( $days ) ) { wp_send_json_error( array( 'message' => __( 'Choose at least one day when customers can collect.', 'boxcart' ) ) ); } // The time slots, the same on every open day: from, until and how many // orders each takes. Rows left empty are ignored; at most 48. $slots = array(); foreach ( $starts as $i => $from ) { $to = isset( $ends[ $i ] ) ? $ends[ $i ] : ''; if ( '' === $from && '' === $to ) { continue; } $start = preg_match( '/^\d{2}:\d{2}$/', $from ) ? strtotime( '1970-01-01 ' . $from . ' UTC' ) : false; $end = preg_match( '/^\d{2}:\d{2}$/', $to ) ? strtotime( '1970-01-01 ' . $to . ' UTC' ) : false; if ( false === $start || false === $end || $end <= $start ) { wp_send_json_error( array( 'message' => __( 'Each time slot needs to end after it starts.', 'boxcart' ) ) ); } $slots[ $start . '-' . $end ] = array( $start, $end, isset( $capacities[ $i ] ) && $capacities[ $i ] > 0 ? min( 999, $capacities[ $i ] ) : 10 ); } if ( empty( $slots ) ) { wp_send_json_error( array( 'message' => __( 'Add at least one time slot customers can choose.', 'boxcart' ) ) ); } ksort( $slots, SORT_NATURAL ); $slots = array_slice( array_values( $slots ), 0, 48 ); $locations = new BoxCart_Admin_Locations(); $location_data = array( 'name' => $name, 'address_line_1' => $address, 'address_line_2' => '', 'city' => $city, 'county' => '', 'postcode' => $postcode, 'phone' => '', 'email' => '', 'instructions' => '', 'is_active' => 1, 'sort_order' => 0, ); // Without Pro a shop has one location, so one that already exists (a // switched-off one, or one saved from another tab) is used instead. $existing = ( function_exists( 'boxcart_is_premium' ) && boxcart_is_premium() ) ? array() : $locations->get_locations(); if ( ! empty( $existing ) ) { $location_id = (int) $existing[0]->id; $updated = $locations->update_location( $location_id, $location_data ); if ( is_wp_error( $updated ) ) { wp_send_json_error( array( 'message' => $updated->get_error_message() ) ); } } else { $location_id = $locations->create_location( $location_data ); if ( is_wp_error( $location_id ) ) { wp_send_json_error( array( 'message' => $location_id->get_error_message() ) ); } } $locations->save_opening_hours( $location_id, $days ); foreach ( $days as $day ) { // The times chosen here replace any the day had (a location used // again from an earlier setup). Orders keep their own times. foreach ( (array) $locations->get_slots( $location_id, $day ) as $old_slot ) { $locations->delete_slot( $old_slot->id ); } foreach ( $slots as $slot ) { $locations->create_slot( array( 'location_id' => $location_id, 'day_of_week' => $day, 'time_start' => gmdate( 'H:i:s', $slot[0] ), 'time_end' => gmdate( 'H:i:s', $slot[1] ), 'capacity' => $slot[2], 'is_active' => 1, ) ); } } wp_send_json_success( array( 'name' => $name, 'slots' => count( $slots ), 'edit_url' => admin_url( 'admin.php?page=boxcart-locations&action=edit&id=' . $location_id ), ) ); } /** * The ways of selling a product offered in the wizard (the product * editor's presets), with the steps customers can buy weights in. * * @return array Keyed by preset: { label, steps (value => label), default_step }. */ public static function get_product_units() { return array( 'each' => array( 'label' => __( 'Each', 'boxcart' ) ), 'unit' => array( 'label' => __( 'Per unit', 'boxcart' ) ), 'bunch' => array( 'label' => __( 'Per bunch', 'boxcart' ) ), 'bag' => array( 'label' => __( 'Per bag', 'boxcart' ) ), 'box' => array( 'label' => __( 'Per box', 'boxcart' ) ), 'kg' => array( 'label' => __( 'By the kg', 'boxcart' ), 'steps' => array( '0.1' => __( '100 g', 'boxcart' ), '0.25' => __( '250 g', 'boxcart' ), '0.5' => __( 'Half a kilo', 'boxcart' ), '1' => __( '1 kg', 'boxcart' ), ), 'default_step' => '0.5', ), 'g' => array( 'label' => __( 'By the gram', 'boxcart' ), 'steps' => array( '50' => __( '50 g', 'boxcart' ), '100' => __( '100 g', 'boxcart' ), '250' => __( '250 g', 'boxcart' ), '500' => __( '500 g', 'boxcart' ), ), 'default_step' => '100', ), 'lb' => array( 'label' => __( 'By the lb', 'boxcart' ), 'steps' => array( '0.25' => __( 'Quarter of a pound', 'boxcart' ), '0.5' => __( 'Half a pound', 'boxcart' ), '1' => __( '1 lb', 'boxcart' ), ), 'default_step' => '0.5', ), ); } /** * Wizard step 3: a first product. */ public function ajax_product() { $this->verify_ajax(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in verify_ajax(). $name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : ''; $price = isset( $_POST['price'] ) ? str_replace( ',', '.', sanitize_text_field( wp_unslash( $_POST['price'] ) ) ) : ''; $units = self::get_product_units(); $unit = isset( $_POST['unit'] ) ? sanitize_key( wp_unslash( $_POST['unit'] ) ) : 'each'; $unit = isset( $units[ $unit ], BoxCart_Product_Quantities::PRESETS[ $unit ] ) ? $unit : 'each'; $step = isset( $_POST[ 'step_' . $unit ] ) ? sanitize_text_field( wp_unslash( $_POST[ 'step_' . $unit ] ) ) : ''; $category = isset( $_POST['category'] ) ? sanitize_text_field( wp_unslash( $_POST['category'] ) ) : ''; $image_id = isset( $_POST['image_id'] ) ? absint( $_POST['image_id'] ) : 0; // phpcs:enable WordPress.Security.NonceVerification.Missing if ( '' === $name ) { wp_send_json_error( array( 'message' => __( 'Give your product a name.', 'boxcart' ) ) ); } if ( BoxCart_Helpers::prices_enabled() && ( '' === $price || ! is_numeric( $price ) || (float) $price < 0 ) ) { wp_send_json_error( array( 'message' => __( 'Enter a price, such as 2.50.', 'boxcart' ) ) ); } if ( $image_id && 'attachment' !== get_post_type( $image_id ) ) { $image_id = 0; } $categories = array(); if ( '' !== $category ) { $term = term_exists( $category, 'boxcart_product_category' ); if ( ! $term ) { $term = wp_insert_term( $category, 'boxcart_product_category' ); } if ( ! is_wp_error( $term ) && ! empty( $term['term_id'] ) ) { $categories[] = (int) $term['term_id']; } } // The preset, and for weights the step customers buy in (also the least // they can order). $type = BoxCart_Product_Quantities::PRESETS[ $unit ]; if ( ! empty( $units[ $unit ]['steps'] ) ) { $step = isset( $units[ $unit ]['steps'][ $step ] ) ? $step : $units[ $unit ]['default_step']; $type['step_increment'] = (float) $step; $type['min_quantity'] = (float) $step; } $type['price_per_unit'] = (float) $price; $type['display_label'] = ''; $type['max_quantity'] = null; $type['display_order'] = 0; $products = new BoxCart_Admin_Products(); $product_id = $products->save_product( array( 'name' => $name, 'description' => '', 'sku' => '', 'price' => '' === $price ? '' : (string) (float) $price, 'stock_status' => 'in_stock', 'sort_order' => 0, 'is_active' => true, 'image_id' => $image_id, 'categories' => $categories, 'use_custom_quantities' => true, 'quantity_types' => array( $type ), ) ); if ( is_wp_error( $product_id ) ) { wp_send_json_error( array( 'message' => $product_id->get_error_message() ) ); } wp_send_json_success( array( 'name' => $name, 'edit_url' => admin_url( 'admin.php?page=boxcart-products&action=edit&id=' . $product_id ), 'count' => (int) wp_count_posts( 'boxcart_product' )->publish, ) ); } /** * Wizard step 4: finishing touches (how customers pay, who emails come * from, VAT). */ public function ajax_touches() { $this->verify_ajax(); $settings = array_merge( BoxCart_Admin_Settings::get_defaults(), (array) get_option( self::OPTION, array() ) ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in verify_ajax(). // Payments and VAT are only on screen when the shop shows prices; with // prices off, their settings are left alone. $prices_on_screen = ! empty( $_POST['prices_on_screen'] ); if ( $prices_on_screen ) { $settings['cash_on_collection_enabled'] = ! empty( $_POST['pay_cash'] ); $settings['bank_transfer_enabled'] = ! empty( $_POST['pay_bank'] ); foreach ( array( 'bank_name' => 'bank_transfer_account_name', 'bank_sort' => 'bank_transfer_sort_code', 'bank_number' => 'bank_transfer_account_number', ) as $field => $key ) { if ( isset( $_POST[ $field ] ) ) { $settings[ $key ] = sanitize_text_field( wp_unslash( $_POST[ $field ] ) ); } } if ( $settings['bank_transfer_enabled'] && ( '' === trim( $settings['bank_transfer_account_name'] ) || '' === trim( $settings['bank_transfer_account_number'] ) ) ) { wp_send_json_error( array( 'message' => __( 'Add the account name and number customers should pay into, or untick bank transfer.', 'boxcart' ) ) ); } } if ( isset( $_POST['from_name'] ) && '' !== trim( sanitize_text_field( wp_unslash( $_POST['from_name'] ) ) ) ) { $settings['email_from_name'] = sanitize_text_field( wp_unslash( $_POST['from_name'] ) ); } $from_address = isset( $_POST['from_address'] ) ? sanitize_email( wp_unslash( $_POST['from_address'] ) ) : ''; if ( $from_address ) { $settings['email_from_address'] = $from_address; } $alerts_to = isset( $_POST['alerts_to'] ) ? sanitize_email( wp_unslash( $_POST['alerts_to'] ) ) : ''; if ( $alerts_to ) { $settings['admin_email'] = $alerts_to; } if ( $prices_on_screen ) { $vat = isset( $_POST['vat'] ) ? sanitize_key( wp_unslash( $_POST['vat'] ) ) : 'none'; $settings['tax_enabled'] = in_array( $vat, array( 'inclusive', 'exclusive' ), true ); if ( $settings['tax_enabled'] ) { $settings['tax_mode'] = $vat; $settings['tax_rate'] = isset( $_POST['vat_rate'] ) ? max( 0, min( 100, (float) str_replace( ',', '.', sanitize_text_field( wp_unslash( $_POST['vat_rate'] ) ) ) ) ) : 20; } } // phpcs:enable WordPress.Security.NonceVerification.Missing if ( $prices_on_screen && ! $settings['cash_on_collection_enabled'] && ! $settings['bank_transfer_enabled'] && empty( $settings['stripe_enabled'] ) ) { wp_send_json_error( array( 'message' => __( 'Choose at least one way for customers to pay.', 'boxcart' ) ) ); } update_option( self::OPTION, $settings ); $progress = (array) get_option( self::PROGRESS_OPTION, array() ); $progress['touches'] = 1; update_option( self::PROGRESS_OPTION, $progress, false ); wp_send_json_success(); } /** * Has the current user dismissed the dashboard Quick Start guide? * * Stored per-user so one admin dismissing it does not hide it for another. * * @return bool */ public static function is_quickstart_dismissed() { return (bool) get_user_meta( get_current_user_id(), 'boxcart_quickstart_dismissed', true ); } /** * Handle the dashboard Quick Start "Dismiss" submission. */ public function maybe_handle_dismiss_quickstart() { if ( ! isset( $_POST['boxcart_setup_action'] ) || 'dismiss_quickstart' !== $_POST['boxcart_setup_action'] ) { return; } if ( ! isset( $_POST['boxcart_dismiss_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['boxcart_dismiss_nonce'] ) ), 'boxcart_dismiss_quickstart' ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } update_user_meta( get_current_user_id(), 'boxcart_quickstart_dismissed', 1 ); wp_safe_redirect( admin_url( 'admin.php?page=boxcart' ) ); exit; } /** * Register the Setup page's contextual help content. * * @param array $topics Existing help topics. * @return array */ public function add_help_topic( $topics ) { $topics['setup'] = array( 'title' => __( 'Setup', 'boxcart' ), 'description' => __( 'Guided Setup gets your shop ready in five steps: your shop pages, where and when customers collect, your first product, finishing touches (how customers pay, who order emails come from, VAT), and a test order. Each step saves on its own; skip any and come back to it here.', 'boxcart' ), 'tips' => array( __( 'BoxCart needs Shop, Basket, and Checkout pages, plus a My Account page for customers. "Create my pages" builds and assigns them for you.', 'boxcart' ), __( 'Already made the pages yourself? BoxCart detects the shortcode and reuses them instead of creating duplicates.', 'boxcart' ), __( 'The collection times you set are used on every day you open. Give each day its own times afterwards under Pickup Location (Locations on Pro).', 'boxcart' ), __( 'Everything you choose here can be changed later in Settings.', 'boxcart' ), ), 'docs' => array( array( 'label' => __( 'Getting started guide', 'boxcart' ), 'url' => 'https://docs.boxcart.dev', ), ), ); return $topics; } /** * The storefront pages a store needs, in setup order. * * Each entry: setting key that stores the page ID, the shortcode that must * appear on the page, a default title, and whether it is required for a * working store (account is strongly recommended but not strictly required). * * @return array */ public static function get_required_pages() { return array( 'products_page_id' => array( 'title' => __( 'Shop', 'boxcart' ), 'label' => __( 'Shop', 'boxcart' ), 'block' => 'boxcart/products', 'shortcode' => '[boxcart_products]', 'required' => true, ), 'basket_page_id' => array( 'title' => __( 'Basket', 'boxcart' ), 'label' => __( 'Basket', 'boxcart' ), 'block' => 'boxcart/basket', 'shortcode' => '[boxcart_basket]', 'required' => true, ), 'checkout_page_id' => array( 'title' => __( 'Checkout', 'boxcart' ), 'label' => __( 'Checkout', 'boxcart' ), 'block' => 'boxcart/checkout', 'shortcode' => '[boxcart_checkout]', 'required' => true, ), 'account_page_id' => array( 'title' => __( 'My Account', 'boxcart' ), 'label' => __( 'My Account', 'boxcart' ), 'block' => 'boxcart/account', 'shortcode' => '[boxcart_account]', 'required' => false, ), ); } /** * Does some post content already contain this page's BoxCart output, in * either form: the dynamic block (``) or the * shortcode (`[boxcart_basket]`)? Checked loosely so wrapper markup or * block attributes still count. * * @param string $content Post content. * @param array $page A get_required_pages() entry. * @return bool */ protected static function content_has_boxcart( $content, $page ) { $content = (string) $content; // Block form: the block name appears as `wp:boxcart/basket`. if ( ! empty( $page['block'] ) && false !== strpos( $content, 'wp:' . $page['block'] ) ) { return true; } // Shortcode form: the bare tag, e.g. `boxcart_basket`. $tag = trim( $page['shortcode'], '[]' ); if ( '' !== $tag && false !== strpos( $content, $tag ) ) { return true; } return false; } /** * Read the BoxCart settings array. * * @return array */ protected static function get_settings() { $settings = get_option( self::OPTION, array() ); $settings = is_array( $settings ) ? $settings : array(); // Merge the defaults in. create_pages() writes this array straight back // to the option, so reading it raw meant Guided Setup persisted a // PARTIAL settings array — page IDs present, but payments_enabled and // show_slot_selector_on_products still absent. A store could then finish // setup with no payment section at checkout and no slot bar, while the // admin screens (which do merge the defaults) insisted both were on. return wp_parse_args( $settings, BoxCart_Admin_Settings::get_defaults() ); } /** * Is a given page-id setting pointing at a real, published page that * actually contains the BoxCart block or shortcode for this slot? * * @param string $setting_key Settings key holding the page ID. * @param array $page A get_required_pages() entry. * @return int Page ID if valid, else 0. */ public static function get_valid_page_id( $setting_key, $page ) { $settings = self::get_settings(); $page_id = isset( $settings[ $setting_key ] ) ? (int) $settings[ $setting_key ] : 0; if ( $page_id <= 0 ) { return 0; } $post = get_post( $page_id ); if ( ! $post || 'page' !== $post->post_type || 'trash' === $post->post_status ) { return 0; } // Valid as long as the BoxCart block or shortcode is present, even if // the owner has customised the rest of the page. if ( ! self::content_has_boxcart( $post->post_content, $page ) ) { return 0; } return $page_id; } /** * Find an existing published or draft page that already contains this * slot's BoxCart block or shortcode, so we adopt a hand-made page instead * of creating a duplicate. * * @param array $page A get_required_pages() entry. * @return int Page ID or 0. */ protected static function find_existing_page( $page ) { $candidates = get_posts( array( 'post_type' => 'page', 'post_status' => array( 'publish', 'draft' ), 'posts_per_page' => 20, 's' => 'boxcart', // get_posts() already defaults suppress_filters to true. ) ); foreach ( $candidates as $candidate ) { if ( self::content_has_boxcart( $candidate->post_content, $page ) ) { return (int) $candidate->ID; } } return 0; } /** * Compute the full onboarding status used by the Setup page and the * dashboard card. * * @return array { * @type bool $has_product * @type bool $has_location * @type array $pages Per-page status keyed by setting key. * @type bool $pages_done All REQUIRED pages valid. * @type bool $complete Everything required is in place. * @type int $done_count * @type int $total_count * } */ public static function get_status() { // Product check (core's cached post counts). $product_counts = wp_count_posts( 'boxcart_product' ); $has_product = ! empty( $product_counts->publish ); // Location check (active locations live in the plugin's own table). $has_location = false; if ( class_exists( 'BoxCart_Location' ) ) { $active = BoxCart_Location::get_all_active(); $has_location = ! empty( $active ); } // Page checks. $pages = array(); $pages_done = true; foreach ( self::get_required_pages() as $key => $page ) { $valid_id = self::get_valid_page_id( $key, $page ); $pages[ $key ] = array( 'label' => $page['label'], 'title' => $page['title'], 'shortcode' => $page['shortcode'], 'required' => $page['required'], 'page_id' => $valid_id, 'done' => $valid_id > 0, 'edit_url' => $valid_id > 0 ? get_edit_post_link( $valid_id, 'raw' ) : '', 'view_url' => $valid_id > 0 ? get_permalink( $valid_id ) : '', ); if ( $page['required'] && 0 === $valid_id ) { $pages_done = false; } } // High-level checklist (the three things a new user must do). $steps = array( 'product' => $has_product, 'location' => $has_location, 'pages' => $pages_done, ); $done_count = count( array_filter( $steps ) ); return array( 'has_product' => $has_product, 'has_location' => $has_location, 'pages' => $pages, 'pages_done' => $pages_done, 'complete' => count( $steps ) === $done_count, 'done_count' => $done_count, 'total_count' => count( $steps ), ); } /** * Create any missing storefront pages and assign them in settings. * * Idempotent: a page that is already assigned (and valid) is left alone; a * hand-made page containing the right shortcode is adopted rather than * duplicated; only genuinely missing pages are created. * * @return array { * @type string[] $created Titles of newly created pages. * @type string[] $adopted Titles of existing pages newly assigned. * @type string[] $skipped Titles already set up. * } */ public static function create_pages() { $settings = self::get_settings(); $result = array( 'created' => array(), 'adopted' => array(), 'skipped' => array(), ); foreach ( self::get_required_pages() as $key => $page ) { // Already valid? Leave it. if ( self::get_valid_page_id( $key, $page ) ) { $result['skipped'][] = $page['title']; continue; } // Adopt a hand-made page that already has the block or shortcode. $existing = self::find_existing_page( $page ); if ( $existing ) { $settings[ $key ] = $existing; $result['adopted'][] = $page['title']; continue; } // Otherwise create a fresh page using the dynamic block, so it shows // as BoxCart's branded block in the editor (matching a manual insert) // while still rendering server-side on the front end. $page_id = wp_insert_post( array( 'post_title' => $page['title'], 'post_content' => '', 'post_status' => 'publish', 'post_type' => 'page', ), true ); if ( ! is_wp_error( $page_id ) && $page_id ) { $settings[ $key ] = (int) $page_id; $result['created'][] = $page['title']; } } update_option( self::OPTION, $settings ); return $result; } /** * Handle the "Create my pages" form submission from the Setup page. */ public function maybe_handle_create_pages() { if ( ! isset( $_POST['boxcart_setup_action'] ) || 'create_pages' !== $_POST['boxcart_setup_action'] ) { return; } if ( ! isset( $_POST['boxcart_setup_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['boxcart_setup_nonce'] ) ), 'boxcart_create_pages' ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } $result = self::create_pages(); $created = count( $result['created'] ) + count( $result['adopted'] ); wp_safe_redirect( add_query_arg( array( 'page' => 'boxcart-setup', 'created' => (int) $created, ), admin_url( 'admin.php' ) ) ); exit; } }