| @@ -34,8 +34,17 @@ | ||
| 34 | 34 | */ |
| 35 | 35 | const CANCEL_AT_META = '_subscrpt_cancel_at'; |
| 36 | 36 | |
| 37 | 37 | /** |
| 38 | + * Key of the "Other" reason, which the survey adds by itself. | |
| 39 | + * | |
| 40 | + * Reserved: it is never one of the store's own reasons. See get_reasons(). | |
| 41 | + * | |
| 42 | + * @var string | |
| 43 | + */ | |
| 44 | + const OTHER_KEY = 'other'; | |
| 45 | + | |
| 46 | + /** | |
| 38 | 47 | * Initialize the class. |
| 39 | 48 | */ |
| 40 | 49 | public function __construct() { |
| 41 | 50 | add_action( 'subscrpt_subscription_pending_cancellation', [ $this, 'schedule_cancellation' ] ); |
| @@ -43,8 +52,10 @@ | ||
| 43 | 52 | add_action( 'subscrpt_subscription_resumed', [ $this, 'clear_scheduled_cancellation' ] ); |
| 44 | 53 | add_action( 'before_single_subscrpt_content', [ $this, 'display_pending_cancellation_notice' ] ); |
| 45 | 54 | add_action( 'before_single_subscrpt_content', [ $this, 'maybe_render_feedback_modal' ] ); |
| 46 | 55 | add_action( 'wp_ajax_subscrpt_record_cancellation_feedback', [ $this, 'record_feedback' ] ); |
| 56 | + add_action( 'wp_ajax_subscrpt_record_cancellation_save', [ $this, 'record_save' ] ); | |
| 57 | + add_action( 'wp_ajax_subscrpt_claim_cancellation_offer', [ $this, 'claim_offer' ] ); | |
| 47 | 58 | add_action( 'subscrpt_details_side_bottom', [ $this, 'render_admin_feedback_card' ] ); |
| 48 | 59 | } |
| 49 | 60 | |
| 50 | 61 | /** |
| @@ -172,10 +183,11 @@ | ||
| 172 | 183 | wp_localize_script( |
| 173 | 184 | 'subscrpt_cancellation_feedback', |
| 174 | 185 | 'subscrptCancellationFeedback', |
| 175 | 186 | [ |
| 176 | - 'ajaxUrl' => admin_url( 'admin-ajax.php' ), | |
| 177 | - 'nonce' => wp_create_nonce( 'subscrpt_cancellation_feedback' ), | |
| 187 | + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), | |
| 188 | + 'nonce' => wp_create_nonce( 'subscrpt_cancellation_feedback' ), | |
| 189 | + 'doneLabel' => __( 'Done', 'subscription' ), | |
| 178 | 190 | ] |
| 179 | 191 | ); |
| 180 | 192 | ?> |
| 181 | 193 | <div class="subscrpt-feedback-modal" id="subscrpt-feedback-modal" data-subscription="<?php echo esc_attr( $subscription_id ); ?>" hidden> |
| @@ -186,9 +198,39 @@ | ||
| 186 | 198 | <button type="button" class="subscrpt-feedback-modal__close" data-subscrpt-feedback-dismiss aria-label="<?php esc_attr_e( 'Close', 'subscription' ); ?>"> |
| 187 | 199 | <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg> |
| 188 | 200 | </button> |
| 189 | 201 | </div> |
| 190 | - <div class="subscrpt-feedback-modal__body"> | |
| 202 | + <?php if ( \SpringDevs\Subscription\Admin\CancellationFlow::offer_enabled() ) : ?> | |
| 203 | + <?php $subscrpt_offer_percent = \SpringDevs\Subscription\Admin\CancellationFlow::offer_percent(); ?> | |
| 204 | + <div class="subscrpt-feedback-modal__body" data-subscrpt-offer-step> | |
| 205 | + <p class="subscrpt-feedback-modal__intro"> | |
| 206 | + <?php | |
| 207 | + if ( 'fixed' === \SpringDevs\Subscription\Admin\CancellationFlow::offer_type() ) { | |
| 208 | + printf( | |
| 209 | + /* translators: %s: formatted discount amount. */ | |
| 210 | + esc_html__( 'Stay with us and take %s off your next order.', 'subscription' ), | |
| 211 | + wp_kses_post( wc_price( \SpringDevs\Subscription\Admin\CancellationFlow::offer_amount() ) ) | |
| 212 | + ); | |
| 213 | + } else { | |
| 214 | + printf( | |
| 215 | + /* translators: %s: discount percentage. */ | |
| 216 | + esc_html__( 'Stay with us and take %s%% off your next order.', 'subscription' ), | |
| 217 | + esc_html( (string) $subscrpt_offer_percent ) | |
| 218 | + ); | |
| 219 | + } | |
| 220 | + ?> | |
| 221 | + </p> | |
| 222 | + <p class="subscrpt-feedback-modal__offer-note" data-subscrpt-offer-result hidden></p> | |
| 223 | + </div> | |
| 224 | + <div class="subscrpt-feedback-modal__footer" data-subscrpt-offer-step> | |
| 225 | + <button type="button" class="subscrpt-feedback-modal__btn subscrpt-feedback-modal__offer-decline" data-subscrpt-offer-decline><?php esc_html_e( 'No thanks, continue', 'subscription' ); ?></button> | |
| 226 | + <button type="button" class="subscrpt-feedback-modal__btn subscrpt-feedback-modal__offer-claim" data-subscrpt-offer-claim> | |
| 227 | + <?php esc_html_e( 'Claim discount', 'subscription' ); ?> | |
| 228 | + </button> | |
| 229 | + </div> | |
| 230 | + <?php endif; ?> | |
| 231 | + | |
| 232 | + <div class="subscrpt-feedback-modal__body"<?php echo \SpringDevs\Subscription\Admin\CancellationFlow::offer_enabled() ? ' data-subscrpt-reason-step hidden' : ''; ?>> | |
| 191 | 233 | <p class="subscrpt-feedback-modal__intro" id="subscrpt-feedback-intro"><?php esc_html_e( 'Please let us know why you are cancelling. Your feedback helps us improve.', 'subscription' ); ?></p> |
| 192 | 234 | <ul class="subscrpt-feedback-modal__reasons"> |
| 193 | 235 | <?php foreach ( $reasons as $index => $reason ) : ?> |
| 194 | 236 | <?php |
| @@ -210,11 +252,11 @@ | ||
| 210 | 252 | <?php if ( self::is_feedback_comment_enabled() ) : ?> |
| 211 | 253 | <textarea class="subscrpt-feedback-modal__comment" id="subscrpt-feedback-comment" rows="3" placeholder="<?php esc_attr_e( 'Additional comments (optional)', 'subscription' ); ?>"></textarea> |
| 212 | 254 | <?php endif; ?> |
| 213 | 255 | </div> |
| 214 | - <div class="subscrpt-feedback-modal__footer"> | |
| 256 | + <div class="subscrpt-feedback-modal__footer"<?php echo \SpringDevs\Subscription\Admin\CancellationFlow::offer_enabled() ? ' data-subscrpt-reason-step hidden' : ''; ?>> | |
| 257 | + <button type="button" class="subscrpt-feedback-modal__btn subscrpt-feedback-modal__confirm" id="subscrpt-feedback-confirm"><?php esc_html_e( 'Confirm cancellation', 'subscription' ); ?></button> | |
| 215 | 258 | <button type="button" class="subscrpt-feedback-modal__btn subscrpt-feedback-modal__keep" data-subscrpt-feedback-dismiss><?php esc_html_e( 'Keep subscription', 'subscription' ); ?></button> |
| 216 | - <button type="button" class="subscrpt-feedback-modal__btn subscrpt-feedback-modal__confirm" id="subscrpt-feedback-confirm"><?php esc_html_e( 'Confirm cancellation', 'subscription' ); ?></button> | |
| 217 | 259 | </div> |
| 218 | 260 | </div> |
| 219 | 261 | </div> |
| 220 | 262 | <?php |
| @@ -318,8 +360,169 @@ | ||
| 318 | 360 | wp_send_json_success( [ 'id' => $data['id'] ] ); |
| 319 | 361 | } |
| 320 | 362 | |
| 321 | 363 | /** |
| 364 | + * AJAX: the customer accepted the retention offer. | |
| 365 | + * | |
| 366 | + * Free owns the request - nonce, ownership, throttle - and asks for an offer | |
| 367 | + * through `subscrpt_cancellation_offer`. Free itself has nothing to give: the | |
| 368 | + * coupon is Pro's, so without a listener this reports no offer rather than | |
| 369 | + * promising a discount that never arrives. | |
| 370 | + * | |
| 371 | + * @return void | |
| 372 | + */ | |
| 373 | + public function claim_offer() { | |
| 374 | + check_ajax_referer( 'subscrpt_cancellation_feedback', 'nonce' ); | |
| 375 | + | |
| 376 | + $subscription_id = isset( $_POST['subscription_id'] ) ? absint( wp_unslash( $_POST['subscription_id'] ) ) : 0; | |
| 377 | + if ( $subscription_id <= 0 ) { | |
| 378 | + wp_send_json_error( [ 'message' => 'invalid_subscription' ] ); | |
| 379 | + } | |
| 380 | + | |
| 381 | + $subs_post = get_post( $subscription_id ); | |
| 382 | + if ( ! $subs_post || 'subscrpt_order' !== $subs_post->post_type ) { | |
| 383 | + wp_send_json_error( [ 'message' => 'invalid_subscription' ] ); | |
| 384 | + } | |
| 385 | + | |
| 386 | + $author_id = (int) $subs_post->post_author; | |
| 387 | + if ( ! current_user_can( 'manage_options' ) && $author_id !== get_current_user_id() ) { | |
| 388 | + wp_send_json_error( [ 'message' => 'forbidden' ] ); | |
| 389 | + } | |
| 390 | + | |
| 391 | + /** | |
| 392 | + * Filters the retention offer handed to a cancelling customer. | |
| 393 | + * | |
| 394 | + * Return an array with a `code` to make the offer; anything falsy means no | |
| 395 | + * offer was issued and the customer continues to the reasons step. | |
| 396 | + * | |
| 397 | + * @param array|null $offer The offer, or null when none is issued. | |
| 398 | + * @param int $subscription_id Subscription ID. | |
| 399 | + * @param int $customer_id Subscription owner. | |
| 400 | + */ | |
| 401 | + $offer = apply_filters( 'subscrpt_cancellation_offer', null, $subscription_id, $author_id ); | |
| 402 | + | |
| 403 | + if ( empty( $offer['code'] ) ) { | |
| 404 | + wp_send_json_error( [ 'message' => 'no_offer' ] ); | |
| 405 | + } | |
| 406 | + | |
| 407 | + // Accepting the offer is a save, and the strongest kind - report it even | |
| 408 | + // if the customer already dismissed the modal once today. | |
| 409 | + delete_transient( self::save_throttle_key( $subscription_id ) ); | |
| 410 | + set_transient( self::save_throttle_key( $subscription_id ), 1, DAY_IN_SECONDS ); | |
| 411 | + | |
| 412 | + $reason_key = isset( $_POST['reason_key'] ) ? sanitize_key( wp_unslash( $_POST['reason_key'] ) ) : ''; | |
| 413 | + | |
| 414 | + $reason_label = ''; | |
| 415 | + foreach ( self::get_reasons() as $reason ) { | |
| 416 | + if ( isset( $reason['key'] ) && (string) $reason['key'] === $reason_key ) { | |
| 417 | + $reason_label = isset( $reason['label'] ) ? (string) $reason['label'] : ''; | |
| 418 | + break; | |
| 419 | + } | |
| 420 | + } | |
| 421 | + | |
| 422 | + do_action( | |
| 423 | + 'subscrpt_subscription_saved', | |
| 424 | + $subscription_id, | |
| 425 | + [ | |
| 426 | + 'subscription_id' => $subscription_id, | |
| 427 | + 'customer_id' => $author_id, | |
| 428 | + 'reason_key' => $reason_key, | |
| 429 | + 'reason_label' => $reason_label, | |
| 430 | + 'offer_accepted' => true, | |
| 431 | + 'offer_code' => (string) $offer['code'], | |
| 432 | + ] | |
| 433 | + ); | |
| 434 | + | |
| 435 | + wp_send_json_success( | |
| 436 | + [ | |
| 437 | + 'code' => (string) $offer['code'], | |
| 438 | + 'message' => isset( $offer['message'] ) ? (string) $offer['message'] : '', | |
| 439 | + ] | |
| 440 | + ); | |
| 441 | + } | |
| 442 | + | |
| 443 | + /** | |
| 444 | + * Transient guarding one save report per subscription per day. | |
| 445 | + * | |
| 446 | + * Every way out of the modal counts as a save - Keep subscription, the X, the | |
| 447 | + * overlay, Escape - so without this a customer idly opening and closing it | |
| 448 | + * would mail the store owner each time. | |
| 449 | + * | |
| 450 | + * @param int $subscription_id Subscription post ID. | |
| 451 | + * @return string | |
| 452 | + */ | |
| 453 | + protected static function save_throttle_key( $subscription_id ) { | |
| 454 | + return 'subscrpt_save_reported_' . (int) $subscription_id; | |
| 455 | + } | |
| 456 | + | |
| 457 | + /** | |
| 458 | + * AJAX: the customer backed out of cancelling. | |
| 459 | + * | |
| 460 | + * Records nothing - the reason list is only meaningful for an actual | |
| 461 | + * cancellation - but fires `subscrpt_subscription_saved` so the retention can | |
| 462 | + * be reported. Throttled to once a day per subscription. | |
| 463 | + * | |
| 464 | + * @return void | |
| 465 | + */ | |
| 466 | + public function record_save() { | |
| 467 | + check_ajax_referer( 'subscrpt_cancellation_feedback', 'nonce' ); | |
| 468 | + | |
| 469 | + $subscription_id = isset( $_POST['subscription_id'] ) ? absint( wp_unslash( $_POST['subscription_id'] ) ) : 0; | |
| 470 | + if ( $subscription_id <= 0 ) { | |
| 471 | + wp_send_json_error( [ 'message' => 'invalid_subscription' ] ); | |
| 472 | + } | |
| 473 | + | |
| 474 | + $subs_post = get_post( $subscription_id ); | |
| 475 | + if ( ! $subs_post || 'subscrpt_order' !== $subs_post->post_type ) { | |
| 476 | + wp_send_json_error( [ 'message' => 'invalid_subscription' ] ); | |
| 477 | + } | |
| 478 | + | |
| 479 | + $author_id = (int) $subs_post->post_author; | |
| 480 | + if ( ! current_user_can( 'manage_options' ) && $author_id !== get_current_user_id() ) { | |
| 481 | + wp_send_json_error( [ 'message' => 'forbidden' ] ); | |
| 482 | + } | |
| 483 | + | |
| 484 | + $throttle = self::save_throttle_key( $subscription_id ); | |
| 485 | + if ( get_transient( $throttle ) ) { | |
| 486 | + wp_send_json_success( [ 'throttled' => true ] ); | |
| 487 | + } | |
| 488 | + set_transient( $throttle, 1, DAY_IN_SECONDS ); | |
| 489 | + | |
| 490 | + $reason_key = isset( $_POST['reason_key'] ) ? sanitize_key( wp_unslash( $_POST['reason_key'] ) ) : ''; | |
| 491 | + | |
| 492 | + $reason_label = ''; | |
| 493 | + foreach ( self::get_reasons() as $reason ) { | |
| 494 | + if ( isset( $reason['key'] ) && (string) $reason['key'] === $reason_key ) { | |
| 495 | + $reason_label = isset( $reason['label'] ) ? (string) $reason['label'] : ''; | |
| 496 | + break; | |
| 497 | + } | |
| 498 | + } | |
| 499 | + | |
| 500 | + $data = [ | |
| 501 | + 'subscription_id' => $subscription_id, | |
| 502 | + 'customer_id' => $author_id, | |
| 503 | + 'reason_key' => $reason_key, | |
| 504 | + 'reason_label' => $reason_label, | |
| 505 | + 'offer_accepted' => false, | |
| 506 | + ]; | |
| 507 | + | |
| 508 | + /** | |
| 509 | + * Fires when a customer opens the cancellation modal and backs out. | |
| 510 | + * | |
| 511 | + * Throttled to once a day per subscription, so a listener may treat each | |
| 512 | + * call as a distinct retention event. | |
| 513 | + * | |
| 514 | + * @param int $subscription_id Subscription ID. | |
| 515 | + * @param array $data Save context: the reason that had been | |
| 516 | + * selected (may be empty) and whether a | |
| 517 | + * retention offer was accepted. | |
| 518 | + */ | |
| 519 | + do_action( 'subscrpt_subscription_saved', $subscription_id, $data ); | |
| 520 | + | |
| 521 | + wp_send_json_success( [ 'saved' => true ] ); | |
| 522 | + } | |
| 523 | + | |
| 524 | + /** | |
| 322 | 525 | * Get Settings |
| 323 | 526 | * |
| 324 | 527 | * @param string $id Setting ID. |
| 325 | 528 | */ |
| @@ -354,10 +557,12 @@ | ||
| 354 | 557 | |
| 355 | 558 | /** |
| 356 | 559 | * Built-in default cancellation reasons. |
| 357 | 560 | * |
| 358 | - * Used when no Pro-managed reason list is set. Filterable so Pro and | |
| 359 | - * integrations can adjust the defaults. | |
| 561 | + * Used when the store has not saved its own list. Written for a store | |
| 562 | + * selling goods on a subscription — the reasons a customer stops a product | |
| 563 | + * subscription — and without "Other", which get_reasons() adds by itself. | |
| 564 | + * Filterable so Pro and integrations can adjust the defaults. | |
| 360 | 565 | * |
| 361 | 566 | * @return array<int,array{key:string,label:string}> |
| 362 | 567 | */ |
| 363 | 568 | public static function default_reasons() { |
| @@ -366,26 +571,26 @@ | ||
| 366 | 571 | 'key' => 'too_expensive', |
| 367 | 572 | 'label' => __( 'Too expensive', 'subscription' ), |
| 368 | 573 | ], |
| 369 | 574 | [ |
| 370 | - 'key' => 'missing_features', | |
| 371 | - 'label' => __( 'Missing features I need', 'subscription' ), | |
| 575 | + 'key' => 'too_much_product', | |
| 576 | + 'label' => __( 'I have more than I need', 'subscription' ), | |
| 372 | 577 | ], |
| 373 | 578 | [ |
| 374 | - 'key' => 'found_alternative', | |
| 375 | - 'label' => __( 'Found a better alternative', 'subscription' ), | |
| 579 | + 'key' => 'quality_issues', | |
| 580 | + 'label' => __( 'Not happy with the quality', 'subscription' ), | |
| 376 | 581 | ], |
| 377 | 582 | [ |
| 378 | - 'key' => 'no_longer_needed', | |
| 379 | - 'label' => __( 'No longer needed', 'subscription' ), | |
| 583 | + 'key' => 'delivery_issues', | |
| 584 | + 'label' => __( 'Delivery took too long', 'subscription' ), | |
| 380 | 585 | ], |
| 381 | 586 | [ |
| 382 | - 'key' => 'technical_issues', | |
| 383 | - 'label' => __( 'Technical issues', 'subscription' ), | |
| 587 | + 'key' => 'found_better_deal', | |
| 588 | + 'label' => __( 'Found a better deal elsewhere', 'subscription' ), | |
| 384 | 589 | ], |
| 385 | 590 | [ |
| 386 | - 'key' => 'other', | |
| 387 | - 'label' => __( 'Other', 'subscription' ), | |
| 591 | + 'key' => 'taking_a_break', | |
| 592 | + 'label' => __( 'Just taking a break', 'subscription' ), | |
| 388 | 593 | ], |
| 389 | 594 | ]; |
| 390 | 595 | |
| 391 | 596 | /** |
| @@ -396,25 +601,53 @@ | ||
| 396 | 601 | return apply_filters( 'subscrpt_cancellation_default_reasons', $reasons ); |
| 397 | 602 | } |
| 398 | 603 | |
| 399 | 604 | /** |
| 400 | - * Resolve the active cancellation reason list. | |
| 605 | + * The store's own reasons: the saved list, or the defaults when none is saved. | |
| 401 | 606 | * |
| 402 | - * Uses the Pro-managed `subscrpt_cancellation_reasons` option when Pro is | |
| 403 | - * active and the option is non-empty; otherwise falls back to the built-in | |
| 404 | - * defaults so the feature works without Pro. | |
| 607 | + * This is what the Reasons editor edits. It is read with or without Pro — | |
| 608 | + * editing reasons is a free feature. An "other" entry saved before "Other" | |
| 609 | + * became automatic is dropped, so it can never show twice. | |
| 405 | 610 | * |
| 406 | 611 | * @return array<int,array{key:string,label:string}> |
| 407 | 612 | */ |
| 613 | + public static function get_configured_reasons() { | |
| 614 | + $reasons = get_option( 'subscrpt_cancellation_reasons', [] ); | |
| 615 | + if ( empty( $reasons ) || ! is_array( $reasons ) ) { | |
| 616 | + $reasons = self::default_reasons(); | |
| 617 | + } | |
| 618 | + | |
| 619 | + return array_values( | |
| 620 | + array_filter( | |
| 621 | + $reasons, | |
| 622 | + static function ( $reason ) { | |
| 623 | + return is_array( $reason ) && self::OTHER_KEY !== ( $reason['key'] ?? '' ); | |
| 624 | + } | |
| 625 | + ) | |
| 626 | + ); | |
| 627 | + } | |
| 628 | + | |
| 629 | + /** | |
| 630 | + * The reasons customers are offered. | |
| 631 | + * | |
| 632 | + * The store's reasons, then "Other" when the comment box is on — an answer | |
| 633 | + * that is none of the listed reasons needs the comment to say what it is, | |
| 634 | + * so without the box there is no "Other". Everything customer-facing reads | |
| 635 | + * this, including the label snapshot taken when feedback is recorded. | |
| 636 | + * | |
| 637 | + * @return array<int,array{key:string,label:string}> | |
| 638 | + */ | |
| 408 | 639 | public static function get_reasons() { |
| 409 | - if ( subscrpt_pro_activated() ) { | |
| 410 | - $reasons = get_option( 'subscrpt_cancellation_reasons', [] ); | |
| 411 | - if ( ! empty( $reasons ) && is_array( $reasons ) ) { | |
| 412 | - return $reasons; | |
| 413 | - } | |
| 640 | + $reasons = self::get_configured_reasons(); | |
| 641 | + | |
| 642 | + if ( self::is_feedback_comment_enabled() ) { | |
| 643 | + $reasons[] = [ | |
| 644 | + 'key' => self::OTHER_KEY, | |
| 645 | + 'label' => __( 'Other', 'subscription' ), | |
| 646 | + ]; | |
| 414 | 647 | } |
| 415 | 648 | |
| 416 | - return self::default_reasons(); | |
| 649 | + return $reasons; | |
| 417 | 650 | } |
| 418 | 651 | |
| 419 | 652 | /** |
| 420 | 653 | * Record when a pending cancellation should become final. |