Current File : /home/digitaw/www/wp-content/plugins/event-tickets/src/Tickets/Seating/Frontend/Timer.php
<?php
/**
 * The Seating feature frontend timer handler.
 *
 * @since 5.16.0
 *
 * @package TEC\Tickets\Seating\Frontend;
 */

namespace TEC\Tickets\Seating\Frontend;

use TEC\Common\Contracts\Provider\Controller as Controller_Contract;
use TEC\Common\lucatume\DI52\Container;
use TEC\Common\Asset;
use TEC\Tickets\Seating\Service\Reservations;
use TEC\Tickets\Seating\Settings;
use TEC\Tickets\Seating\Tables\Sessions;
use TEC\Tickets\Seating\Frontend;
use TEC\Tickets\Seating\Template;
use Tribe__Tickets__Main as ET;

/**
 * Class Cookie.
 *
 * @since 5.16.0
 *
 * @package TEC\Tickets\Seating\Frontend;
 */
class Timer extends Controller_Contract {
	/**
	 * The AJAX action used from the JS code to start the timer.
	 *
	 * @since 5.16.0
	 *
	 * @var string
	 */
	const ACTION_START = 'tec_tickets_seating_timer_start';

	/**
	 * The AJAX action used from the JS code to sync the timer with the backend.
	 *
	 * @since 5.16.0
	 *
	 * @var string
	 */
	const ACTION_SYNC = 'tec_tickets_seating_timer_sync';

	/**
	 * The AJAX action used from the JS code to get the data to render the redirection modal.
	 *
	 * @since 5.16.0
	 *
	 * @var string
	 */
	const ACTION_INTERRUPT_GET_DATA = 'tec_tickets_seating_timer_interrupt_get_data';

	/**
	 * The AJAX action used from the JS code to signal the timer should pause to allow the user to checkout.
	 *
	 * @since 5.17.0
	 *
	 * @var string
	 */
	const ACTION_PAUSE_TO_CHECKOUT = 'tec_tickets_seating_timer_pause_to_checkout';

	/**
	 * A reference to the template object.
	 *
	 * @since 5.16.0
	 *
	 * @var Template
	 */
	private Template $template;

	/**
	 * A reference to the Sessions table handler.
	 *
	 * @since 5.16.0
	 *
	 * @var Sessions
	 */
	private Sessions $sessions;

	/**
	 * A reference to the Service object.
	 *
	 * @since 5.16.0
	 *
	 * @var Reservations
	 */
	private Reservations $reservations;

	/**
	 * A reference to the Session object.
	 *
	 * @since 5.16.0
	 *
	 * @var Session
	 */
	private Session $session;

	/**
	 * A reference to the Frontend object.
	 *
	 * @since 5.16.0
	 *
	 * @var Frontend
	 */
	private Frontend $frontend;

	/**
	 * The current token used to render the timer.
	 * Set on explicit render requests.
	 *
	 * @since 5.16.0
	 *
	 * @var string|null
	 */
	private ?string $current_token = null;

	/**
	 * The current post ID used to render the timer.
	 * Set on explicit render requests.
	 *
	 * @since 5.16.0
	 *
	 * @var int|null
	 */
	private ?int $current_post_id = null;

	/**
	 * Timer constructor.
	 *
	 * @since 5.16.0
	 *
	 * @param Container    $container    A reference to the container object.
	 * @param Template     $template     A reference to the template object.
	 * @param Sessions     $sessions     A reference to the Sessions table handler.
	 * @param Reservations $reservations A reference to the Reservations object.
	 * @param Session      $session      A reference to the Session object.
	 * @param Frontend     $frontend     A reference to the Frontend object.
	 */
	public function __construct(
		Container $container,
		Template $template,
		Sessions $sessions,
		Reservations $reservations,
		Session $session,
		Frontend $frontend
	) {
		parent::__construct( $container );
		$this->template     = $template;
		$this->sessions     = $sessions;
		$this->reservations = $reservations;
		$this->session      = $session;
		$this->frontend     = $frontend;
	}

