label, in the order the customer sees it. Plain * literals, not translation calls: they are what the settings store, and the * merchant edits them as rows. A row still holding one of them is shown in * the reader's language (builtin_labels(), display_label()). */ const DEFAULT_LABELS = array( 'damaged' => 'The item arrived damaged or faulty', 'wrong_item' => 'I received the wrong item', 'size' => 'The size does not fit', 'not_liked' => 'I do not like it', 'changed_mind' => 'I changed my mind', 'other' => 'Other', ); /** * The built-in labels in the reader's language, key => label (step 6, * translations). The settings keep DEFAULT_LABELS, the English; these are * what a screen, the portal and an email show for a built-in reason the shop * left as it was (display_label()). * * @return array */ public static function builtin_labels() { return array( 'damaged' => _x( 'The item arrived damaged or faulty', 'return reason', 'careback' ), 'wrong_item' => _x( 'I received the wrong item', 'return reason', 'careback' ), 'size' => _x( 'The size does not fit', 'return reason', 'careback' ), 'not_liked' => _x( 'I do not like it', 'return reason', 'careback' ), 'changed_mind' => _x( 'I changed my mind', 'return reason', 'careback' ), 'other' => _x( 'Other', 'return reason', 'careback' ), ); } /** * The label a reader sees for a stored row: a built-in reason whose stored * label is still the built-in English one (DEFAULT_LABELS) in the reader's * language; any other label — the shop's own words, a reason it added — as * written. * * @param string $key Reason key. * @param string $label Label as stored. * @return string */ public static function display_label( $key, $label ) { $key = (string) $key; $label = (string) $label; if ( isset( self::DEFAULT_LABELS[ $key ] ) && self::DEFAULT_LABELS[ $key ] === $label ) { $names = self::builtin_labels(); return $names[ $key ]; } return $label; } /** * The built-in list as key => label. * * @return array */ public static function defaults() { return self::DEFAULT_LABELS; } /** * The built-in list as editable rows, carrying the built-in keys. * * @param array $labels Replacement label per built-in key. A * key not listed, or listed blank, keeps * the built-in label. * @return array */ public static function default_rows( array $labels = array() ) { $rows = array(); foreach ( self::DEFAULT_LABELS as $key => $label ) { $given = isset( $labels[ $key ] ) ? trim( (string) $labels[ $key ] ) : ''; $rows[] = array( 'key' => $key, 'label' => '' !== $given ? $given : $label, 'fault' => in_array( $key, self::DEFAULT_FAULT_KEYS, true ), ); } return $rows; } /** * Text keys the built-in labels were editable under before 0.12.0 * (`reason_`). * * @return string[] */ public static function legacy_text_keys() { $keys = array(); foreach ( array_keys( self::DEFAULT_LABELS ) as $key ) { $keys[] = 'reason_' . $key; } return $keys; } /** * Built-in key => label as it was typed into the `reason_` text fields. * A field left blank is not listed. * * @param mixed $texts The stored `texts` setting. * @return array */ public static function labels_from_texts( $texts ) { $out = array(); if ( ! is_array( $texts ) ) { return $out; } foreach ( array_keys( self::DEFAULT_LABELS ) as $key ) { $value = isset( $texts[ 'reason_' . $key ] ) ? trim( (string) $texts[ 'reason_' . $key ] ) : ''; if ( '' !== $value ) { $out[ $key ] = $value; } } return $out; } /** * Filter: what the installation of the reason list writes into the settings * ( array $update, array $rows, Settings $settings ). See install(). */ const INSTALL_FILTER = 'careback_reasons_install'; /** * Writes the built-in entries into the merchant's own list while that list is * empty. * * Runs on activation and on the upgrade to schema 0.12.0. Before 0.12.0 the * built-in labels were edited as texts (`reason_`); those texts belong to * the shop's custom wording, so carrying a label typed there into the list, * and dropping the dead fields, is left to the add-on that keeps the texts * (`careback_reasons_install`). A call with a list already in place, and * nothing an add-on adds, writes nothing. * * @return bool True when the settings were written. */ public static function install( Settings $settings ) { $raw = $settings->get( 'reasons', '' ); $rows = is_array( $raw ) ? self::from_array( $raw ) : self::from_text( $raw ); $update = empty( $rows ) ? array( 'reasons' => self::default_rows() ) : array(); /** * Filters what the installation of the reason list writes into the * settings. See api/data.md. * * @param array $update Settings key => value; `reasons` while the list is empty. * @param array $rows The list as stored, normalised; empty when there is none. * @param Settings $settings Plugin settings. */ $update = apply_filters( self::INSTALL_FILTER, $update, $rows, $settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_reasons_install, the final prefix. if ( empty( $update ) || ! is_array( $update ) ) { return false; } // The list a shop already has is never replaced on install. if ( ! empty( $rows ) ) { unset( $update['reasons'] ); if ( empty( $update ) ) { return false; } } $settings->update( $update ); return true; } /** * Reason key => number of filed requests carrying it, read from the grouped * counts of the request table. A request with no reason is not counted. * * @param array> $counts Rows of `reason` and `total`. * @return array */ public static function usage( array $counts ) { $out = array(); foreach ( $counts as $row ) { if ( ! is_array( $row ) || ! isset( $row['reason'] ) ) { continue; } $key = (string) $row['reason']; $total = isset( $row['total'] ) ? (int) $row['total'] : 0; if ( '' === $key || $total < 1 ) { continue; } $out[ $key ] = isset( $out[ $key ] ) ? $out[ $key ] + $total : $total; } return $out; } /** * The counts of usage() limited to the rows currently on the list, keeping * the order of those rows. A row no request carries is not listed. * * @param array> $rows Editable rows. * @param array $usage Output of usage(). * @return array */ public static function usage_of_rows( array $rows, array $usage ) { $out = array(); foreach ( $rows as $row ) { $key = ( is_array( $row ) && isset( $row['key'] ) ) ? (string) $row['key'] : ''; if ( '' !== $key && isset( $usage[ $key ] ) && (int) $usage[ $key ] > 0 ) { $out[ $key ] = (int) $usage[ $key ]; } } return $out; } /** * The routes a reason is offered on, normalised. * * `null` means the row has never been through the route screen and is offered * everywhere — which is what every row written before this feature carries, * and what a row added in the reason table carries until the routes are set. * An explicit empty array means the merchant has taken it off every route: * the reason keeps its key, its history and its figures, and simply stops * being offered. * * @param mixed $routes Raw value from the stored row or the form. * @return array|null */ public static function clean_routes( $routes ) { if ( ! is_array( $routes ) ) { return null; } $out = array(); foreach ( $routes as $route ) { $route = sanitize_key( (string) $route ); if ( Routes::exists( $route ) && ! in_array( $route, $out, true ) ) { $out[] = $route; } } return $out; } /** * The routes each stored row carries, exactly as stored: key => routes, or * null for a row never configured. Unlike rows(), a route nobody registers * now is kept — it is what a save has to write back for a route of an * add-on that is switched off, so the row is still offered there once it is * on again. * * @return array|null> */ public static function stored_routes( Settings $settings ) { $raw = $settings->get( 'reasons', '' ); $out = array(); if ( ! is_array( $raw ) ) { return $out; } foreach ( $raw as $row ) { if ( ! is_array( $row ) ) { continue; } $key = self::clean_key( isset( $row['key'] ) ? $row['key'] : '' ); if ( '' === $key || array_key_exists( $key, $out ) ) { continue; } if ( ! isset( $row['routes'] ) || ! is_array( $row['routes'] ) ) { $out[ $key ] = null; continue; } $routes = array(); foreach ( $row['routes'] as $route ) { $route = sanitize_key( (string) $route ); if ( '' !== $route && ! in_array( $route, $routes, true ) ) { $routes[] = $route; } } $out[ $key ] = $routes; } return $out; } /** True when at least one reason is offered on this route. */ public static function route_has_reasons( $route, Settings $settings ) { foreach ( self::rows( $settings ) as $row ) { if ( self::offered_on( $row, $route ) ) { return true; } } return false; } /** True when this row is offered on the given route. */ public static function offered_on( array $row, $route ) { $routes = isset( $row['routes'] ) ? $row['routes'] : null; // Never configured: offered everywhere, so switching the feature on // changes nothing until the merchant actually picks. if ( ! is_array( $routes ) ) { return true; } return in_array( (string) $route, $routes, true ); } /** * Whether the reason is asked for on a route, and whether it is required. * * @return string One of 'off', 'optional', 'required'. */ public static function mode( $route, Settings $settings ) { $route = (string) $route; // A route whose mode is fixed answers with it whatever is saved. The // statutory route is: the EU rules (Article 9(1) of Directive 2011/83/EU) // let a consumer withdraw without giving any reason, so a shop cannot make // the reason a condition of filing one, and the setting is not offered. if ( Routes::property( $route, 'reason_mode_fixed' ) ) { return (string) Routes::property( $route, 'reason_mode' ); } $mode = sanitize_key( (string) $settings->get( 'reason_mode_' . $route, '' ) ); // Unsaved, each route asks the way it was registered to: optionally, // or — on the route that reports a fault — as a requirement. $mode = in_array( $mode, array( 'off', 'optional', 'required' ), true ) ? $mode : (string) Routes::property( $route, 'reason_mode' ); // A route that requires a reason and offers none is a dead end: the // dropdown holds nothing but "please choose", and the request can never be // sent. It is a saveable combination — the list is edited on one screen // and the requirement on another — so it is caught here rather than left // for a customer to discover. Only the requirement is softened: a route // told not to ask stays told not to ask. if ( 'required' === $mode && ! self::route_has_reasons( $route, $settings ) ) { return 'optional'; } return $mode; } /** A key reduced to the characters a stored reason key may hold. */ public static function clean_key( $key ) { // The hyphen is kept because `sanitize_key()` keeps it: a key derived from a // label that carries one must survive every later save unchanged. return trim( preg_replace( '/_+/', '_', preg_replace( '/[^a-z0-9_\-]/', '_', strtolower( (string) $key ) ) ), '_' ); } /** * Reads the one-label-per-line format saved before the three fields, with a * leading `*` marking a fault reason. The key of each line is derived exactly * as that format derived it. * * @return array */ public static function from_text( $raw ) { $rows = array(); $seen = array(); foreach ( preg_split( '/\r\n|\r|\n/', (string) $raw ) as $line ) { $line = trim( $line ); if ( '' === $line ) { continue; } $is_fault = 0 === strpos( $line, self::FAULT_PREFIX ); if ( $is_fault ) { $line = trim( substr( $line, strlen( self::FAULT_PREFIX ) ) ); } if ( '' === $line ) { continue; } $key = sanitize_key( $line ); if ( '' === $key ) { $key = 'r' . substr( md5( $line ), 0, 8 ); } $base = $key; $i = 2; while ( isset( $seen[ $key ] ) ) { $key = $base . '_' . $i; $i++; } $seen[ $key ] = true; $rows[] = array( 'key' => $key, 'label' => $line, 'fault' => $is_fault, ); } return $rows; } /** * Rows saved in the three-field format, dropped when they carry no label and * de-duplicated by key. A row without a key takes one derived from its label. * * @return array */ public static function from_array( array $raw ) { $rows = array(); $seen = array(); foreach ( $raw as $row ) { if ( ! is_array( $row ) ) { continue; } $label = trim( (string) ( isset( $row['label'] ) ? $row['label'] : '' ) ); if ( '' === $label ) { continue; } $key = self::clean_key( isset( $row['key'] ) ? $row['key'] : '' ); if ( '' === $key ) { $key = sanitize_key( $label ); } if ( '' === $key ) { $key = 'r' . substr( md5( $label ), 0, 8 ); } $base = $key; $i = 2; while ( isset( $seen[ $key ] ) ) { $key = $base . '_' . $i; $i++; } $seen[ $key ] = true; $rows[] = array( 'key' => $key, 'label' => $label, 'fault' => ! empty( $row['fault'] ), 'routes' => self::clean_routes( isset( $row['routes'] ) ? $row['routes'] : null ), ); } return $rows; } /** * Posted rows in the order their position field asks for, lowest first. The * posted order is kept while no row carries a position; a row whose position * is missing or blank follows the numbered ones in the order it was posted. * * @param array $posted One entry per row. * @return array */ public static function sort_posted( array $posted ) { $numbered = false; foreach ( $posted as $row ) { if ( is_array( $row ) && isset( $row['position'] ) && '' !== trim( (string) $row['position'] ) ) { $numbered = true; break; } } if ( ! $numbered ) { return array_values( $posted ); } $decorated = array(); $index = 0; foreach ( $posted as $row ) { $given = ( is_array( $row ) && isset( $row['position'] ) ) ? trim( (string) $row['position'] ) : ''; $decorated[] = array( 'position' => '' === $given ? PHP_INT_MAX : (int) $given, 'index' => $index, 'row' => $row, ); $index++; } usort( $decorated, static function ( $a, $b ) { if ( $a['position'] === $b['position'] ) { return $a['index'] - $b['index']; } return $a['position'] < $b['position'] ? -1 : 1; } ); return array_column( $decorated, 'row' ); } /** * Rows as they come back from the settings screen, in the order the position * fields ask for. A row whose stored key is already in the list keeps that * key whatever the key field carries. * * @param array $posted One entry per row: stored, key, label, fault, position. * @return array */ public static function from_posted( array $posted, Settings $settings, &$moves = array() ) { $posted = self::sort_posted( $posted ); $locked = array(); $moves = array(); foreach ( self::rows( $settings ) as $row ) { $locked[ $row['key'] ] = true; } $rows = array(); $retired = array(); foreach ( $posted as $row ) { if ( ! is_array( $row ) ) { continue; } $stored = self::clean_key( isset( $row['stored'] ) ? $row['stored'] : '' ); $key = isset( $locked[ $stored ] ) ? $stored : self::clean_key( isset( $row['key'] ) ? $row['key'] : '' ); $label = sanitize_text_field( isset( $row['label'] ) ? $row['label'] : '' ); // The editor shows a built-in reason in the reader's language // (display_label()); sent back unchanged, it is stored as the built-in // English label again, so it keeps following each reader's language. $names = self::builtin_labels(); if ( isset( $names[ $key ] ) && $names[ $key ] === $label ) { $label = self::DEFAULT_LABELS[ $key ]; } $rows[] = array( 'key' => $key, 'label' => $label, 'fault' => empty( $row['fault'] ) ? 0 : 1, 'routes' => isset( $row['routes'] ) ? $row['routes'] : null, ); if ( ! empty( $row['remove'] ) && '' !== $key ) { $retired[ $key ] = self::clean_key( isset( $row['move_to'] ) ? $row['move_to'] : '' ); } } $rows = self::from_array( $rows ); // A reason still on the list after this save is the only thing requests // may be moved onto: a row going away in the same save would take them // with it. Rows marked for retirement are therefore not candidates. $staying = array(); foreach ( $rows as $row ) { if ( ! isset( $retired[ $row['key'] ] ) ) { $staying[ $row['key'] ] = true; } } $out = array(); foreach ( $rows as $row ) { $key = $row['key']; // Retired with somewhere for its requests to go: the row leaves and the // move is reported. Without a valid target the row stays, because // dropping the key is what leaves a request with an unreadable reason. if ( isset( $retired[ $key ] ) && '' !== $retired[ $key ] && isset( $staying[ $retired[ $key ] ] ) ) { $moves[ $key ] = $retired[ $key ]; continue; } $out[] = $row; } return $out; } /** * The merchant's list as editable rows, in stored order. Reads either format * of the stored setting and falls back to the built-in rows while it is * empty. * * @return array */ public static function rows( Settings $settings ) { $raw = $settings->get( 'reasons', '' ); $rows = is_array( $raw ) ? self::from_array( $raw ) : self::from_text( $raw ); return ! empty( $rows ) ? $rows : self::default_rows(); } /** * The list as key => label, in the order the customer sees it. * * @return array */ public static function all( Settings $settings, $route = '' ) { $list = array(); foreach ( self::rows( $settings ) as $row ) { // An empty route asks for the whole list, which is what every reader // outside the portal wants: a request keeps the reason it was filed // under, and a screen that renders one must find its label whatever // the route offers today. if ( '' !== (string) $route && ! self::offered_on( $row, $route ) ) { continue; } $list[ $row['key'] ] = self::display_label( $row['key'], $row['label'] ); } return apply_filters( 'careback_reasons', $list, $route ); } /** * The list each route offers, as route => key => label. Handed to the portal * so the dropdown can be rebuilt when the customer changes route without * another request. * * @return array> */ public static function by_route( Settings $settings ) { $out = array(); foreach ( Routes::keys() as $route ) { $out[ $route ] = self::all( $settings, $route ); } return $out; } /** * Keys of the reasons marked as blaming the shop. * * @return array */ public static function fault_keys( Settings $settings ) { $keys = array(); foreach ( self::rows( $settings ) as $row ) { if ( ! empty( $row['fault'] ) ) { $keys[] = $row['key']; } } return array_values( array_unique( (array) apply_filters( 'careback_fault_reasons', $keys ) ) ); } /** True when this reason key is marked as a fault. Empty is never a fault. */ public static function is_fault( $reason, Settings $settings ) { $reason = (string) $reason; return '' !== $reason && in_array( $reason, self::fault_keys( $settings ), true ); } }