page ( array $pages ). */ const PAGES_FILTER = 'careback_admin_pages'; /** The menu the screen is an entry of. */ const PARENT = 'woocommerce'; /** Page slug of the entry and of its first tab, Requests. */ const PAGE = 'careback'; /** Filter: the groups ("tabs") of the settings screen, key => group ( array $tabs ). */ const TABS_FILTER = 'careback_settings_tabs'; /** Filter: the sections of the settings screen, key => section ( array $sections ). */ const SECTIONS_FILTER = 'careback_settings_sections'; /** Filter: the rows of one section's table ( array $fields, string $section ). */ const FIELDS_FILTER = 'careback_settings_fields'; /** * Filter: the values a settings form stores ( array $values, string $screen, * Settings $settings, array $posted ) — `$screen` is SCREEN or * Track_Admin::SCREEN; `$posted` is the posted form (unslashed, not yet * sanitised), read once by the form's handler after its capability and * nonce checks (since 0.84.2): a section reads its fields from it, never * from the request itself. */ const SAVE_FILTER = 'careback_save_settings'; /** * Filter: the fields of a settings form that are stored as HTML, as paths * ("field", "field/key" or "field/key/key"); posted_form() reads each of * them through wp_kses_post() instead of as plain text. An add-on lists the * HTML fields of the sections it adds. */ const HTML_FIELDS_FILTER = 'careback_settings_html_fields'; /** * Id of this screen — the returns half of the settings screen — in the * settings hooks: the section and row callables and `careback_save_settings` * are handed it, the tracking half hands Track_Admin::SCREEN. */ const SCREEN = 'return'; private $settings; private $requests; private $setup; private $events; private $requests_repository; /** Reason key => filed requests, read once per render of the settings screen. */ private $reason_usage = null; /** * Hook name of each tab's page (as WordPress names it) => its slug, filled by menu(). * * @var array */ private $page_hooks = array(); /** * @param Settings $settings The shared settings. * @param Admin_Requests $requests The Requests screen. * @param Setup $setup The Setup screen. * @param Events_Repository|null $events The event log. * @param mixed $unused No longer read; kept so callers passing the request table sixth still reach it. * @param Requests_Repository|null $requests_repository The requests table. */ public function __construct( Settings $settings, Admin_Requests $requests, Setup $setup, ?Events_Repository $events = null, $unused = null, ?Requests_Repository $requests_repository = null ) { unset( $unused ); $this->events = $events instanceof Events_Repository ? $events : new Events_Repository(); $this->requests_repository = $requests_repository instanceof Requests_Repository ? $requests_repository : new Requests_Repository(); $this->settings = $settings; $this->requests = $requests; $this->setup = $setup; } public function init() { add_action( 'admin_menu', array( $this, 'menu' ) ); add_action( 'admin_head', array( $this, 'hide_tab_entries' ) ); add_filter( 'submenu_file', array( $this, 'menu_current' ), 10, 2 ); add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) ); add_action( 'admin_post_careback_save_settings', array( $this, 'save' ) ); add_action( 'admin_notices', array( $this, 'dependency_notice' ) ); add_action( 'admin_notices', array( $this, 'index_gap_notice' ) ); add_filter( 'plugin_action_links_' . plugin_basename( CAREBACK_FILE ), array( $this, 'action_links' ) ); } /** * The files of the screen, on its tabs only: the shared stylesheet and * script (heading, tabs, help icons) on every tab, and each of Careback's * tabs its own — the request list's, Setup's, and on Settings its * stylesheet and the reason editor. Scripts run in the footer, after the * markup they read has been printed. * * @param string $hook Hook name of the screen. */ public function assets( $hook ) { $tab = $this->tab_of( (string) $hook ); if ( '' === $tab ) { return; } wp_enqueue_style( 'careback-admin', CAREBACK_URL . 'assets/css/admin.css', array( 'dashicons' ), CAREBACK_VERSION ); wp_enqueue_script( 'careback-admin', CAREBACK_URL . 'assets/js/admin.js', array(), CAREBACK_VERSION, true ); if ( self::PAGE === $tab ) { wp_enqueue_style( 'careback-admin-requests', CAREBACK_URL . 'assets/css/admin-requests.css', array( 'careback-admin' ), CAREBACK_VERSION ); wp_enqueue_script( 'careback-admin-requests', CAREBACK_URL . 'assets/js/admin-requests.js', array(), CAREBACK_VERSION, true ); } elseif ( 'careback-setup' === $tab ) { wp_enqueue_style( 'careback-admin-setup', CAREBACK_URL . 'assets/css/admin-setup.css', array( 'careback-admin' ), CAREBACK_VERSION ); // The Copy buttons, and the box "I have added the link" saved as it is ticked. wp_enqueue_script( 'careback-admin-setup', CAREBACK_URL . 'assets/js/admin-setup.js', array(), CAREBACK_VERSION, true ); } elseif ( 'careback-settings' === $tab ) { wp_enqueue_style( 'careback-admin-settings', CAREBACK_URL . 'assets/css/admin-settings.css', array( 'careback-admin' ), CAREBACK_VERSION ); // The confirmation under the switch of statutory withdrawal, the example // of when the periods start, the warning of the box that deletes all data. wp_enqueue_script( 'careback-admin-settings', CAREBACK_URL . 'assets/js/admin-settings.js', array(), CAREBACK_VERSION, true ); wp_enqueue_script( 'careback-reasons', CAREBACK_URL . 'assets/js/admin-reasons.js', array( 'jquery', 'jquery-ui-sortable' ), CAREBACK_VERSION, true ); } } /** * The tab a screen is, by its hook name ("_page_"), or '' for a * screen that is not one of Careback's. The menu part is not read: it is * "woocommerce", and was the plugin's own menu before. * * @param string $hook Hook name or screen id. * @return string Page slug of the tab. */ private function tab_of( $hook ) { if ( 1 !== preg_match( '/_page_([a-z0-9_-]+)$/', (string) $hook, $match ) ) { return ''; } return in_array( $match[1], array_keys( $this->screen_tabs() ), true ) ? $match[1] : ''; } /** * A help icon with a plain-language tooltip. The audience is site builders, * not developers, so every tip explains what the field is for in one line. * Drawn by Admin_Tip::render(), the one implementation every screen and an * add-on's section use. * * @param string $text The explanation. */ private static function tip( $text ) { Admin_Tip::render( $text ); } /** * Reason key => number of filed requests carrying it, for the rows currently * on the list. Read once and kept for the rest of the render. * * @return array */ private function reason_usage( array $rows ) { if ( null === $this->reason_usage ) { $this->reason_usage = Reasons::usage_of_rows( $rows, Reasons::usage( (array) $this->requests_repository->reason_counts() ) ); } return $this->reason_usage; } /** * Warning printed above the reason editor, naming how many of the listed * reasons filed requests already carry and how many requests that is. * Printed only while at least one listed reason has been filed under. */ private function reason_usage_notice() { $usage = $this->reason_usage( Reasons::rows( $this->settings ) ); if ( empty( $usage ) ) { return; } printf( '

%s

', esc_html( sprintf( /* translators: 1: how many reasons on the list are already in use, 2: how many filed requests carry them. */ __( 'Filed requests already carry some of these reasons (reasons in use: %1$d; requests filed under them: %2$d). The count is shown against each row. Rewording a reason is safe — every request keeps the reason it was filed under. A reason with requests behind it cannot simply be deleted: tick "Retire", pick the reason its requests move onto, and save. Statistics for periods that have already closed keep the figures they were counted with.', 'careback' ), count( $usage ), array_sum( $usage ) ) ) ); } /** * One posted reason mode, refused down to a value the plugin knows. Public * so the section of a route an add-on registers saves its mode the same way. * * The statutory route is never read from here — `Reasons::mode()` fixes it — * so there is no path by which a form could make a withdrawal require a * reason. * * @param string $route Route the mode belongs to. * @param array|null $posted The posted form (the fourth argument of * `careback_save_settings`); null while the * settings form is being saved reads the form * that save was handed (an add-on built for 0.84.1). * @return string 'off', 'optional' or 'required'. */ public static function reason_mode_input( $route, $posted = null ) { $posted = is_array( $posted ) ? $posted : self::$saving; $field = 'reason_mode_' . $route; $value = isset( $posted[ $field ] ) && is_scalar( $posted[ $field ] ) ? sanitize_key( $posted[ $field ] ) : ''; return in_array( $value, array( 'off', 'optional', 'required' ), true ) ? $value : 'optional'; } /** * The form save() is storing, for the duration of that save (reason_mode_input()). * * @var array */ private static $saving = array(); /** * A settings form as posted, every value sanitised as it is read: the form * a save handler hands to its sections and to `careback_save_settings`. * The capability and the nonce are checked here as well (the handler has * checked them already; a second check costs nothing and keeps the read * next to its check). * * Every value is plain text (sanitize_textarea_field(): tags stripped, line * breaks kept), except the fields stored as HTML, which are read on their * own through wp_kses_post(): the fallback contact, the body of each status * email, and what an add-on lists through `careback_settings_html_fields`. * Readers still validate each value for what it holds (a number, a key, an * allowed choice) before they store it; no raw request data leaves this * method. * * @param string $action Nonce action of the form. * @return array Field => sanitised value; empty without the capability. */ public static function posted_form( $action ) { if ( ! current_user_can( 'manage_woocommerce' ) ) { return array(); } check_admin_referer( $action ); $form = map_deep( wp_unslash( $_POST ), 'sanitize_textarea_field' ); $form = is_array( $form ) ? $form : array(); $paths = array( 'fallback_contact' ); foreach ( Statuses::keys() as $status ) { $paths[] = 'status_emails/' . $status . '/body'; } $paths = (array) apply_filters( self::HTML_FIELDS_FILTER, $paths ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_settings_html_fields, the final prefix. foreach ( $paths as $path ) { $k = array_values( array_filter( explode( '/', (string) $path ), static function ( $part ) { return '' !== $part; } ) ); if ( 1 === count( $k ) && isset( $_POST[ $k[0] ] ) && is_string( $_POST[ $k[0] ] ) ) { $form[ $k[0] ] = wp_kses_post( wp_unslash( $_POST[ $k[0] ] ) ); } elseif ( 2 === count( $k ) && isset( $_POST[ $k[0] ][ $k[1] ] ) && is_string( $_POST[ $k[0] ][ $k[1] ] ) ) { $form[ $k[0] ][ $k[1] ] = wp_kses_post( wp_unslash( $_POST[ $k[0] ][ $k[1] ] ) ); } elseif ( 3 === count( $k ) && isset( $_POST[ $k[0] ][ $k[1] ][ $k[2] ] ) && is_string( $_POST[ $k[0] ][ $k[1] ][ $k[2] ] ) ) { $form[ $k[0] ][ $k[1] ][ $k[2] ] = wp_kses_post( wp_unslash( $_POST[ $k[0] ][ $k[1] ][ $k[2] ] ) ); } } return $form; } /** * Writes the per-route ticks onto the reason rows. * * A key the route screen listed takes exactly what was ticked there, an empty * selection included. A key it did not list — a row added in the same save — * is left alone, so it keeps the "offered everywhere" default rather than * silently arriving switched off on every route. * * Two things are kept that the screen cannot show, so a save never loses * them. A row stored with routes nobody registers now — routes of an add-on * that is switched off — keeps them. And a row never configured, which is * offered everywhere, stays so while every route listed for it is ticked: * writing out the routes on screen would take it off the ones that are not. * * Each key goes through Reasons::clean_key(), each route is checked * against Routes::keys(). * * @param array $rows Rows built by Reasons::from_posted(). * @param array $posted The posted form (save()). * @return array */ private function apply_route_selection( array $rows, array $posted ) { $known = isset( $posted['reason_routes_known'] ) && is_array( $posted['reason_routes_known'] ) ? $posted['reason_routes_known'] : array(); $selected = isset( $posted['reason_routes'] ) && is_array( $posted['reason_routes'] ) ? $posted['reason_routes'] : array(); if ( empty( $known ) ) { return $rows; } $routes = Routes::keys(); $stored = Reasons::stored_routes( $this->settings ); $listed = array(); $ticked = array(); foreach ( $routes as $route ) { foreach ( (array) ( isset( $known[ $route ] ) ? $known[ $route ] : array() ) as $key ) { $listed[ Reasons::clean_key( $key ) ][] = $route; } foreach ( (array) ( isset( $selected[ $route ] ) ? $selected[ $route ] : array() ) as $key ) { $ticked[ Reasons::clean_key( $key ) ][] = $route; } } foreach ( $rows as $index => $row ) { $key = $row['key']; if ( ! isset( $listed[ $key ] ) ) { continue; } $on = isset( $ticked[ $key ] ) ? array_values( array_unique( $ticked[ $key ] ) ) : array(); $before = array_key_exists( $key, $stored ) ? $stored[ $key ] : null; if ( null === $before && array() === array_diff( array_unique( $listed[ $key ] ), $on ) ) { $rows[ $index ]['routes'] = null; continue; } $kept = is_array( $before ) ? array_values( array_diff( $before, $routes ) ) : array(); $rows[ $index ]['routes'] = array_values( array_unique( array_merge( $on, $kept ) ) ); } return $rows; } /** * The reason block of one route: whether a reason is asked for there, and * which of the shared reasons the customer picks from. Public so the section * of a route an add-on registers prints the same block. * * The reasons themselves are defined once, further down the screen. Here the * merchant only ticks where each is offered — so a key keeps one meaning, * one fault flag and one set of figures however many routes show it. * * @param string $route Route these settings belong to. * @param array $s Stored settings. * @param Settings $settings The settings the screen prints (the section callables are handed them). */ public static function render_route_reasons( $route, array $s, Settings $settings ) { $rows = Reasons::rows( $settings ); $statute = 'withdrawal' === $route; ?> label. */ private function reason_row( $index, array $row, $locked, $position = '', $used = 0, array $targets = array() ) { $used = (int) $used; ?> 0 ? 'data-careback-used="' . esc_attr( (string) $used ) . '"' : ''; ?>> pattern="[a-z0-9_]+" /> /> 0 ? esc_html( (string) $used ) : '—'; ?> 0 ) : ?> settings ); $usage = $this->reason_usage( $rows ); $targets = Reasons::all( $this->settings ); ?> $row ) : ?> reason_row( (string) $index, $row, true, (string) ( (int) $index + 1 ), isset( $usage[ $row['key'] ] ) ? $usage[ $row['key'] ] : 0, $targets ); ?>

tip( __( 'The choices the customer picks from in the "Reason" dropdown. The key is the value stored on every request and counted in the statistics — it can be set while a reason is new and is fixed afterwards, so changing the text keeps the figures together. Tick "Our fault" for a reason that blames you, such as a damaged or incorrect item: the tick is not shown to the customer, and a request filed with that reason is flagged in the request list so you can decide the return postage. "Filed under it" counts the requests already carrying the reason; such a row is retired rather than removed, by ticking "Retire" and naming the reason its requests move onto. Removing every row and saving restores the built-in list.', 'careback' ) ); ?>

title, tab title (`menu_title`, * the title when not given), capability, callback, position. Setup and * Settings are this plugin's own; an add-on adds a tab of its own through * `careback_admin_pages`. Requests is the menu entry itself and is not on the * list. * * A tab's callback prints its body: render_page() prints the frame around it * — the .wrap, the heading and the tabs. * * @return array */ public function pages() { $pages = array( 'careback-setup' => array( 'title' => __( 'Setup', 'careback' ), 'callback' => array( $this->setup, 'render' ), 'position' => 20, ), 'careback-settings' => array( 'title' => __( 'Settings', 'careback' ), 'callback' => array( $this, 'render' ), 'position' => 50, ), ); $pages = apply_filters( self::PAGES_FILTER, $pages ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_admin_pages, the final prefix. $out = array(); foreach ( (array) $pages as $slug => $page ) { $slug = sanitize_key( (string) $slug ); if ( '' === $slug || 'careback' === $slug || ! is_array( $page ) || empty( $page['callback'] ) || ! is_callable( $page['callback'] ) ) { continue; } $title = isset( $page['title'] ) ? (string) $page['title'] : $slug; $out[ $slug ] = array( 'title' => $title, 'menu_title' => isset( $page['menu_title'] ) ? (string) $page['menu_title'] : $title, 'capability' => isset( $page['capability'] ) ? (string) $page['capability'] : 'manage_woocommerce', 'callback' => $page['callback'], 'position' => isset( $page['position'] ) ? (int) $page['position'] : 1000, ); } return self::by_position( $out ); } /** * The tabs of the screen, in order: slug => label. Requests first — the * menu entry opens it — then pages() (Setup, Settings, then an add-on's). * * @return array */ public function screen_tabs() { $tabs = array( self::PAGE => __( 'Requests', 'careback' ) ); foreach ( $this->pages() as $slug => $page ) { $tabs[ $slug ] = wp_strip_all_tags( $page['menu_title'] ); } return $tabs; } /** * WooCommerce → Careback, with the count of new requests beside it, opens * Requests; every other tab is a page of the same menu, so its address opens * as before, and leaves the menu on `admin_head` (hide_tab_entries()). The * window title of every tab is the plugin's name. */ public function menu() { $badge = ''; $new = (int) $this->requests->new_count(); if ( $new > 0 ) { $badge = ' ' . $new . ''; } $this->page_hooks = array(); $hook = add_submenu_page( self::PARENT, __( 'Careback', 'careback' ), __( 'Careback', 'careback' ) . $badge, 'manage_woocommerce', self::PAGE, array( $this, 'render_page' ) ); if ( is_string( $hook ) ) { $this->page_hooks[ $hook ] = self::PAGE; } foreach ( $this->pages() as $slug => $page ) { $hook = add_submenu_page( self::PARENT, __( 'Careback', 'careback' ), $page['menu_title'], $page['capability'], $slug, array( $this, 'render_page' ) ); if ( is_string( $hook ) ) { $this->page_hooks[ $hook ] = $slug; } } } /** * Takes the tabs after Requests out of the menu, so WooCommerce → Careback * is the plugin's one entry. Runs on `admin_head`: WordPress has checked * access to the page by then (through the menu), and prints the menu after. */ public function hide_tab_entries() { foreach ( array_keys( $this->pages() ) as $slug ) { remove_submenu_page( self::PARENT, $slug ); } } /** * On any tab, WooCommerce → Careback is the current entry of the menu. * * @param string|null $submenu_file The entry WordPress would mark. * @param string $parent_file The menu open. * @return string|null */ public function menu_current( $submenu_file, $parent_file = '' ) { unset( $parent_file ); $page = isset( $GLOBALS['plugin_page'] ) ? (string) $GLOBALS['plugin_page'] : ''; return '' !== $page && isset( $this->screen_tabs()[ $page ] ) ? self::PAGE : $submenu_file; } /** * Prints one tab of the screen: the frame — the heading, "Careback", and the * tabs, the current one marked — and the tab's body below it. The callback * of every tab's page; the tab is the page WordPress opened. * * @param string $slug Page slug of the tab; '' (what WordPress hands a page * callback) for the page being shown. */ public function render_page( $slug = '' ) { $slug = is_string( $slug ) ? $slug : ''; if ( '' === $slug ) { $slug = isset( $this->page_hooks[ current_filter() ] ) ? $this->page_hooks[ current_filter() ] : ''; } if ( '' === $slug && isset( $GLOBALS['plugin_page'] ) ) { $slug = (string) $GLOBALS['plugin_page']; } $pages = $this->pages(); $tabs = $this->screen_tabs(); $slug = isset( $tabs[ $slug ] ) ? $slug : self::PAGE; $cap = isset( $pages[ $slug ] ) ? $pages[ $slug ]['capability'] : 'manage_woocommerce'; if ( ! current_user_can( $cap ) ) { return; } ?>


requests, 'render' ) ); ?>
$entries Key => entry carrying `position`. * @return array */ private static function by_position( array $entries ) { return Registry::by_position( $entries ); } public function action_links( $links ) { $url = admin_url( 'admin.php?page=careback-settings' ); array_unshift( $links, '' . esc_html__( 'Settings', 'careback' ) . '' ); return $links; } /** * Whether a notice about something that keeps the plugin from working may * show on the current screen: the plugin's own screens (its tabs) and the * Plugins screen, nowhere else in wp-admin. Both such notices can be * dismissed (WordPress.org guideline 11). * * @return bool */ private function notice_screen() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $id = $screen ? (string) $screen->id : ''; if ( in_array( $id, array( 'plugins', 'plugins-network' ), true ) ) { return true; } // A tab is "_page_": "woocommerce_page_careback-settings" under // WooCommerce. The menu part is not read (it was the plugin's own menu, // with the count of new requests in it, before step 6). return '' !== $this->tab_of( $id ); } public function dependency_notice() { if ( Plugin::woocommerce_active() || ! $this->notice_screen() ) { return; } echo '

' . esc_html__( 'Careback requires WooCommerce to be active.', 'careback' ) . '

'; } /** * Admin warning for an index the last upgrade could not put in place, on * the plugin's own screens and the Plugins screen (notice_screen()). * * Worth a notice of its own rather than a line in a log, because the guard * that is missing is usually the unique one on ticket identifiers — the * thing that stops one signed session filing two requests. A site can run * for months without noticing it is not there, which is the whole problem: * the upgrade said nothing and stamped the new version anyway. */ public function index_gap_notice() { if ( ! current_user_can( 'manage_woocommerce' ) || ! $this->notice_screen() ) { return; } $gaps = get_option( Database::GAPS_OPTION, array() ); if ( ! is_array( $gaps ) || ! $gaps ) { return; } echo '

' . esc_html__( 'Careback: the database upgrade finished, but one or more indexes could not be added. Until they are, the protection they provide is not in place.', 'careback' ) . '

    '; foreach ( $gaps as $message ) { echo '
  • ' . esc_html( (string) $message ) . '
  • '; } echo '
'; } /** * The groups of the settings screen — its "tabs" — key => label, intro * line and position. This plugin's own; an add-on adds one through * `careback_settings_tabs`. * * @return array */ public function tabs() { $tabs = array( 'routes' => array( 'label' => __( 'The four routes', 'careback' ), 'intro' => __( 'Everything that belongs to one route is here: how long it stays open, whether it is offered at all, which reasons the customer picks from on it, and whether a reason is asked for. Wording, emails and the shared reason list are further down — a reason is defined once and offered where you tick it.', 'careback' ), 'position' => 10, ), 'portal' => array( 'label' => __( 'Portal and form', 'careback' ), 'position' => 20, ), 'timing' => array( 'label' => __( 'Deadlines and reminders', 'careback' ), 'position' => 30, ), 'emails' => array( 'label' => __( 'Emails', 'careback' ), 'position' => 50, ), 'safeguards' => array( 'label' => __( 'Safeguards, retention and statistics', 'careback' ), 'position' => 60, ), ); $tabs = apply_filters( self::TABS_FILTER, $tabs ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_settings_tabs, the final prefix. $out = array(); foreach ( (array) $tabs as $key => $tab ) { $key = sanitize_key( (string) $key ); if ( '' === $key || ! is_array( $tab ) ) { continue; } $out[ $key ] = array( 'label' => isset( $tab['label'] ) ? (string) $tab['label'] : $key, 'intro' => isset( $tab['intro'] ) ? (string) $tab['intro'] : '', 'position' => isset( $tab['position'] ) ? (int) $tab['position'] : 1000, ); } return self::by_position( $out ); } /** * The sections of the settings screen, key => the group it stands in, its * heading, its position within the group and the callable that prints it * ( array $stored_settings, string $screen, Settings $settings ), handed * SCREEN and the screen's settings. This plugin's own; an add-on * adds one through `careback_settings_sections`. A section of a group nobody * registers, or without a callable, is left out; so is a section of the * tracking screen's group (Track_Admin::TAB), which that screen prints. * * @return array */ public function sections() { $sections = array( 'route-withdrawal' => array( 'tab' => 'routes', 'label' => __( 'Statutory withdrawal', 'careback' ), 'position' => 10, 'render' => array( $this, 'render_section_route_withdrawal' ), ), 'route-return' => array( 'tab' => 'routes', 'label' => __( 'Voluntary return', 'careback' ), 'position' => 20, 'render' => array( $this, 'render_section_route_return' ), ), 'portal' => array( 'tab' => 'portal', 'label' => __( 'Portal', 'careback' ), 'position' => 10, 'render' => array( $this, 'render_section_portal' ), ), 'fields' => array( 'tab' => 'portal', 'label' => __( 'Optional fields', 'careback' ), 'position' => 20, 'render' => array( $this, 'render_section_fields' ), ), 'reasons' => array( 'tab' => 'portal', 'label' => __( 'Reasons', 'careback' ), 'position' => 30, 'render' => array( $this, 'render_section_reasons' ), ), 'variation' => array( 'tab' => 'portal', 'label' => __( 'Item variation details', 'careback' ), 'position' => 40, 'render' => array( $this, 'render_section_variation' ), ), 'windows' => array( 'tab' => 'timing', 'label' => __( 'Notifications', 'careback' ), 'position' => 10, 'render' => array( $this, 'render_section_windows' ), ), 'emails' => array( 'tab' => 'emails', 'label' => __( 'Email templates', 'careback' ), 'position' => 10, 'render' => array( $this, 'render_section_emails' ), ), 'status-emails' => array( 'tab' => 'emails', 'label' => __( 'Status update emails', 'careback' ), 'position' => 30, 'render' => array( $this, 'render_section_status_emails' ), ), 'retention' => array( 'tab' => 'safeguards', 'label' => __( 'Retention and security', 'careback' ), 'position' => 20, 'render' => array( $this, 'render_section_retention' ), ), 'bots' => array( 'tab' => 'safeguards', 'label' => __( 'Bot protection', 'careback' ), 'position' => 40, 'render' => array( $this, 'render_section_bots' ), ), ); return array_filter( self::section_entries( $sections ), static function ( $section ) { return Track_Admin::TAB !== $section['tab']; } ); } /** * A screen's own sections passed through `careback_settings_sections`, * checked and ordered by position: key => tab, label, position, render. An * entry without a group or a callable is dropped. Each screen keeps the * groups it prints. * * @param array $sections The screen's own sections. * @return array */ public static function section_entries( array $sections ) { $sections = apply_filters( self::SECTIONS_FILTER, $sections ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_settings_sections, the final prefix. $out = array(); foreach ( (array) $sections as $key => $section ) { $key = sanitize_key( (string) $key ); if ( '' === $key || ! is_array( $section ) || empty( $section['tab'] ) || empty( $section['render'] ) || ! is_callable( $section['render'] ) ) { continue; } $out[ $key ] = array( 'tab' => sanitize_key( (string) $section['tab'] ), 'label' => isset( $section['label'] ) ? (string) $section['label'] : $key, 'position' => isset( $section['position'] ) ? (int) $section['position'] : 1000, 'render' => $section['render'], ); } return self::by_position( $out ); } /** * Grouping of the settings screen: group key => its heading, its intro line * and the sections it holds, section key => that section's heading. Built * from tabs() and sections(); a group without a section is left out. * * This is the only place the order and the wording come together. The index * at the top of the screen and the headings between the fields are both * built from it, and the keys become the `careback-grp-*` and `careback-sec-*` anchors a * link can target. Nothing here touches the stored option keys. * * @return array}> */ private function settings_map() { $map = array(); foreach ( $this->tabs() as $key => $tab ) { $map[ $key ] = array( 'label' => $tab['label'], 'intro' => $tab['intro'], 'sections' => array(), ); } foreach ( $this->sections() as $key => $section ) { if ( isset( $map[ $section['tab'] ] ) ) { $map[ $section['tab'] ]['sections'][ $key ] = $section['label']; } } return array_filter( $map, static function ( $group ) { return ! empty( $group['sections'] ); } ); } /** * Prints the rows of one section's table in position order: the rows the * section passes, and those an add-on registers for it through * `careback_settings_fields`. Each row is key => position and the callable * that prints it ( array $stored_settings, string $screen, Settings $settings ). * * @param string $section Section the table belongs to. * @param array $fields The section's own rows. * @param array $s Stored settings. */ public function render_fields( $section, array $fields, array $s ) { self::print_fields( $section, $fields, $s, self::SCREEN, $this->settings ); } /** * What render_fields() does, for any screen: the rows of one section's * table, its own and those registered through `careback_settings_fields`, * in position order, each printed by its callable ( array $stored_settings, * string $screen, Settings $settings ). * * @param string $section Section the table belongs to. * @param array $fields The section's own rows. * @param array $s Stored settings. * @param string $screen Id of the screen printing the table: SCREEN or Track_Admin::SCREEN. * @param Settings $settings The settings that screen prints. */ public static function print_fields( $section, array $fields, array $s, $screen, Settings $settings ) { $fields = apply_filters( self::FIELDS_FILTER, $fields, (string) $section ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_settings_fields, the final prefix. $rows = array(); foreach ( (array) $fields as $key => $field ) { if ( ! is_array( $field ) || empty( $field['render'] ) || ! is_callable( $field['render'] ) ) { continue; } $rows[ (string) $key ] = array( 'position' => isset( $field['position'] ) ? (int) $field['position'] : 1000, 'render' => $field['render'], ); } foreach ( self::by_position( $rows ) as $row ) { call_user_func( $row['render'], $s, (string) $screen, $settings ); } } /** * Index of the whole screen, printed above the form: every group with the * sections under it, each a link to that section's anchor. * * The links move within the page and never reload it, so an unsaved field is * never lost by jumping about, and every field stays inside the one form. */ private function render_settings_index() { ?> settings_map(); if ( ! isset( $map[ $group_key ] ) ) { return; } printf( '

%2$s %3$s

', esc_attr( $group_key ), esc_html( $map[ $group_key ]['label'] ), esc_html__( 'Section index', 'careback' ) ); } /** Heading of one section, carrying the anchor its index entry links to. */ private function section_heading( $group_key, $section_key ) { $map = $this->settings_map(); if ( ! isset( $map[ $group_key ]['sections'][ $section_key ] ) ) { return; } printf( '

%2$s

', esc_attr( $section_key ), esc_html( $map[ $group_key ]['sections'][ $section_key ] ) ); } /** * The body of the Settings tab (render_page() prints the frame): the * returns desk, or the tracking desk when asked for. */ public function render() { if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } // One tab, two desks. Settings is where somebody goes to change // something, so both live here rather than in a tab of their own — which // read as a separate, nested application. The tracking half posts to its // own handler and writes only its own keys: this screen's save() sends // every key it knows and reads each checkbox as present-or-zero, so a form // without the returns checkboxes would switch the portal off on first save. if ( Track_Admin::requested() ) { Track_Admin::render_screen( $this->settings ); return; } $s = $this->settings->all(); $sections = $this->sections(); ?>
render_settings_index(); ?>
settings_map() as $group_key => $group ) : ?> group_heading( $group_key ); ?>

section_heading( $group_key, $section_key ); ?> settings ); ?>
settings ); ?>

settings, $this->requests_repository ); ?>
render_fields( 'route-withdrawal', array( 'statutory_window_days' => array( 'position' => 10, 'render' => array( $this, 'render_field_statutory_window' ), ), ), $s ); ?> settings ); ?>
settings->get( 'statutory_window_days', 14 ); $careback_on = Routes::withdrawal_offered( $this->settings ); ?>

tip( __( 'The statutory withdrawal period that applies to your store, commonly 14 days. Used only to tag a request as a withdrawal vs. a normal return — advisory, requests are never rejected automatically on time.', 'careback' ) ); ?>

settings ) ) { esc_html_e( 'Your own goodwill offer, on your own terms, listed once the statutory window has closed.', 'careback' ); } else { esc_html_e( 'Your own return offer, on your own terms, open from delivery until the end of the return window.', 'careback' ); } ?>

render_fields( 'route-return', array(), $s ); ?> settings ); ?> render_fields( 'portal', array(), $s ); ?>