	/**
	 * Binds and sets up implementations.
	 *
	 * @since 5.16.0
	 *
	 * @return void
	 */
	protected function do_register(): void {
		add_action( 'tec_tickets_seating_seat_selection_timer', [ $this, 'render' ], 10, 2 );
		add_action( 'wp_ajax_nopriv_' . self::ACTION_START, [ $this, 'ajax_start' ] );
		add_action( 'wp_ajax_' . self::ACTION_START, [ $this, 'ajax_start' ] );
		add_action( 'wp_ajax_nopriv_' . self::ACTION_SYNC, [ $this, 'ajax_sync' ] );
		add_action( 'wp_ajax_' . self::ACTION_SYNC, [ $this, 'ajax_sync' ] );
		add_action( 'wp_ajax_nopriv_' . self::ACTION_INTERRUPT_GET_DATA, [ $this, 'ajax_interrupt' ] );
		add_action( 'wp_ajax_' . self::ACTION_INTERRUPT_GET_DATA, [ $this, 'ajax_interrupt' ] );
		add_action( 'wp_ajax_nopriv_' . self::ACTION_PAUSE_TO_CHECKOUT, [ $this, 'ajax_pause_to_checkout' ] );
		add_action( 'wp_ajax_' . self::ACTION_PAUSE_TO_CHECKOUT, [ $this, 'ajax_pause_to_checkout' ] );

		// Tickets Commerce checkout page: here the timer should be hydrated from the cookie, no arguments are needed.
		add_action(
			'tribe_template_after_include:tickets/v2/commerce/checkout/cart/header',
			[ $this, 'render_to_sync' ],
			10,
			0
		);

		// Attendee Registration page: here the timer should be hydrated from the cookie, no arguments are needed.
		add_action(
			'tribe_template_after_include:tickets-plus/v2/attendee-registration/content/event/summary/title',
			[ $this, 'render_to_sync' ],
			10,
			0
		);

		// Attendee Registration modal: here the timer should be hydrated from the cookie, no arguments are needed.
		add_action(
			'tribe_template_before_include:tickets-plus/v2/modal/cart',
			[ $this, 'render_to_sync' ],
			10,
			0
		);

		Asset::add(
			'tec-tickets-seating-session',
			'frontend/session.js',
			ET::VERSION
		)
			->add_to_group_path( 'tec-seating' )
			->set_dependencies(
				'tribe-dialog-js',
				'wp-hooks',
				'wp-i18n',
				'tec-tickets-seating-utils'
			)
			->add_localize_script( 'tec.tickets.seating.frontend.sessionData', fn() => $this->get_localized_data() )
			->enqueue_on( 'tec_tickets_seating_seat_selection_timer' )
			->add_to_group( 'tec-tickets-seating-frontend' )
			->add_to_group( 'tec-tickets-seating' )
			->register();

		Asset::add(
			'tec-tickets-seating-session-style',
			'frontend/style-session.css',
			ET::VERSION
		)
			->add_to_group_path( 'tec-seating' )
			->set_dependencies( 'tribe-dialog' )
			->enqueue_on( 'tec_tickets_seating_seat_selection_timer' )
			->add_to_group( 'tec-tickets-seating-frontend' )
			->add_to_group( 'tec-tickets-seating' )
			->register();
	}

	/**
	 * Unregisters the controller by unsubscribing from front-end hooks.
	 *
	 * @since 5.16.0
	 *
	 * @return void
	 */
	public function unregister(): void {
		remove_action( 'tec_tickets_seating_seat_selection_timer', [ $this, 'render' ], );
		remove_action( 'wp_ajax_nopriv_' . self::ACTION_START, [ $this, 'ajax_start' ] );
		remove_action( 'wp_ajax_' . self::ACTION_START, [ $this, 'ajax_start' ] );
		remove_action( 'wp_ajax_nopriv_' . self::ACTION_SYNC, [ $this, 'ajax_sync' ] );
		remove_action( 'wp_ajax_' . self::ACTION_SYNC, [ $this, 'ajax_sync' ] );
		remove_action( 'wp_ajax_nopriv_' . self::ACTION_INTERRUPT_GET_DATA, [ $this, 'ajax_interrupt' ] );
		remove_action( 'wp_ajax_' . self::ACTION_INTERRUPT_GET_DATA, [ $this, 'ajax_interrupt' ] );
		remove_action( 'wp_ajax_nopriv_' . self::ACTION_PAUSE_TO_CHECKOUT, [ $this, 'ajax_pause_to_checkout' ] );
		remove_action( 'wp_ajax_' . self::ACTION_PAUSE_TO_CHECKOUT, [ $this, 'ajax_pause_to_checkout' ] );

		// Tickets Commerce checkout page: here the timer should be hydrated from the cookie, no arguments are needed.
		remove_action(
			'tribe_template_after_include:tickets/v2/commerce/checkout/cart/header',
			[ $this, 'render_to_sync' ]
		);

		// Attendee Registration page: here the timer should be hydrated from the cookie, no arguments are needed.
		remove_action(
			'tribe_template_after_include:tickets-plus/v2/attendee-registration/content/event/summary/title',
			[ $this, 'render_to_sync' ]
		);

		// Attendee Registration modal: here the timer should be hydrated from the cookie, no arguments are needed.
		remove_action(
			'tribe_template_before_include:tickets-plus/v2/modal/cart',
			[ $this, 'render_to_sync' ]
		);
	}

