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 = '/