reason_usage_notice(); ?> render_fields( 'variation', array(), $s ); ?>

render_fields( 'emails', array( 'ack' => array( 'position' => 10, 'render' => array( $this, 'render_field_ack_emails' ), ), 'defect_intro' => array( 'position' => 70, 'render' => array( $this, 'render_field_defect_intro' ), ), 'admin' => array( 'position' => 90, 'render' => array( $this, 'render_field_admin_emails' ), ), ), $s ); ?> tip( __( 'Subject line of the receipt email the customer gets right after sending a request.', 'careback' ) ); ?> tip( __( 'Opening line of that receipt email. The receipt time and item list are always added below it.', 'careback' ) ); ?> tip( __( 'Sign-off line of the receipt email, e.g. your thanks and contact details.', 'careback' ) ); ?> tip( __( 'Opening line when the customer reported a faulty or incorrect item. Leave blank to use the acknowledgement intro.', 'careback' ) ); ?> tip( __( 'Subject of the internal email sent to your team for every new request.', 'careback' ) ); ?> tip( /* translators: {order} {ref} {name}: placeholders, keep them exactly as written. */ __( 'Text of that internal email. Tokens {order} {ref} {name} are replaced with the real values.', 'careback' ) ); ?>

render_fields( 'bots', array( 'timing' => array( 'position' => 10, 'render' => array( $this, 'render_field_timing' ), ), 'ban' => array( 'position' => 30, 'render' => array( $this, 'render_field_ban' ), ), ), $s ); ?> tip( __( 'A signed timestamp is added when the form loads. A submit that arrives within a second or two is almost certainly a bot; a very old one is a replayed page. The portal fetches a fresh timestamp every time it loads, so a page cache does not age the form out — and a submission that is only old is answered with "reload the page", never counted as a failed attempt.', 'careback' ) ); ?>