	/**
	 * Renders the seat selection timer HTML.
	 *
	 * Note it's the JS code responsibility to start the timer by means of a request to the backend.
	 *
	 * @since 5.16.0
	 *
	 * @param string|null $token        The ephemeral token used to secure the iframe communication with the service
	 *                                  and identify the seat selection session.
	 * @param int|null    $post_id      The ID of the post to purchase tickets for.
	 * @param bool        $sync_on_load Whether to sync the timer with the backend on DOM ready or not, defaults to
	 *                                  `false`.
	 *
	 * @return void The seat selection timer HTML is rendered.
	 */
	public function render( string $token = null, int $post_id = null, bool $sync_on_load = false ): void {
		if ( ! ( $token && $post_id ) ) {
			// Token and post ID did not come from the action, pull them from the cookie, if possible.
			$cookie_timer_token_post_id = $this->session->get_session_token_object_id();

			if ( null === $cookie_timer_token_post_id ) {
				// The timer cannot be rendered.
				return;
			}

			[ $token, $post_id ] = $cookie_timer_token_post_id;
		} else {
			if ( ! tec_tickets_seating_enabled( $post_id ) ) {
				// The post is not using assigned seating, do not render the timer.
				return;
			}

			// If a cookie and token were passed, store them for later use.
			$this->current_token   = $token;
			$this->current_post_id = $post_id;
		}

		if ( ! tec_tickets_seating_enabled( $post_id ) ) {
			// The post is not using assigned seating, do not render the timer.
			return;
		}

		wp_enqueue_script( 'tec-tickets-seating-session' );
		wp_enqueue_style( 'tec-tickets-seating-session-style' );

		/** @noinspection UnusedFunctionResultInspection */
		$this->template->template(
			'seat-selection-timer',
			[
				'token'        => $token,
				'redirect_url' => get_permalink( $post_id ),
				'post_id'      => $post_id,
				'sync_on_load' => $sync_on_load,
			]
		);
	}

	/**
	 * Renders the seat selection timer HTML adding the attribute that will trigger its immediate
	 * synchronization with the backend.
	 *
	 * @since 5.16.0
	 *
	 * @return void
	 */
	public function render_to_sync(): void {
		$this->render( $this->current_token, $this->current_post_id, true );
	}

	/**
	 * Returns the seat-selection timeout for a post in seconds.
	 *
	 * @since 5.16.0
	 *
	 * @param int $post_id The post ID the iframe is for.
	 *
	 * @return int The seat-selection timeout for a post in seconds.
	 */
	public function get_timeout( $post_id ): int {
		$limit_in_minutes = tribe( Settings::class )->get_reservation_time_limit();

		/**
		 * Filters the seat selection timeout, default is 15 minutes.
		 *
		 * @since 5.16.0
		 *
		 * @param int $timeout The timeout in seconds.
		 * @param int $post_id The post ID the iframe is for.
		 */
		return apply_filters( 'tec_tickets_seating_selection_timeout', $limit_in_minutes * 60, $post_id );
	}

	/**
	 * Returns the data to be localized on the timer frontend.
	 *
	 * @since 5.16.0
	 *
	 * @return array{
	 *     ajaxUrl: string,
	 *     ajaxNonce: string,
	 *     checkoutGraceTime: int,
	 *     ACTION_START: string,
	 *     ACTION_TIME_LEFT: string,
	 *     ACTION_REDIRECT: string,
	 *     ACTION_INTERRUPT_GET_DATA: string,
	 *     ACTION_PAUSE_TO_CHECKOUT: string,
	 * } The data to be localized on the timer frontend.
	 */
	public function get_localized_data(): array {
		return [
			'ajaxUrl'                   => admin_url( 'admin-ajax.php' ),
			'ajaxNonce'                 => wp_create_nonce( Session::COOKIE_NAME ),
			'checkoutGraceTime'         => $this->get_checkout_grace_time(),
			'ACTION_START'              => self::ACTION_START,
			'ACTION_SYNC'               => self::ACTION_SYNC,
			'ACTION_INTERRUPT_GET_DATA' => self::ACTION_INTERRUPT_GET_DATA,
			'ACTION_PAUSE_TO_CHECKOUT'  => self::ACTION_PAUSE_TO_CHECKOUT,
		];
	}

