load_dependencies(); $this->maybe_update_database(); $this->set_locale(); $this->init_core(); $this->init_cron(); $this->init_customer_restrictions(); if ( is_admin() ) { $this->init_admin(); add_action( 'admin_init', array( $this, 'maybe_redirect_after_activation' ) ); add_action( 'admin_init', array( $this, 'maybe_backfill_settings_defaults' ) ); } else { $this->init_frontend(); } $this->init_ajax(); $this->init_blocks(); } /** * Check and update database if needed. */ private function maybe_update_database() { if ( BoxCart_Database::needs_update() ) { BoxCart_Database::create_tables(); } // Ensure new columns exist (dbDelta doesn't reliably add new columns). // Only run these checks once per DB version, then set a flag to skip on subsequent loads. $columns_version = get_option( 'boxcart_columns_ensured', '0.0.0' ); if ( version_compare( $columns_version, BoxCart_Database::DB_VERSION, '<' ) ) { BoxCart_Database::ensure_slot_columns(); BoxCart_Database::ensure_order_columns(); BoxCart_Database::ensure_quantity_type_columns(); BoxCart_Database::ensure_display_label_column(); BoxCart_Database::ensure_price_prefix_column(); BoxCart_Database::ensure_payment_columns(); BoxCart_Database::ensure_view_token_column(); update_option( 'boxcart_columns_ensured', BoxCart_Database::DB_VERSION ); } // Repair weighed order lines if an older reactivation rounded them. if ( get_option( 'boxcart_recover_quantities' ) ) { BoxCart_Database::ensure_quantity_type_columns(); BoxCart_Database::recover_rounded_quantities(); } // Run one-time data migrations. BoxCart_Database::fix_inconsistent_quantity_settings(); $this->maybe_restore_pro_email_templates(); } /** * Switch the Pro-only email templates back on (once, on the Free build). * * Before 1.5.0, saving the Emails tab on Free marked the templates it * doesn't show (order changed, messages) as disabled, so shops that * upgraded found them off. Free never sends them, so turning them back on * changes nothing until the shop upgrades. * * A shop that went straight from Free 1.4.0 to Pro arrives with all four * off. On Pro they're only turned back on the first time Pro runs (Pro * hasn't created its tables yet), and only when all four are off together, * which is what the Free bug left. A shop already on Pro keeps its choice. */ private function maybe_restore_pro_email_templates() { if ( get_option( 'boxcart_pro_templates_restored' ) ) { return; } $settings = get_option( 'boxcart_settings', array() ); $keys = array( 'order_modified', 'order_modified_admin', 'order_message_to_customer', 'order_message_to_admin' ); $off = array(); foreach ( $keys as $key ) { if ( isset( $settings['email_templates'][ $key ]['enabled'] ) && ! $settings['email_templates'][ $key ]['enabled'] ) { $off[] = $key; } } // Pro's classes aren't loaded yet at this point, so check for its files, // and for its tables' version option to tell an existing Pro shop. $is_pro = file_exists( BOXCART_PLUGIN_DIR . 'includes/premium/bootstrap.php' ); $pro_before = false !== get_option( 'boxcart_pro_db_version', false ); $from_free_bug = count( $off ) === count( $keys ) && ! $pro_before; $changed = ! empty( $off ) && ( ! $is_pro || $from_free_bug ); if ( $changed ) { foreach ( $off as $key ) { $settings['email_templates'][ $key ]['enabled'] = true; } } if ( $changed ) { update_option( 'boxcart_settings', $settings ); } update_option( 'boxcart_pro_templates_restored', 1 ); } /** * Backfill any settings keys missing from the stored option. * * BoxCart_Admin_Settings::get_defaults() is only merged in when the ADMIN * reads settings, via BoxCart_Admin_Settings::get_settings(). Everything * else — the storefront, the payment layer, the Setup screen — reads the * raw option and falls back to whatever inline default each call site * happens to use. Where a call site disagrees with get_defaults(), the * admin shows one thing and the shop does another. * * That gap was invisible until 1.1.0, because the only way to assign your * pages was to save the General tab, and every settings save persists the * whole merged array — so installs silently cured themselves. Guided Setup * writes the option directly without merging, so from 1.1.0 a store could * reach "setup complete" with the option still missing payments_enabled, * show_slot_selector_on_products and friends. Symptoms: no payment section * at checkout and no slot bar on the products page, with an admin screen * insisting both were switched on. * * Runs once per version, and only ADDS absent keys — wp_parse_args never * overwrites a key that is already present, so any value an admin has ever * saved is left exactly as it is. Sites that have saved any settings tab * are therefore untouched. * * @since 1.3.0 */ public function maybe_backfill_settings_defaults() { if ( BOXCART_VERSION === get_option( 'boxcart_settings_backfilled' ) ) { return; } $settings = get_option( 'boxcart_settings', array() ); if ( ! is_array( $settings ) ) { $settings = array(); } // array_diff_key rather than wp_parse_args: this adds ONLY the absent // keys and leaves the stored order alone. wp_parse_args would return the // defaults' ordering, rewriting the whole option on every version bump // even when nothing was actually missing. $missing = array_diff_key( BoxCart_Admin_Settings::get_defaults(), $settings ); if ( ! empty( $missing ) ) { update_option( 'boxcart_settings', array_merge( $settings, $missing ) ); } update_option( 'boxcart_settings_backfilled', BOXCART_VERSION ); } /** * Load required dependencies. */ private function load_dependencies() { // Helper class (load first as other classes may use it). require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-helpers.php'; // Core classes. require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-post-types.php'; require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-taxonomies.php'; require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-database.php'; require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-product.php'; require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-order.php'; require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-cutoff.php'; require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-location.php'; require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-slot.php'; require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-customer.php'; require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-email.php'; require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-notices.php'; require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-tax.php'; require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-product-quantities.php'; require_once BOXCART_PLUGIN_DIR . 'includes/core/class-boxcart-payment.php'; // API classes. require_once BOXCART_PLUGIN_DIR . 'includes/api/class-boxcart-ajax.php'; // Basket class needed for both frontend and cron. require_once BOXCART_PLUGIN_DIR . 'includes/frontend/class-boxcart-basket.php'; // Shortcodes and Blocks – needed in both admin (Gutenberg render_callback) and frontend. require_once BOXCART_PLUGIN_DIR . 'includes/frontend/class-boxcart-shortcodes.php'; require_once BOXCART_PLUGIN_DIR . 'includes/class-boxcart-blocks.php'; if ( is_admin() ) { // Admin classes (free + Pro UI shell; Pro submenus gated inside the menu class). require_once BOXCART_PLUGIN_DIR . 'includes/admin/class-boxcart-admin.php'; require_once BOXCART_PLUGIN_DIR . 'includes/admin/class-boxcart-admin-menus.php'; require_once BOXCART_PLUGIN_DIR . 'includes/admin/class-boxcart-admin-products.php'; require_once BOXCART_PLUGIN_DIR . 'includes/admin/class-boxcart-admin-orders.php'; require_once BOXCART_PLUGIN_DIR . 'includes/admin/class-boxcart-admin-locations.php'; require_once BOXCART_PLUGIN_DIR . 'includes/admin/class-boxcart-admin-settings.php'; require_once BOXCART_PLUGIN_DIR . 'includes/admin/class-boxcart-admin-dashboard-widget.php'; require_once BOXCART_PLUGIN_DIR . 'includes/admin/class-boxcart-setup.php'; // License-activation UI / status notice. Both files contain // upgrade-path guidance that WordPress.org Plugin Directory // Guideline 5 disallows from the Free zip — bin/build-zip.sh // strips them on Free builds, so guard the requires with // file_exists() rather than a hardcoded inclusion. $boxcart_license_activation = BOXCART_PLUGIN_DIR . 'includes/admin/class-boxcart-license-activation.php'; if ( file_exists( $boxcart_license_activation ) ) { require_once $boxcart_license_activation; } $boxcart_license_status_notice = BOXCART_PLUGIN_DIR . 'includes/admin/class-boxcart-license-status-notice.php'; if ( file_exists( $boxcart_license_status_notice ) ) { require_once $boxcart_license_status_notice; } } else { // Frontend classes. require_once BOXCART_PLUGIN_DIR . 'includes/frontend/class-boxcart-frontend.php'; require_once BOXCART_PLUGIN_DIR . 'includes/frontend/class-boxcart-checkout.php'; require_once BOXCART_PLUGIN_DIR . 'includes/frontend/class-boxcart-sidecart.php'; // BoxCart_Account (customer accounts) is autoloaded on demand from // includes/frontend/class-boxcart-account.php. Customer accounts // are a Free feature — login, register, order history and // password reset all work on the Free build. } } /** * Set up internationalisation. * * Since WordPress 4.6, plugins hosted on WordPress.org have their * translations loaded automatically by core from translate.wordpress.org. * We therefore no longer need to call load_plugin_textdomain() here. * * If you are shipping custom translations (not via wp.org's GlotPress), * place the .mo files in /languages/ and uncomment the action below. */ private function set_locale() { // No-op: WordPress auto-loads translations for wp.org-hosted plugins. } /** * Initialise core components. */ private function init_core() { new BoxCart_Post_Types(); new BoxCart_Taxonomies(); new BoxCart_Email(); // Ensure customer role exists. $this->ensure_customer_role(); // Allow SVG uploads for icon customisation (restricted and checked). add_filter( 'upload_mimes', array( $this, 'allow_svg_uploads' ) ); add_filter( 'wp_handle_upload_prefilter', array( $this, 'check_svg_upload' ) ); } /** * Allow SVG files in the WordPress media uploader. * * An SVG can carry JavaScript that runs when the file is opened directly, so * it is only allowed for users who can already post unfiltered HTML. That * grants nobody a capability they don't already have. Compressed .svgz is * not allowed at all, because it can't be inspected on upload. * * @param array $mimes Allowed mime types. * @return array */ public function allow_svg_uploads( $mimes ) { if ( current_user_can( 'unfiltered_html' ) ) { $mimes['svg'] = 'image/svg+xml'; } return $mimes; } /** * Refuse SVG uploads that contain scripts, event handlers or external references. * * Belt and braces alongside the capability check above. Custom icons are * shown via or a CSS mask, where scripts never run, but the file itself * is reachable at its own URL. * * @param array $file Upload data from wp_handle_upload. * @return array */ public function check_svg_upload( $file ) { $name = isset( $file['name'] ) ? strtolower( (string) $file['name'] ) : ''; if ( '.svg' !== substr( $name, -4 ) ) { return $file; } $path = isset( $file['tmp_name'] ) ? $file['tmp_name'] : ''; $contents = ( $path && is_readable( $path ) ) ? file_get_contents( $path ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local temp upload, not a remote URL. if ( false === $contents ) { $file['error'] = __( 'This SVG file could not be read.', 'boxcart' ); return $file; } $unsafe = '/]+href\s*=\s*["\'](?!#)/i'; if ( preg_match( $unsafe, $contents ) ) { $file['error'] = __( 'This SVG contains scripts or external references, so it cannot be uploaded. Please use a simple icon file.', 'boxcart' ); } return $file; } /** * Ensure the customer role exists. * * This runs on every page load but only creates the role if it doesn't * exist. Delegates to the shared helper method for consistency. */ private function ensure_customer_role() { BoxCart_Helpers::create_customer_role(); } /** * Initialise cron hooks. */ private function init_cron() { add_action( 'boxcart_cleanup_expired_baskets', array( 'BoxCart_Basket', 'cleanup_expired_baskets' ) ); add_action( 'wp_login', array( 'BoxCart_Basket', 'handle_login' ), 10, 2 ); add_action( 'boxcart_cleanup_old_logs', array( 'BoxCart_Order', 'cleanup_old_logs' ) ); add_action( 'boxcart_cleanup_awaiting_orders', array( 'BoxCart_Checkout', 'cleanup_awaiting_orders' ) ); add_action( 'boxcart_generate_image_sizes', array( 'BoxCart_Helpers', 'generate_missing_image_sizes' ) ); // Events are scheduled on activation, but updates don't activate, and a // lost event would never come back. Put back any that are missing. add_action( 'init', function () { $events = array( 'boxcart_cleanup_expired_baskets' => 'twicedaily', 'boxcart_cleanup_old_logs' => 'daily', 'boxcart_cleanup_awaiting_orders' => 'hourly', ); foreach ( $events as $hook => $recurrence ) { if ( ! wp_next_scheduled( $hook ) ) { wp_schedule_event( time(), $recurrence, $hook ); } } } ); } /** * Initialise admin components. */ private function init_admin() { new BoxCart_Admin(); } /** * Initialise frontend components. */ private function init_frontend() { // Initialize session for notices. add_action( 'init', array( 'BoxCart_Notices', 'init' ), 1 ); new BoxCart_Frontend(); // Customer accounts are available on both builds. Free gets login + // register + basic order history; Pro unlocks favourites, reorder, // messaging and the order-modification window inside the dashboard. new BoxCart_Account(); } /** * Initialise AJAX handlers. */ private function init_ajax() { new BoxCart_Ajax(); } /** * Initialise Gutenberg blocks. */ private function init_blocks() { new BoxCart_Blocks(); } /** * Initialise customer restrictions. * * Handles redirecting customers away from wp-admin and hiding the admin bar. * This runs in both admin and frontend contexts. */ private function init_customer_restrictions() { // Redirect customers away from wp-admin. add_action( 'admin_init', array( $this, 'redirect_customers_from_admin' ) ); // Redirect customers to account page after login. add_filter( 'login_redirect', array( $this, 'redirect_customer_on_login' ), 10, 3 ); // Hide admin bar for customers. add_action( 'after_setup_theme', array( $this, 'hide_admin_bar_for_customers' ) ); } /** * Redirect customers away from wp-admin. * * Non-admin users (those without edit_posts capability) are redirected * to the frontend account page when trying to access wp-admin. */ public function redirect_customers_from_admin() { // Don't redirect AJAX requests. if ( wp_doing_ajax() ) { return; } // Only redirect logged-in users. if ( ! is_user_logged_in() ) { return; } // Check if redirect is enabled. $settings = get_option( 'boxcart_settings', array() ); $redirect_enabled = isset( $settings['redirect_customers_from_admin'] ) ? (bool) $settings['redirect_customers_from_admin'] : true; if ( ! $redirect_enabled ) { return; } // Get current user. $user = wp_get_current_user(); // Only customers are sent away, not admins, editors or shop staff. if ( ! $this->is_customer_user( $user ) ) { return; } // Redirect to the account page. $redirect_url = $this->get_account_page_url(); if ( $redirect_url ) { wp_safe_redirect( $redirect_url ); exit; } } /** * Redirect to the BoxCart dashboard after first activation. */ public function maybe_redirect_after_activation() { if ( ! get_transient( 'boxcart_activation_redirect' ) ) { return; } delete_transient( 'boxcart_activation_redirect' ); // Don't redirect on multisite bulk activation or if activating other plugins. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- activate-multi is a WordPress core parameter set by the plugins screen; we only check its presence, not its value. if ( wp_doing_ajax() || isset( $_GET['activate-multi'] ) ) { return; } // Until the opt-in (or, on Pro, the licence) screen has been answered, // Freemius hides every BoxCart page but the main one, where it shows that // screen. Go there: afterwards Freemius sends new shops on to Guided Setup // (see boxcart_after_freemius_activation_url()). if ( function_exists( 'boxcart_fs' ) && boxcart_fs()->is_activation_mode() ) { wp_safe_redirect( admin_url( 'admin.php?page=boxcart' ) ); exit; } // New shops start in Guided Setup; shops already set up go to the dashboard. $setup_done = class_exists( 'BoxCart_Setup' ) && BoxCart_Setup::get_status()['complete']; wp_safe_redirect( admin_url( $setup_done ? 'admin.php?page=boxcart' : 'admin.php?page=boxcart-setup' ) ); exit; } /** * Redirect customers to account page after login. * * @param string $redirect_to The redirect destination URL. * @param string $requested_redirect_to The requested redirect destination URL. * @param WP_User|WP_Error $user WP_User object if login was successful. * @return string Redirect URL. */ public function redirect_customer_on_login( $redirect_to, $requested_redirect_to, $user ) { // Only process for successful logins. if ( ! $user instanceof WP_User ) { return $redirect_to; } // Check if redirect is enabled. $settings = get_option( 'boxcart_settings', array() ); $redirect_enabled = isset( $settings['redirect_customers_from_admin'] ) ? (bool) $settings['redirect_customers_from_admin'] : true; if ( ! $redirect_enabled ) { return $redirect_to; } // Only customers are redirected, not admins, editors or shop staff. if ( ! $this->is_customer_user( $user ) ) { return $redirect_to; } // Send them back where they signed in from (checkout's "Returning // customer? Sign in", the favourites and members-only prompts), as long // as it's on this site and not the dashboard customers are kept out of. $requested = $requested_redirect_to ? wp_validate_redirect( $requested_redirect_to, '' ) : ''; if ( $requested && 0 !== strpos( $requested, admin_url() ) ) { return $requested; } // Get the account page URL. $account_url = $this->get_account_page_url(); if ( $account_url ) { return $account_url; } return $redirect_to; } /** * Whether a user is a shop customer rather than staff. * * Customers are users whose only roles are customer roles (BoxCart's * Customer, Subscriber, or WooCommerce's Customer). Anyone else, such as * shop staff given a custom role, keeps the dashboard and admin bar even * without the edit_posts capability. * * @param WP_User $user User. * @return bool */ private function is_customer_user( $user ) { if ( $user->has_cap( 'edit_posts' ) ) { return false; } /** * Roles treated as customers (sent to My Account, no admin bar). * * @param string[] $roles Role slugs. */ $customer_roles = (array) apply_filters( 'boxcart_customer_roles', array( 'boxcart_customer', 'subscriber', 'customer' ) ); return ! array_diff( (array) $user->roles, $customer_roles ); } /** * Hide the admin bar for customers. */ public function hide_admin_bar_for_customers() { if ( ! is_user_logged_in() ) { return; } // Check if hide admin bar is enabled. $settings = get_option( 'boxcart_settings', array() ); $hide_bar = isset( $settings['hide_admin_bar_for_customers'] ) ? (bool) $settings['hide_admin_bar_for_customers'] : true; if ( ! $hide_bar ) { return; } // Get current user. $user = wp_get_current_user(); // Only customers lose the admin bar, not admins, editors or shop staff. if ( ! $this->is_customer_user( $user ) ) { return; } // Hide admin bar for customers. show_admin_bar( false ); } /** * Get the account page URL. * * @return string Account page URL or home URL if not set. */ private function get_account_page_url() { $settings = get_option( 'boxcart_settings', array() ); $page_id = ! empty( $settings['account_page_id'] ) ? (int) $settings['account_page_id'] : 0; if ( $page_id ) { return get_permalink( $page_id ); } return home_url(); } /** * Get plugin version. * * @return string Plugin version. */ public function get_version() { return BOXCART_VERSION; } }