'$', 'currency_position' => 'before', // before | after 'product_slug' => 'products', 'label_singular' => '', // what a product is called on this site ("Piece"); empty = Product 'label_plural' => '', // "Pieces"; empty = Products 'category_label_singular' => '', // "Collection"; empty = Product Category 'category_label_plural' => '', // "Collections"; empty = Product Categories 'category_slug' => 'nexcart-category', // namespaced to avoid WooCommerce product_cat base clash 'products_per_page' => 12, 'archive_columns' => 3, // 2 | 3 | 4 'show_price' => 1, 'show_category_badge' => 1, 'disable_css' => 0, 'enable_category_filter' => 1, 'enable_price_filter' => 0, 'enable_search' => 1, 'enable_sort' => 1, 'filter_mode' => 'url', // url | ajax 'filterable_attributes' => array(), 'default_contact' => array(), /* * The enquiry list (1.1.0). `enquiry_page` is the page carrying `[aumnexcart_enquiry]` + a form; * 0 = the feature is off and products end in a contact link as before. The two labels are what the * add button says before and after; `enquiry_field` is the hidden form field the sheet writes into. */ 'enquiry_page' => 0, 'enquiry_label' => '', 'enquiry_added_label' => '', 'enquiry_field' => 'enquiry_items', ); } /** * Returns the merged settings (stored over defaults). Guarantees * every documented key exists with a sane type. * * @return array */ public static function get_all() { $stored = get_option( self::OPTION_KEY, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } return array_merge( self::get_defaults(), $stored ); } /** * Shortcut for a single setting with type-aware default. * * @param string $key * @param mixed $fallback * @return mixed */ public static function get( $key, $fallback = null ) { $all = self::get_all(); return array_key_exists( $key, $all ) ? $all[ $key ] : $fallback; } public function __construct() { add_action( 'admin_menu', array( $this, 'register_menu' ) ); add_action( 'admin_init', array( $this, 'maybe_save' ) ); add_action( 'admin_init', array( $this, 'maybe_flush_rewrites' ) ); } // ------------------------------------ // Menu mounting (Aum ecosystem pattern) // ------------------------------------ /** * Whether the AumCreate theme (parent or child) is active. Mirrors the * detection used by AumViso and AumLang so the suite mounts consistently. * * @return bool */ private function is_aumcreate_theme() { $theme = wp_get_theme(); return 'aumcreate' === $theme->get_template() || false !== stripos( (string) $theme->get( 'Name' ), 'aumcreate' ); } /** * Registers the settings page outside the WP "Settings" menu: * under the AumCreate theme menu when active, otherwise under the * AumNexCart product CPT's own top-level menu. */ public function register_menu() { if ( $this->is_aumcreate_theme() ) { /* * Under the AumCreate theme menu the item is labelled "NexCart" (a feature-area * name, like AumViso's "SEO/GEO") — the product catalog itself lives in the * separate "AumNexCart" CPT menu. * * 🔴 The parent slug is asked for, not hard-coded. AumCreate 2.11.0 moved its * admin pages under Appearance, because wordpress.org does not allow a theme to * create a top-level menu — so `aumcreate-settings` stopped being a top-level * menu. `add_submenu_page()` still returns a normal hook suffix in that case and * the entry still lands in `$submenu['aumcreate-settings']`, but that array has * no menu to be rendered under: **this page disappears from the admin with no * error of any kind.** Measured on 2026-09-30 — 83 menu items, "NexCart" not one * of them, while the plugin reported itself as working. * * `function_exists()` keeps this working on older versions of the theme, where * the function does not exist and the old slug is still the right answer. */ $parent = function_exists( 'aumcreate_admin_parent_slug' ) ? aumcreate_admin_parent_slug() : 'aumcreate-settings'; $this->hook = (string) add_submenu_page( $parent, __( 'AumNexCart', 'aumnexcart' ), __( 'NexCart', 'aumnexcart' ), 'manage_options', self::PAGE_SLUG, array( $this, 'render_page' ) ); return; } $this->hook = (string) add_submenu_page( 'edit.php?post_type=' . AumNexCart_Post_Type::POST_TYPE, __( 'AumNexCart Settings', 'aumnexcart' ), __( 'Settings', 'aumnexcart' ), 'manage_options', self::PAGE_SLUG, array( $this, 'render_page' ) ); } /** * Processes the settings form submission. Validates the nonce * and capability, then sanitizes each field according to its * declared type before persisting. */ public function maybe_save() { if ( empty( $_POST['aumnexcart_settings_submit'] ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } check_admin_referer( self::NONCE_ACTION ); $raw = isset( $_POST['aumnexcart'] ) && is_array( $_POST['aumnexcart'] ) ? map_deep( wp_unslash( $_POST['aumnexcart'] ), 'sanitize_textarea_field' ) : array(); $current = self::get_all(); $active = isset( $_POST['active_tab'] ) ? sanitize_key( wp_unslash( $_POST['active_tab'] ) ) : 'general'; $old_product_slug = $current['product_slug']; $old_category_slug = $current['category_slug']; switch ( $active ) { case 'general': $current['currency_symbol'] = isset( $raw['currency_symbol'] ) ? sanitize_text_field( $raw['currency_symbol'] ) : '$'; $current['currency_position'] = isset( $raw['currency_position'] ) && 'after' === $raw['currency_position'] ? 'after' : 'before'; $current['product_slug'] = isset( $raw['product_slug'] ) ? sanitize_title( $raw['product_slug'] ) : 'products'; $current['label_singular'] = isset( $raw['label_singular'] ) ? sanitize_text_field( $raw['label_singular'] ) : ''; $current['label_plural'] = isset( $raw['label_plural'] ) ? sanitize_text_field( $raw['label_plural'] ) : ''; $current['category_label_singular'] = isset( $raw['category_label_singular'] ) ? sanitize_text_field( $raw['category_label_singular'] ) : ''; $current['category_label_plural'] = isset( $raw['category_label_plural'] ) ? sanitize_text_field( $raw['category_label_plural'] ) : ''; $current['category_slug'] = isset( $raw['category_slug'] ) ? sanitize_title( $raw['category_slug'] ) : 'nexcart-category'; $current['products_per_page'] = isset( $raw['products_per_page'] ) ? max( 1, absint( $raw['products_per_page'] ) ) : 12; break; case 'display': $current['archive_columns'] = isset( $raw['archive_columns'] ) && in_array( (int) $raw['archive_columns'], array( 2, 3, 4 ), true ) ? (int) $raw['archive_columns'] : 3; $current['show_price'] = empty( $raw['show_price'] ) ? 0 : 1; $current['show_category_badge'] = empty( $raw['show_category_badge'] ) ? 0 : 1; $current['disable_css'] = empty( $raw['disable_css'] ) ? 0 : 1; break; case 'filter': $current['enable_category_filter'] = empty( $raw['enable_category_filter'] ) ? 0 : 1; $current['enable_price_filter'] = empty( $raw['enable_price_filter'] ) ? 0 : 1; $current['enable_search'] = empty( $raw['enable_search'] ) ? 0 : 1; $current['enable_sort'] = empty( $raw['enable_sort'] ) ? 0 : 1; $current['filter_mode'] = isset( $raw['filter_mode'] ) && 'ajax' === $raw['filter_mode'] ? 'ajax' : 'url'; $keys_raw = isset( $raw['filterable_attributes'] ) ? (string) $raw['filterable_attributes'] : ''; $list = array_filter( array_map( 'trim', preg_split( '/\r\n|\r|\n/', $keys_raw ) ) ); $current['filterable_attributes'] = array_values( array_map( 'sanitize_text_field', $list ) ); break; case 'contact': $rows = isset( $raw['default_contact'] ) && is_array( $raw['default_contact'] ) ? $raw['default_contact'] : array(); $clean = array(); foreach ( $rows as $row ) { $item = AumNexCart_Meta::normalize_contact_row( $row, true ); if ( $item ) { unset( $item['href'] ); $clean[] = $item; } } $current['default_contact'] = $clean; break; case 'enquiry': $current['enquiry_page'] = isset( $raw['enquiry_page'] ) ? absint( $raw['enquiry_page'] ) : 0; $current['enquiry_label'] = isset( $raw['enquiry_label'] ) ? sanitize_text_field( $raw['enquiry_label'] ) : ''; $current['enquiry_added_label'] = isset( $raw['enquiry_added_label'] ) ? sanitize_text_field( $raw['enquiry_added_label'] ) : ''; $field = isset( $raw['enquiry_field'] ) ? sanitize_key( $raw['enquiry_field'] ) : ''; $current['enquiry_field'] = '' !== $field ? $field : 'enquiry_items'; break; case 'advanced': break; } update_option( self::OPTION_KEY, $current ); if ( $current['product_slug'] !== $old_product_slug || $current['category_slug'] !== $old_category_slug ) { set_transient( self::FLUSH_FLAG, 1, MINUTE_IN_SECONDS ); } add_settings_error( self::PAGE_SLUG, 'aumnexcart_saved', __( 'Settings saved.', 'aumnexcart' ), 'updated' ); wp_safe_redirect( self::page_url( $active, array( 'updated' => 1 ) ) ); exit; } /** * If the permalink slugs changed on the previous request, flush * rewrite rules once now that the CPT has been re-registered * with the new slugs. */ public function maybe_flush_rewrites() { if ( get_transient( self::FLUSH_FLAG ) ) { flush_rewrite_rules(); delete_transient( self::FLUSH_FLAG ); } } // ------------------------------------ // Tabs + URL helpers // ------------------------------------ /** key => [ label, dashicon, savable ]. */ private function tabs() { return array( 'general' => array( 'label' => __( 'General', 'aumnexcart' ), 'icon' => 'dashicons-admin-generic', 'savable' => true ), 'display' => array( 'label' => __( 'Display', 'aumnexcart' ), 'icon' => 'dashicons-layout', 'savable' => true ), 'filter' => array( 'label' => __( 'Filter', 'aumnexcart' ), 'icon' => 'dashicons-filter', 'savable' => true ), 'contact' => array( 'label' => __( 'Contact', 'aumnexcart' ), 'icon' => 'dashicons-email-alt', 'savable' => true ), 'enquiry' => array( 'label' => __( 'Enquiry', 'aumnexcart' ), 'icon' => 'dashicons-clipboard', 'savable' => true ), 'tools' => array( 'label' => __( 'Tools', 'aumnexcart' ), 'icon' => 'dashicons-admin-tools', 'savable' => false ), 'migration' => array( 'label' => __( 'Migration', 'aumnexcart' ), 'icon' => 'dashicons-migrate', 'savable' => false ), 'advanced' => array( 'label' => __( 'Advanced', 'aumnexcart' ), 'icon' => 'dashicons-admin-settings', 'savable' => true ), ); } /** * Base/working URL of this settings page, parent-agnostic (works whether * mounted under the AumCreate theme menu or the product CPT menu). Public * + static so the Tools / Migration subsystems can build redirect URLs to * their own tabs without knowing where the page is mounted. * * @param string $tab Optional tab key to append. * @param array $extra Optional extra query args. * @return string */ public static function page_url( $tab = '', $extra = array() ) { /* * menu_page_url() runs its result through esc_url() before returning it, so the separators come * back as `&`. Handing that to add_query_arg() corrupts the URL: it reads `#038;page=…` as a * fragment, lifts it off the end, and the rebuilt link has lost `page=` altogether — which is why * every settings tab link and every post-save redirect landed on the product list instead of this * screen. Decode it back to a plain URL here; the callers escape at output (the tab links use * esc_url(), and wp_safe_redirect() must be given an unescaped URL). */ $base = wp_specialchars_decode( menu_page_url( self::PAGE_SLUG, false ) ); if ( ! $base ) { // Fallback before the menu is fully registered. $base = admin_url( 'admin.php?page=' . self::PAGE_SLUG ); } $args = array(); if ( '' !== $tab ) { $args['tab'] = $tab; } if ( ! empty( $extra ) ) { $args = array_merge( $args, $extra ); } return ! empty( $args ) ? add_query_arg( $args, $base ) : $base; } // ------------------------------------ // Render // ------------------------------------ /** * Renders the settings page HTML using the shared .aml-app shell. */ public function render_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $tabs = $this->tabs(); $active = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'general'; if ( ! isset( $tabs[ $active ] ) ) { $active = 'general'; } /* * There is no licence gate here, and there is no licence anywhere in this plugin. * * It used to lock an unlicensed site onto a "License" tab. That went in 2026-08-26 (the plugin is * part of a suite, never sold on its own), leaving only a read of the theme's licence code for the * self-hosted update check — and that went too when the plugin moved to wordpress.org, where * updates come from .org and there is nothing to authorise. */ $settings = self::get_all(); $is_savable = ! empty( $tabs[ $active ]['savable'] ); ?>

