Customers -> Cancellation. The * move keeps every option key byte-identical, so nothing that reads them had to * change - see Illuminate\Cancellation::get_settings() and ::get_reasons(), * which remain the only readers. * * What did have to change is the settings GROUP. wp-admin/options.php writes * `null` to every option registered in the submitted group that is absent from * the POST, so leaving these four in `wp_subscription_settings` would mean * saving the Settings page wiped them - and saving this page wiped everything * else. They are registered in their own group instead (see OPTION_GROUP), and * Pro registers its options into the same group. * * @package SpringDevs\Subscription\Admin */ namespace SpringDevs\Subscription\Admin; use SpringDevs\Subscription\Illuminate\Cancellation; /** * Cancellation Flow - admin page. */ class CancellationFlow { /** * Admin page slug. */ const SLUG = 'wp-subscription-cancellation'; /** * Settings group for every option on this page. * * Deliberately separate from `wp_subscription_settings`: options.php nulls * anything registered in a group but missing from the posted form, so two * pages must never share one group. */ const OPTION_GROUP = 'wp_subscription_cancellation_settings'; /** * The page hook returned by add_submenu_page (for the enqueue gate). * * @var string */ private $hook = ''; /** * Initialize the class. */ public function __construct() { add_action( 'admin_menu', array( $this, 'register_page' ), 11 ); add_filter( 'subscrpt_submenu_order', array( $this, 'position_submenu' ) ); add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); } /** * Register the Cancellation Flow submenu under the WPSubscription top menu. * * @return void */ public function register_page() { $this->hook = (string) add_submenu_page( 'wp-subscription', __( 'Cancellation Flow', 'subscription' ), __( 'Cancellation Flow', 'subscription' ), 'manage_options', self::SLUG, array( $this, 'render_page' ) ); } /** * Position the item within the WPSubscription submenu order. * * @param array $order Ordered slug => position map. * @return array */ public function position_submenu( $order ) { if ( is_array( $order ) ) { $order[ self::SLUG ] = 70; } return $order; } /** * Register the free options this page owns. * * Same option keys as before the move, new group. Pro registers * `subscrpt_cancellation_delay` into this same group from its own * Cancellation class. The reason list used to be Pro's too; it is free's now, * and Pro only registers it against a free version without sanitize_reasons(). * * @return void */ public function register_settings() { register_setting( self::OPTION_GROUP, 'subscrpt_cancellation_feedback_enabled', array( 'type' => 'string', 'default' => '1', 'sanitize_callback' => 'sanitize_text_field', ) ); register_setting( self::OPTION_GROUP, 'subscrpt_cancellation_feedback_comment', array( 'type' => 'string', 'default' => '1', 'sanitize_callback' => 'sanitize_text_field', ) ); register_setting( self::OPTION_GROUP, 'subscrpt_cancellation_reasons', array( 'type' => 'array', 'default' => array(), 'sanitize_callback' => array( __CLASS__, 'sanitize_reasons' ), ) ); /** * Fires so add-ons can register options into the Cancellation Flow group. * * Anything registered here is saved by this page and by no other, which * is the whole point of the separate group. * * @param string $group The settings group name. */ do_action( 'subscrpt_register_cancellation_settings', self::OPTION_GROUP ); } /** * Sanitize the cancellation reason list. * * The editor posts the ordered list as a JSON string. Each label is * sanitized, each key comes from the posted key (or the label) and is made * unique, and empty labels are dropped. * * `other` is reserved: the survey adds "Other" by itself while the comment * box is on (Cancellation::get_reasons()), so a saved one would show twice, * or show with no box to explain it in. * * Null means the list was not in the submitted form at all — the Offers tab * posts to the same settings group without rendering this field — and * options.php would save that null as an empty list. The stored list is kept. * * Pro checks for this method to know free owns the option. * * @param mixed $value JSON string, array, or null when not posted. * @return array */ public static function sanitize_reasons( $value ) { if ( null === $value ) { $stored = get_option( 'subscrpt_cancellation_reasons', array() ); return is_array( $stored ) ? $stored : array(); } // options.php has already unslashed the POST; unslashing again would // strip the backslashes JSON uses to escape a quote in a label. $decoded = is_string( $value ) ? json_decode( $value, true ) : $value; if ( ! is_array( $decoded ) ) { return array(); } $reasons = array(); $seen = array(); foreach ( $decoded as $item ) { if ( ! is_array( $item ) ) { continue; } $label = isset( $item['label'] ) ? sanitize_text_field( $item['label'] ) : ''; if ( '' === $label ) { continue; } $key = ! empty( $item['key'] ) ? sanitize_key( $item['key'] ) : sanitize_key( $label ); if ( Cancellation::OTHER_KEY === $key ) { continue; } if ( '' === $key ) { $key = 'reason'; } if ( isset( $seen[ $key ] ) ) { $key .= '_' . count( $reasons ); } $seen[ $key ] = true; $reasons[] = array( 'key' => $key, 'label' => $label, ); } return $reasons; } /** * Enqueue assets only on this page. * * Reuses the shared component bundle plus the settings stylesheet, which * already styles every field type rendered here. * * @param string $hook Current admin page hook. * @return void */ public function enqueue_assets( $hook ) { if ( ! $this->hook || $hook !== $this->hook ) { return; } wp_enqueue_style( 'subscrpt_admin_components' ); wp_enqueue_script( 'subscrpt_admin_components' ); wp_enqueue_style( 'subscrpt_admin_settings_css', SUBSCRPT_ASSETS . '/css/admin-settings.css', array( 'subscrpt_admin_components' ), SUBSCRPT_VERSION ); // Keeps the Reasons tab's list in step with the editor before saving. wp_enqueue_script( 'subscrpt_cancellation_flow_js', SUBSCRPT_ASSETS . '/js/admin/cancellation-flow.js', array( 'subscrpt_admin_components' ), SUBSCRPT_VERSION, true ); } /** * The tabs this page shows. * * One for now. Kept as a list so adding the next one is a data change. * * @return array */ public static function tabs() { return array( 'reasons' => array( 'label' => __( 'Reasons', 'subscription' ) ), 'offers' => array( 'label' => __( 'Offers', 'subscription' ) ), ); } /** * The Reasons tab field: the editable reason list. * * Free, with or without Pro: this page registers and sanitizes the option * (sanitize_reasons()), and Cancellation::get_configured_reasons() reads it. * The automatic "Other" is not part of the list, so it is not edited here. * * @return array */ public static function reasons_field() { return array( 'id' => 'subscrpt_cancellation_reasons', 'title' => __( 'Cancellation Reasons', 'subscription' ), 'description' => __( 'Reasons offered in the cancellation survey form. Shown when Cancellation Survey is enabled. "Other" is added automatically while Survey Comment Box is on.', 'subscription' ), 'value' => Cancellation::get_configured_reasons(), 'modal' => true, 'button_label' => __( 'Manage reasons', 'subscription' ), 'modal_title' => __( 'Cancellation Reasons', 'subscription' ), 'add_placeholder' => __( 'Add a reason…', 'subscription' ), 'add_label' => __( 'Add reason', 'subscription' ), 'empty_text' => __( 'No reasons yet. Add one below.', 'subscription' ), ); } /** * The Offers tab fields: one retention discount. * * Pro-locked like the rest of this page's write settings - Pro registers and * sanitizes them, and only Pro turns an accepted offer into a coupon. * * @return array */ public static function offer_fields() { $pro_locked = ! subscrpt_pro_activated(); return array( array( 'type' => 'toggle', 'field_data' => array( 'id' => 'subscrpt_cancellation_offer_enabled', 'title' => __( 'Retention Offer', 'subscription' ), 'label' => __( 'Offer a discount before cancelling', 'subscription' ), 'description' => __( 'Show the customer a one-off discount code when they start cancelling. Accepting it keeps the subscription.', 'subscription' ), 'value' => '1', 'checked' => self::offer_enabled(), 'pro_locked' => $pro_locked, ), ), array( 'type' => 'join', 'field_data' => array( 'title' => __( 'Discount', 'subscription' ), 'description' => __( 'Percentage off, or a flat amount in the store currency, applied by the generated coupon.', 'subscription' ), 'elements' => array( SettingsHelper::inp_element( array( 'id' => 'subscrpt_cancellation_offer_percent', 'value' => self::offer_percent(), 'type' => 'number', 'style' => 'min-width:170px;width:170px;' . ( 'fixed' === self::offer_type() ? 'display:none;' : '' ), 'attributes' => array( 'min' => '1', 'max' => '100', 'step' => '1', ), ), true ), SettingsHelper::inp_element( array( 'id' => 'subscrpt_cancellation_offer_amount', 'value' => wc_format_decimal( self::offer_amount(), wc_get_price_decimals() ), 'type' => 'number', 'style' => 'min-width:170px;width:170px;' . ( 'fixed' === self::offer_type() ? '' : 'display:none;' ), 'attributes' => array( 'min' => '0', 'step' => (string) ( 1 / pow( 10, wc_get_price_decimals() ) ), ), ), true ), SettingsHelper::select_element( array( 'id' => 'subscrpt_cancellation_offer_type', 'class' => 'subscrpt-offer-type', 'options' => array( 'percent' => __( 'Percentage (%)', 'subscription' ), /* translators: %s: store currency symbol. */ 'fixed' => sprintf( __( 'Flat (%s)', 'subscription' ), get_woocommerce_currency_symbol() ), ), 'selected' => self::offer_type(), ), true ), ), 'pro_locked' => $pro_locked, ), ), array( 'type' => 'input', 'field_data' => array( 'id' => 'subscrpt_cancellation_offer_days', 'title' => __( 'Valid for', 'subscription' ), 'type' => 'number', 'description' => __( 'Days before the generated coupon expires.', 'subscription' ), 'value' => self::offer_days(), 'attrs' => array( 'min' => '1', 'max' => '365', 'step' => '1', ), 'pro_locked' => $pro_locked, ), ), ); } /** * Whether a retention offer should be shown. * * Free stores and reads the settings so the UI round-trips, but only ever * answers false without Pro - Pro is what turns an accepted offer into a * coupon, so offering one free would be a promise nothing keeps. * * @return bool */ public static function offer_enabled() { if ( ! subscrpt_pro_activated() ) { return false; } $value = 'fixed' === self::offer_type() ? self::offer_amount() : self::offer_percent(); return '1' === get_option( 'subscrpt_cancellation_offer_enabled', '' ) && $value > 0; } /** * Configured discount type: `percent` or `fixed`. * * @return string */ public static function offer_type() { return 'fixed' === get_option( 'subscrpt_cancellation_offer_type', 'percent' ) ? 'fixed' : 'percent'; } /** * Configured flat discount amount, in the store currency. * * @return float */ public static function offer_amount() { return max( 0, (float) get_option( 'subscrpt_cancellation_offer_amount', 0 ) ); } /** * Configured discount percentage. * * @return int */ public static function offer_percent() { return max( 0, min( 100, (int) get_option( 'subscrpt_cancellation_offer_percent', 20 ) ) ); } /** * Configured coupon lifetime in days. * * @return int */ public static function offer_days() { return max( 1, (int) get_option( 'subscrpt_cancellation_offer_days', 7 ) ); } /** * The flow-wide options shown in the sidebar, in render order. * * @return array */ public static function sidebar_fields() { $pro_locked = ! subscrpt_pro_activated(); return array( array( 'type' => 'select', 'field_data' => array( 'id' => 'subscrpt_cancellation_delay', 'title' => __( 'Cancellation Timing', 'subscription' ), 'description' => __( 'When a subscription is cancelled, choose when it actually ends.', 'subscription' ), 'options' => array( '24h' => __( 'After 24 hours', 'subscription' ), 'instant' => __( 'Immediately', 'subscription' ), 'period' => __( 'At end of billing period (before next renewal)', 'subscription' ), ), 'selected' => esc_attr( Cancellation::get_settings( 'subscrpt_cancellation_delay' ) ), 'pro_locked' => $pro_locked, ), ), array( 'type' => 'toggle', 'field_data' => array( 'id' => 'subscrpt_cancellation_feedback_enabled', 'title' => __( 'Cancellation Survey', 'subscription' ), 'label' => __( 'Ask customers why they are cancelling', 'subscription' ), 'description' => __( 'Show a short cancellation survey when a customer cancels a subscription, and record the reason for churn tracking.', 'subscription' ), 'value' => '1', 'checked' => Cancellation::is_feedback_enabled(), ), ), array( 'type' => 'toggle', 'field_data' => array( 'id' => 'subscrpt_cancellation_feedback_comment', 'title' => __( 'Survey Comment Box', 'subscription' ), 'label' => __( 'Allow an additional comment', 'subscription' ), 'description' => __( 'Show an optional free-text comment field in the cancellation survey.', 'subscription' ), 'value' => '1', 'checked' => Cancellation::is_feedback_comment_enabled(), ), ), ); } /** * Render the page: shared header + the flow view. * * @return void */ public function render_page() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view routing, no state change. $active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'reasons'; $tabs = self::tabs(); if ( ! isset( $tabs[ $active_tab ] ) ) { $active_tab = 'reasons'; } $menu = new Menu(); if ( method_exists( $menu, 'render_admin_header' ) ) { $menu->render_admin_header( '', '', array( array( 'label' => __( 'Cancellation Flow', 'subscription' ) ) ) ); } $page_url = admin_url( 'admin.php?page=' . self::SLUG ); include __DIR__ . '/views/cancellation-flow.php'; } }