	/**
	 * Checks the AJAX request parameters and returns them if they are valid.
	 *
	 * @since 5.16.0
	 *
	 * @return array{0: string, 1: int}|false The token and post ID or `false` if the nonce verification failed.
	 */
	private function ajax_check_request() {
		if ( ! check_ajax_referer( Session::COOKIE_NAME, '_ajaxNonce', false ) ) {
			wp_send_json_error(
				[
					'error' => 'Nonce verification failed',
				],
				403
			);

			// This will never be reached, but we need to return something.
			return false;
		}

		$token   = tribe_get_request_var( 'token', null );
		$post_id = tribe_get_request_var( 'postId', null );

		if ( ! ( $token && $post_id ) ) {
			wp_send_json_error(
				[
					'error' => 'Missing required parameters',
				],
				400
			);

			// This will never be reached, but we need to return something.
			return false;
		}

		return [ $token, $post_id ];
	}

	/**
	 * Handles the AJAX request to start the timer.
	 *
	 * This request will create a new session in the database and will return the number of seconds left in the timer.
	 *
	 * @since 5.16.0
	 *
	 * @return void
	 */
	public function ajax_start(): void {
		$token_and_post_id = $this->ajax_check_request();

		if ( ! $token_and_post_id ) {
			return;
		}

		[ $token, $post_id ] = $token_and_post_id;

		$timeout = $this->get_timeout( $post_id );

		// When starting a new session, we need to remove the previous sessions for the same post.
		$this->session->cancel_previous_for_object( $post_id, $token );

		// We're in the context of an XHR/AJAX request: the browser will set the cookie for us.
		$now        = microtime( true );
		$expiration = (int) $now + $timeout;
		$this->session->add_entry( $post_id, $token );

		if ( ! $this->sessions->insert_or_update( $token, $post_id, $expiration ) ) {
			wp_send_json_error(
				[
					'error' => 'Failed to start timer',
				],
				500
			);

			return;
		}

		wp_send_json_success(
			[
				'secondsLeft' => $timeout,
				'timestamp'   => $now,
			]
		);
	}

	/**
	 * Handles the AJAX request to sync the timer with the backend.
	 *
	 * This request will read the number of seconds left in the timer from the database to allow the
	 * frontend to update the timer with a synced value.
	 *
	 * @since 5.16.0
	 *
	 * @return void The AJAX response is sent back to the browser.
	 */
	public function ajax_sync(): void {
		[ $token, $post_id ] = $this->ajax_check_request();

		$has_tickets_available = $this->frontend->get_events_ticket_capacity_for_seating( $post_id );

		// If no tickets are available, the users should be interrupted.
		$seconds_left = $has_tickets_available ? $this->sessions->get_seconds_left( $token ) : 0;

		wp_send_json_success(
			[
				'secondsLeft' => $seconds_left,
				'timestamp'   => microtime( true ),
			]
		);
	}