v


'; wp_nonce_field( self::NONCE_ACTION ); echo ''; } switch ( $active ) { case 'general': $this->render_general_tab( $settings ); break; case 'display': $this->render_display_tab( $settings ); break; case 'filter': $this->render_filter_tab( $settings ); break; case 'contact': $this->render_contact_tab( $settings ); break; case 'enquiry': $this->render_enquiry_tab( $settings ); break; case 'tools': AumNexCart_Tools::render_tab(); break; case 'migration': AumNexCart_Migration::render_tab(); break; case 'advanced': $this->render_advanced_tab(); break; } if ( $is_savable ) { echo '

'; echo ''; } ?>

switch_row( 'aumnexcart[show_price]', $s['show_price'], __( 'Display price on archive cards and product page', 'aumnexcart' ) ); $this->switch_row( 'aumnexcart[show_category_badge]', $s['show_category_badge'], __( 'Display the category label above product titles', 'aumnexcart' ) ); $this->switch_row( 'aumnexcart[disable_css]', $s['disable_css'], __( 'Disable plugin frontend stylesheet (use your theme styles instead)', 'aumnexcart' ) ); ?>

switch_row( 'aumnexcart[enable_category_filter]', $s['enable_category_filter'], __( 'Show category filter on archive page', 'aumnexcart' ) ); $this->switch_row( 'aumnexcart[enable_price_filter]', $s['enable_price_filter'], __( 'Show price filter (only applies to range-mode products)', 'aumnexcart' ) ); $this->switch_row( 'aumnexcart[enable_search]', $s['enable_search'], __( 'Show keyword search box', 'aumnexcart' ) ); $this->switch_row( 'aumnexcart[enable_sort]', $s['enable_sort'], __( 'Show sort dropdown (newest, name, price)', 'aumnexcart' ) ); ?>

' . esc_html__( 'No default actions yet.', 'aumnexcart' ) . '

'; } else { foreach ( $rows as $i => $row ) { $this->render_contact_row( $i, $row ); } } ?>

'aumnexcart[enquiry_page]', 'id' => 'aum-nx-enquiry-page', /* * wp_dropdown_pages() echoes by default, so everything handed to it is output: * the label goes into an