tip( __( 'When one source (hashed) gets this many wrong order/email attempts within the throttling window, it is temporarily blocked entirely. Set 0 to disable auto-blocking.', 'careback' ) ); ?> tip( __( 'How long an auto-block lasts before the source may try again.', 'careback' ) ); ?> settings, $form ); $withdrawal_on = isset( $switch['withdrawal_enabled'] ) ? (bool) $switch['withdrawal_enabled'] : Routes::withdrawal_offered( $this->settings ); // Per-status customer emails: enabled flag + subject + HTML body. A status // nobody registers now — one an add-on that is switched off brought — // keeps what it had: the screen did not show it, so it posted nothing // that could stand for a change. $status_emails = $this->settings->get( 'status_emails', array() ); $status_emails = is_array( $status_emails ) ? array_diff_key( $status_emails, array_flip( Statuses::keys() ) ) : array(); if ( isset( $form['status_emails'] ) && is_array( $form['status_emails'] ) ) { $posted = array(); foreach ( Statuses::keys() as $st ) { $r = isset( $form['status_emails'][ $st ] ) && is_array( $form['status_emails'][ $st ] ) ? $form['status_emails'][ $st ] : array(); $posted[ $st ] = array( 'enabled' => empty( $r['enabled'] ) ? 0 : 1, 'subject' => isset( $r['subject'] ) && is_string( $r['subject'] ) ? sanitize_text_field( $r['subject'] ) : '', 'body' => isset( $r['body'] ) && is_string( $r['body'] ) ? wp_kses_post( $r['body'] ) : '', ); } $status_emails = array_merge( $posted, $status_emails ); } // Reason rows: key, customer text and fault box. Sanitised and de-duplicated // by Reasons, which also keeps the key of a row that is already in the list. $reasons = array(); if ( isset( $form['reasons'] ) && is_array( $form['reasons'] ) ) { $moves = array(); $reasons = Reasons::from_posted( $form['reasons'], $this->settings, $moves ); // Carried out before the list is stored, so a request never sits for an // instant on a key the list no longer holds. foreach ( $moves as $from => $to ) { $this->requests_repository->move_reason( $from, $to ); } $reasons = $this->apply_route_selection( $reasons, $form ); } // Every key this screen owns is written here; a section an add-on // registers adds its own through `careback_save_settings`, which hands it // this screen's id (SCREEN) and its settings. A key nobody sends is not // touched: Settings::update() lays these over what is stored. $this->settings->update( (array) apply_filters( self::SAVE_FILTER, // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_save_settings, the final prefix. array( 'portal_enabled' => isset( $_POST['portal_enabled'] ) ? 1 : 0, 'return_window_days' => isset( $_POST['return_window_days'] ) ? absint( wp_unslash( $_POST['return_window_days'] ) ) : 30, // Floored at fourteen days, the legal minimum in the EU: a shop may // widen the window, never shorten it. See Settings::FLOORS. 'statutory_window_days' => isset( $_POST['statutory_window_days'] ) ? max( $withdrawal_on ? Settings::FLOORS['statutory_window_days'] : 1, absint( wp_unslash( $_POST['statutory_window_days'] ) ) ) : 14, 'admin_recipients' => isset( $_POST['admin_recipients'] ) ? sanitize_text_field( wp_unslash( $_POST['admin_recipients'] ) ) : '', // Rendered as HTML on the portal, so allow safe markup (links, emphasis). 'fallback_contact' => isset( $_POST['fallback_contact'] ) ? wp_kses_post( wp_unslash( $_POST['fallback_contact'] ) ) : '', 'reasons' => $reasons, 'reason_mode_return' => self::reason_mode_input( 'return', $form ), 'field_reason' => isset( $_POST['field_reason'] ) ? 1 : 0, 'field_comment' => isset( $_POST['field_comment'] ) ? 1 : 0, 'field_quantity' => isset( $_POST['field_quantity'] ) ? 1 : 0, // The shop's extra item details are stored by Item_Fields (X-04). 'variation_display' => isset( $_POST['variation_display'] ) ? 1 : 0, 'email_ack_subject' => isset( $_POST['email_ack_subject'] ) ? sanitize_text_field( wp_unslash( $_POST['email_ack_subject'] ) ) : '', 'email_ack_intro' => isset( $_POST['email_ack_intro'] ) ? sanitize_textarea_field( wp_unslash( $_POST['email_ack_intro'] ) ) : '', 'email_ack_closing' => isset( $_POST['email_ack_closing'] ) ? sanitize_textarea_field( wp_unslash( $_POST['email_ack_closing'] ) ) : '', 'email_defect_intro' => isset( $_POST['email_defect_intro'] ) ? sanitize_textarea_field( wp_unslash( $_POST['email_defect_intro'] ) ) : '', 'email_admin_subject' => isset( $_POST['email_admin_subject'] ) ? sanitize_text_field( wp_unslash( $_POST['email_admin_subject'] ) ) : '', 'email_admin_body' => isset( $_POST['email_admin_body'] ) ? sanitize_textarea_field( wp_unslash( $_POST['email_admin_body'] ) ) : '', 'status_emails' => $status_emails, 'retention_extra_years' => isset( $_POST['retention_extra_years'] ) ? max( 1, absint( wp_unslash( $_POST['retention_extra_years'] ) ) ) : 3, 'retention_events_days' => isset( $_POST['retention_events_days'] ) ? max( 1, absint( wp_unslash( $_POST['retention_events_days'] ) ) ) : 90, 'lookup_max_attempts' => isset( $_POST['lookup_max_attempts'] ) ? max( 1, absint( wp_unslash( $_POST['lookup_max_attempts'] ) ) ) : 5, 'lookup_window_minutes' => isset( $_POST['lookup_window_minutes'] ) ? max( 1, absint( wp_unslash( $_POST['lookup_window_minutes'] ) ) ) : 15, 'ticket_ttl_minutes' => isset( $_POST['ticket_ttl_minutes'] ) ? max( 1, absint( wp_unslash( $_POST['ticket_ttl_minutes'] ) ) ) : 30, 'enable_time_token' => isset( $_POST['enable_time_token'] ) ? 1 : 0, 'min_submit_seconds' => isset( $_POST['min_submit_seconds'] ) ? absint( wp_unslash( $_POST['min_submit_seconds'] ) ) : 2, // Zero is allowed here and means "no ceiling": see the field tooltip. 'max_submit_seconds' => isset( $_POST['max_submit_seconds'] ) ? absint( wp_unslash( $_POST['max_submit_seconds'] ) ) : 86400, 'ban_threshold' => isset( $_POST['ban_threshold'] ) ? absint( wp_unslash( $_POST['ban_threshold'] ) ) : 10, 'ban_minutes' => isset( $_POST['ban_minutes'] ) ? max( 1, absint( wp_unslash( $_POST['ban_minutes'] ) ) ) : 60, ), self::SCREEN, $this->settings, $form ) ); self::$saving = array(); if ( $switch ) { $this->settings->update( $switch ); } $location = add_query_arg( 'updated', '1', admin_url( 'admin.php?page=careback-settings' ) ); $invalid = implode( ',', Admin_Sections::invalid_fields() ); if ( '' !== $switched ) { $location = add_query_arg( Withdrawal_Switch::RESULT_ARG, $switched, $location ); } // A field a section refused keeps its stored value and says why. if ( '' !== $invalid ) { $location = add_query_arg( Admin_Sections::INVALID_ARG, $invalid, $location ); } wp_safe_redirect( $location ); exit; } }