	/**
	 * Handles an AJAX request to interrupt the user flow and clear the seat selection.
	 *
	 * @since 5.16.0
	 *
	 * @return void  The AJAX response is sent back to the browser.
	 */
	public function ajax_interrupt(): void {
		$token_and_post_id = $this->ajax_check_request();

		if ( ! $token_and_post_id ) {
			return;
		}

		[ $token, $post_id ] = $token_and_post_id;


		$post_type             = get_post_type( $post_id );
		$post_type_object      = get_post_type_object( $post_type );
		$has_tickets_available = $this->frontend->get_events_ticket_capacity_for_seating( $post_id );

		if ( $has_tickets_available ) {
			$content      = _x(
				'Your seat selections are no longer reserved, but tickets are still available.',
				'Seat selection expired timer content',
				'event-tickets'
			);
			$button_label = _x( 'Find Seats', 'Seat selection expired timer button label', 'event-tickets' );
			$redirect_url = get_permalink( $post_id );
		} else {
			if ( $post_type_object ) {
				$post_type_label = strtolower( get_post_type_labels( $post_type_object )->singular_name ) ?? 'event';
			} else {
				$post_type_label = 'event';
			}

			$content = sprintf(
			// Translators: %s: The post type singular name.
				_x( 'This %s is now sold out.', 'Seat selection expired timer content', 'event-tickets' ),
				$post_type_label
			);

			if ( 'tribe_events' === $post_type ) {
				$button_label = sprintf(
				// Translators: %s: The post type singular name.
					_x( 'Find Another %s', 'Seat selection expired timer button label', 'event-tickets' ),
					ucfirst( $post_type_label )
				);

				$redirect_url = get_post_type_archive_link( $post_type );
			} else {
				$button_label = _x( 'Return to Home Page', 'Seat selection expired timer button label', 'event-tickets' );
				$redirect_url = get_home_url();
			}
		}

		/**
		 * Fires when a seat selection session is interrupted due to the timer expiring or the seat selection session
		 * being otherwise interrupted.
		 *
		 * @since 5.16.0
		 *
		 * @param int    $post_id The post ID the session is being interrupted for.
		 * @param string $token   The ephemeral token the session is being interrupted for.
		 */
		do_action( 'tec_tickets_seating_session_interrupt', $post_id, $token );

		// Remove the seat selection session cookie entry.
		$this->session->remove_entry( $post_id, $token );

		// Cancel the reservations for the post ID and token, remove the session associated with the token from the database.
		if ( ! (
			$this->reservations->cancel( $post_id, $this->sessions->get_reservation_uuids_for_token( $token ) )
			&& $this->sessions->delete_token_session( $token )
		) ) {
			wp_send_json_error(
				[
					'error' => 'Failed to cancel the reservations',
				],
				500
			);

			return;
		}

		// Check whether the session was interrupted due to the timer expiring or the tickets being sold out.
		$interrupt_title = ! $has_tickets_available ? _x( 'Sold Out', 'Seat selection sold out timer title', 'event-tickets' ) : _x( 'Time limit expired', 'Seat selection expired timer title', 'event-tickets' );

		$data = [
			'title'       => esc_html( $interrupt_title ),
			'content'     => esc_html( $content ),
			'buttonLabel' => esc_html( $button_label ),
			'redirectUrl' => esc_url( $redirect_url ),
		];

		/**
		 * Filters the seat selection expired timer data.
		 *
		 * @since 5.16.0
		 *
		 * @param array<string,string>   $data The seat selection expired timer data.
		 * @param int                    $post_id The post ID the session is being interrupted for.
		 * @param string                 $token   The ephemeral token the session is being interrupted for.
		 */
		$data = apply_filters( 'tec_tickets_seat_selection_timer_expired_data', $data, $post_id, $token );

		wp_send_json_success( $data );
	}

	/**
	 * Returns the current token set from a previous render in the context of this request.
	 *
	 * @since 5.16.0
	 *
	 * @return string|null The current token, or `null` if not set.
	 */
	public function get_current_token(): ?string {
		return $this->current_token;
	}

	/**
	 * Returns the current post ID set from a previous render in the context of this request.
	 *
	 * @since 5.16.0
	 *
	 * @return int|null The current post ID, or `null` if not set.
	 */
	public function get_current_post_id(): ?int {
		return $this->current_post_id;
	}

	/**
	 * Returns the filtered checkout grace time given to a user to complete the checkout process.
	 *
	 * @since 5.17.0
	 *
	 * @return int The filtered checkout grace time.
	 */
	public function get_checkout_grace_time(): int {
		/**
		 * Filters the grace time given to a user to complete the checkout process.
		 *
		 * @since 5.17.0
		 *
		 * @param int $grace_time The grace time allowed to a user to complete the checkout process.
		 */
		return (int) apply_filters( 'tec_tickets_seating_checkout_grace_time', 60 );
	}

	/**
	 * Handles the action from the backend signaling the user is checking out.
	 *
	 * @since 5.17.0
	 *
	 * @return void  The AJAX response is sent back to the browser.
	 */
	public function ajax_pause_to_checkout(): void {
		[ $token, $post_id ] = $this->ajax_check_request();

		$has_tickets_available = $this->frontend->get_events_ticket_capacity_for_seating( $post_id );

		if ( ! $has_tickets_available ) {
			wp_send_json_success(
				[
					'secondsLeft' => 0,
					'timestamp'   => microtime( true ),
				]
			);

			return;
		}

		// From this moment, give the user about 60 seconds to complete the checkout flow.
		$grace_time         = $this->get_checkout_grace_time();
		$updated_expiration = $this->sessions->set_token_expiration_timestamp( $token, time() + $grace_time, true );

		// If no tickets are available or the timestamp expiration update failed, the users should be interrupted.
		$seconds_left = $updated_expiration !== false ? $this->sessions->get_seconds_left( $token ) : 0;

		wp_send_json_success(
			[
				'secondsLeft' => $seconds_left,
				'timestamp'   => microtime( true ),
			]
		);
	}
}