sections(); if ( ! isset( $sections[ $section ] ) ) { return; } if ( ! $this->save( $section, $sections ) ) { return; } wp_safe_redirect( add_query_arg( 'submex_saved', '1', self::section_url( $section ) ) ); exit; } /** * Saves the page as WooCommerce's tab does; given values save only the fields they name, as Woo blanks the rest. * * @param array $sections * @param array|null $values Unslashed, keyed by option id. */ public function save( string $section, array $sections, ?array $values = null ): bool { $saved = array(); foreach ( $this->page_sections( $section, $sections ) as $id ) { $fields = $this->settings_for( $id ); if ( null !== $values ) { $fields = array_values( array_filter( $fields, static fn( array $field ): bool => array_key_exists( (string) ( $field['id'] ?? '' ), $values ) ) ); } if ( $fields ) { \WC_Admin_Settings::save_fields( $fields, null === $values ? null : wp_slash( self::as_posted( $values ) ) ); $saved[] = $id; } } // After the whole stack is written: a section's checks may read another's settings. foreach ( array_filter( $saved, static fn( string $id ): bool => '' !== $id ) as $id ) { do_action( 'woocommerce_update_options_submex_' . $id ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce's own settings hook. } return (bool) $saved; } /** * An array-option id such as woocommerce_x_settings[enabled] nested as a browser posts it, which is where WooCommerce reads it. * * @param array $values * @return array */ private static function as_posted( array $values ): array { $posted = array(); foreach ( $values as $id => $value ) { if ( preg_match( '/^([^\[\]]+)\[([^\[\]]+)\]$/', (string) $id, $parts ) ) { $posted[ $parts[1] ][ $parts[2] ] = $value; } else { $posted[ $id ] = $value; } } return $posted; } public function render(): void { App_Host::start( self::SLUG ); $this->render_screen(); App_Host::end(); } private function render_screen(): void { $sections = $this->sections(); $current = $this->current_section( $sections ); Page_Shell::open( __( 'Settings', 'submex' ), '', array( array( 'label' => __( 'Settings', 'submex' ) ) ), '', false ); $this->render_notices(); if ( ! $sections ) { printf( '
%s
', esc_html__( "WooCommerce's settings could not be loaded, so Submex's settings cannot be shown. Check that WooCommerce is active.", 'submex' ) ); Page_Shell::close(); return; } echo '
'; $this->render_nav( $sections, $current ); echo '
'; wp_nonce_field( self::NONCE, 'submex_settings_nonce' ); printf( '', esc_attr( $current ) ); $this->render_cards( $this->page_fields( $current, $sections ) ); printf( '
%s' . '
', esc_html__( 'Changes apply to new renewals and purchases from the moment you save.', 'submex' ), esc_html__( 'Save changes', 'submex' ) ); echo '
'; Page_Shell::close(); } /** * The menu's groups, in menu order. A stacked group draws all its sections on one page; * a listed one draws one section at a time, chosen from a list under the group. * * @return array */ public static function groups(): array { $groups = array( 'general' => array( 'label' => __( 'General', 'submex' ), 'icon' => '', 'sections' => array( '', 'license' ), ), 'customers' => array( 'label' => __( 'Customer Controls', 'submex' ), 'icon' => '', 'sections' => array( 'customer_controls' ), ), 'billing' => array( 'label' => __( 'Renewal & Billing', 'submex' ), 'icon' => '', 'sections' => array( 'recovery', 'renewal' ), ), 'switching' => array( 'label' => __( 'Upgrade & Downgrade', 'submex' ), 'icon' => '', 'sections' => array( 'switching', 'upsell' ), ), 'checkout' => array( 'label' => __( 'Cart & Checkout', 'submex' ), 'icon' => '', 'sections' => array( 'checkout' ), ), 'shipping' => array( 'label' => __( 'Shipping', 'submex' ), 'icon' => '', 'sections' => array( 'live-qr' ), ), 'notifications' => array( 'label' => __( 'Email Notifications', 'submex' ), 'icon' => '', 'sections' => array( 'notifications', 'payment_methods' ), ), 'payments' => array( 'label' => __( 'Payments', 'submex' ), 'icon' => '', 'sections' => array( 'stripe', 'paypal', 'mollie', 'razorpay', 'xendit', 'square', 'authorize_net', 'braintree', 'adyen', 'gocardless', 'woopayments', 'paddle', 'bkash', 'sslcommerz' ), 'list' => true, ), self::FALLBACK_GROUP => array( 'label' => __( 'Integrations', 'submex' ), 'icon' => '', 'sections' => array( 'whatsapp', 'affiliatewp', 'anniversary', 'winback' ), 'list' => true, ), ); /** * Filter the Settings menu's groups, in menu order. * * @param array $groups */ return (array) apply_filters( 'submex_settings_groups', $groups ); } /** * Which group a section is drawn in. */ public static function group_of( string $section ): string { foreach ( self::groups() as $id => $group ) { if ( in_array( $section, $group['sections'], true ) ) { return $id; } } return self::FALLBACK_GROUP; } /** * The group's sections that exist on this site, in the group's order, then any the * group only holds as the fallback, in the order they were registered. * * @param array $sections * @return string[] */ private function group_sections( string $group, array $sections ): array { $listed = self::groups()[ $group ]['sections'] ?? array(); $found = array_values( array_filter( $listed, static fn( string $id ): bool => isset( $sections[ $id ] ) ) ); foreach ( array_keys( $sections ) as $id ) { if ( self::group_of( (string) $id ) === $group && ! in_array( (string) $id, $found, true ) ) { $found[] = (string) $id; } } return $found; } /** * The sections drawn together with this one: its whole group when stacked, else itself. * * @param array $sections * @return string[] */ public function page_sections( string $current, array $sections ): array { $group = self::group_of( $current ); return empty( self::groups()[ $group ]['list'] ) ? $this->group_sections( $group, $sections ) : array( $current ); } /** * @param array $sections * @return array>> */ public function page_fields( string $current, array $sections ): array { $fields = array(); foreach ( $this->page_sections( $current, $sections ) as $id ) { $fields[ $id ] = $this->settings_for( $id ); } return $fields; } /** * Groups with a section on this site; `list` when it shows its sections one at a time. * * @param array $sections * @return array}> */ public function menu( array $sections ): array { $menu = array(); foreach ( self::groups() as $id => $group ) { $members = $this->group_sections( $id, $sections ); // A group with nothing in it on this site is left out rather than drawn empty. if ( ! $members ) { continue; } $menu[] = array( 'id' => $id, 'label' => $group['label'], 'icon' => $group['icon'], 'list' => ! empty( $group['list'] ) && count( $members ) > 1, 'sections' => array_map( static fn( string $section ): array => array( 'id' => $section, 'title' => (string) $sections[ $section ], ), $members ), ); } return $menu; } /** * What just happened: a save of our own, or a licence action that came back from Pro. * * @param array $query Slashed, as $_GET is. * @return array */ public function notices( array $query ): array { $notices = array(); if ( isset( $query['submex_saved'] ) ) { $notices[] = array( 'type' => 'good', 'message' => __( 'Settings saved.', 'submex' ), ); } $licence = isset( $query['submex_license_notice'] ) ? sanitize_text_field( wp_unslash( (string) $query['submex_license_notice'] ) ) : ''; if ( '' === $licence ) { return $notices; } $known = array( 'activated' => __( 'Licence activated.', 'submex' ), 'deactivated' => __( 'Licence deactivated.', 'submex' ), 'ok' => __( 'Licence updated.', 'submex' ), ); $notices[] = array( 'type' => isset( $known[ $licence ] ) ? 'good' : 'bad', 'message' => $known[ $licence ] ?? $licence, ); return $notices; } private function render_notices(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- redirect flags, not actions. foreach ( $this->notices( $_GET ) as $notice ) { printf( '
%s
', esc_attr( $notice['type'] ), esc_html( $notice['message'] ) ); } } /** * @param array $sections */ private function render_nav( array $sections, string $current ): void { $active = self::group_of( $current ); echo ''; } /** * WooCommerce's settings array is a flat list where a "title" opens a group and * "sectionend" closes it. Each group becomes a card; a page of one card needs no heading, * since the menu already names it. A row PHP has to draw carries its markup. * * @param array>> $by_section * @return array, joined: array>, html: ?string}>}> */ public function cards( array $by_section ): array { $all = $by_section ? array_merge( ...array_values( $by_section ) ) : array(); $joined = $this->joined_fields( $all ); $cards = array(); foreach ( $by_section as $section => $fields ) { $card = null; $rows = array(); $first = true; foreach ( $fields as $field ) { $type = (string) ( $field['type'] ?? '' ); if ( 'title' === $type || 'sectionend' === $type ) { if ( $rows ) { $cards[] = array( $card, $rows, $first ? (string) $section : null ); $first = false; } $rows = array(); $card = 'title' === $type ? array( (string) ( $field['title'] ?? '' ), (string) ( $field['desc'] ?? '' ) ) : null; continue; } if ( isset( $field['submex_joins'] ) ) { continue; } $html = '' === (string) ( $field['id'] ?? '' ) ? $this->wide_markup( $field ) : null; // A custom row with nothing to say, such as the tax repair with nothing to repair, draws nothing. if ( '' === $html ) { continue; } $rows[] = array( 'field' => $field, 'joined' => $joined[ (string) ( $field['id'] ?? '' ) ] ?? array(), 'html' => $html, ); } if ( $rows ) { $cards[] = array( $card, $rows, $first ? (string) $section : null ); } } $titled = count( $cards ) > 1; return array_map( static fn( array $card ): array => array( 'title' => $titled ? (string) ( $card[0][0] ?? '' ) : '', 'desc' => (string) ( $card[0][1] ?? '' ), 'anchor' => $card[2], 'rows' => $card[1], ), $cards ); } /** * @param array>> $by_section */ private function render_cards( array $by_section ): void { $toggles = $this->toggle_states( $by_section ? array_merge( ...array_values( $by_section ) ) : array() ); foreach ( $this->cards( $by_section ) as $card ) { printf( '
', null === $card['anchor'] ? '' : ' id="' . esc_attr( 'submex-section-' . ( '' === $card['anchor'] ? 'general' : $card['anchor'] ) ) . '"' ); if ( '' !== $card['title'] || '' !== $card['desc'] ) { echo '
'; if ( '' !== $card['title'] ) { printf( '

%s

', esc_html( $card['title'] ) ); } if ( '' !== $card['desc'] ) { printf( '

%s

', wp_kses_post( $card['desc'] ) ); } echo '
'; } echo '
'; foreach ( $card['rows'] as $row ) { if ( null === $row['html'] ) { $this->render_row( $row['field'], $toggles, $row['joined'] ); } else { echo '
' . wp_kses( $row['html'], Allowed_Html::form() ) . '
'; } } echo '
'; } } /** * Every switch on the page and whether it is on, so a row that depends on one starts * out hidden when it is off. * * @param array> $fields * @return array */ private function toggle_states( array $fields ): array { $toggles = array(); foreach ( $fields as $field ) { if ( 'checkbox' === ( $field['type'] ?? '' ) && ! empty( $field['id'] ) ) { $toggles[ (string) $field['id'] ] = array( 'on' => 'yes' === \WC_Admin_Settings::get_option( (string) $field['id'], $field['default'] ?? '' ), 'parent' => (string) ( $field['submex_show_if'] ?? '' ), ); } } return $toggles; } /** * Fields drawn beside another field's control instead of on a row of their own, such as * a unit beside a number. * * @param array> $fields * @return array>> */ private function joined_fields( array $fields ): array { $joined = array(); foreach ( $fields as $field ) { if ( isset( $field['submex_joins'] ) ) { $joined[ (string) $field['submex_joins'] ][] = $field; } } return $joined; } /** * @param array $toggles */ private function is_shown( string $parent, array $toggles, int $depth = 0 ): bool { if ( '' === $parent || ! isset( $toggles[ $parent ] ) || $depth > 10 ) { return true; } return $toggles[ $parent ]['on'] && $this->is_shown( $toggles[ $parent ]['parent'], $toggles, $depth + 1 ); } /** * @param array $field * @param array $toggles * @param array> $joined */ private function render_row( array $field, array $toggles, array $joined ): void { $id = (string) ( $field['id'] ?? '' ); $help = $this->help_text( $field ); $parent = (string) ( $field['submex_show_if'] ?? '' ); printf( '
', $this->is_shown( $parent, $toggles ) ? '' : ' is-hidden', '' === $parent ? '' : ' data-submex-show-if="' . esc_attr( $parent ) . '"' ); printf( '
', esc_attr( $id ), esc_html( (string) ( $field['title'] ?? '' ) ) ); if ( '' !== $help ) { printf( '

%s

', esc_attr( $id . '-help' ), wp_kses_post( $help ) ); } echo '
'; $preview = Notification_Settings::preview_url( (string) ( $field['submex_email'] ?? '' ) ); if ( '' !== $preview ) { printf( '%s %s', esc_url( $preview ), esc_html__( 'Preview', 'submex' ), /* translators: %s: the email's name, such as "Renewal reminder" */ esc_html( sprintf( __( '%s (opens in a new tab)', 'submex' ), (string) ( $field['title'] ?? '' ) ) ) ); } $this->render_control( $field, '' !== $help ); foreach ( $joined as $extra ) { printf( '', esc_attr( (string) ( $extra['id'] ?? '' ) ), esc_html( (string) ( $extra['title'] ?? '' ) ) ); $this->render_control( $extra, false ); } if ( '' !== $this->suffix( $field ) ) { printf( '%s', wp_kses_post( $this->suffix( $field ) ) ); } echo '
'; } /** * @param array $field */ private function render_control( array $field, bool $described ): void { $id = (string) ( $field['id'] ?? '' ); $type = (string) ( $field['type'] ?? 'text' ); $value = (string) $this->field_value( $field ); $aria = $described ? sprintf( ' aria-describedby="%s"', esc_attr( $id . '-help' ) ) : ''; switch ( $type ) { case 'checkbox': $html = sprintf( '', esc_attr( $id ), checked( $value, 'yes', false ), $aria ); break; case 'select': $html = sprintf( ''; break; case 'textarea': $html = sprintf( '', esc_attr( $id ), $aria, $this->attributes( $field ), esc_textarea( $value ) ); break; case 'text': case 'password': case 'number': case 'email': case 'url': $html = sprintf( '', esc_attr( $type ), esc_attr( $id ), 'number' === $type ? ' submex-input--short' : ' submex-input--wide', esc_attr( $value ), $aria, $this->attributes( $field ) ); break; default: $html = $this->woo_markup( $field ); } echo wp_kses( $html, Allowed_Html::form() ); } /** * What a field holds now: a fixed value it shows, such as a webhook address, or the saved option. * * @param array $field * @return mixed */ public function field_value( array $field ) { return array_key_exists( 'value', $field ) ? $field['value'] : \WC_Admin_Settings::get_option( (string) ( $field['id'] ?? '' ), $field['default'] ?? '' ); } /** * A row with no option of its own, such as the health checks, drawn whole by PHP. * * @param array $field */ public function wide_markup( array $field ): string { if ( 'submex_status' !== ( $field['type'] ?? '' ) ) { return $this->woo_markup( $field ); } ob_start(); foreach ( Settings::status_checks() as $check ) { Settings::render_check( $check ); } return '
' . ob_get_clean() . '
'; } /** * Anything this screen has no markup for - a custom type another plugin registered - * is handed back to WooCommerce so it still renders and still saves. * * @param array $field */ public function woo_markup( array $field ): string { ob_start(); \WC_Admin_Settings::output_fields( array( $field ) ); $markup = trim( (string) ob_get_clean() ); return '' === $markup ? '' : '' . $markup . '
'; } /** * @param array $field */ public function help_text( array $field ): string { $desc = '' === $this->suffix( $field ) ? trim( (string) ( $field['desc'] ?? '' ) ) : ''; $tip = isset( $field['desc_tip'] ) && is_string( $field['desc_tip'] ) ? trim( $field['desc_tip'] ) : ''; if ( '' === $desc || '' === $tip ) { return $desc . $tip; } // WooCommerce draws these apart, so a checkbox's label rarely ends a sentence. return ( preg_match( '/[.!?:]$/u', $desc ) ? $desc : $desc . '.' ) . ' ' . $tip; } /** * A number's description is the unit WooCommerce prints after it, such as "days ahead", * unless it is shown as the tooltip. * * @param array $field */ public function suffix( array $field ): string { return 'number' === ( $field['type'] ?? '' ) && true !== ( $field['desc_tip'] ?? false ) ? trim( (string) ( $field['desc'] ?? '' ) ) : ''; } /** * @param array $field */ private function attributes( array $field ): string { $out = ''; foreach ( (array) ( $field['custom_attributes'] ?? array() ) as $name => $value ) { $out .= sprintf( ' %s="%s"', esc_attr( (string) $name ), esc_attr( (string) $value ) ); } if ( isset( $field['placeholder'] ) ) { $out .= sprintf( ' placeholder="%s"', esc_attr( (string) $field['placeholder'] ) ); } return $out; } /** * @return array */ public function sections(): array { $page = $this->woo_page(); return $page ? (array) $page->get_sections() : array(); } /** * @param array $sections */ private function current_section( array $sections ): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen routing. $asked = isset( $_GET['section'] ) ? sanitize_key( wp_unslash( $_GET['section'] ) ) : ''; return isset( $sections[ $asked ] ) ? $asked : ''; } /** * @return array> */ public function settings_for( string $section ): array { $page = $this->woo_page(); return $page ? (array) $page->get_settings_for_section( $section ) : array(); } private function woo_page(): ?\WC_Settings_Page { // No include: WooCommerce's autoloader maps wc_admin* to includes/admin/, so asking // for the class is what loads it. if ( ! class_exists( '\WC_Admin_Settings' ) ) { return null; } foreach ( \WC_Admin_Settings::get_settings_pages() as $page ) { if ( $page instanceof \WC_Settings_Page && 'submex' === $page->get_id() ) { return $page; } } return null; } /** * Any section's address, including one drawn stacked inside a group. */ public static function section_url( string $section = '' ): string { $args = array( 'page' => self::SLUG ); if ( '' !== $section ) { $args['section'] = $section; } return add_query_arg( $args, admin_url( 'admin.php' ) ); } }