service = $service; } /** Attach only authenticated forms and scoped screens. */ public function register() { add_filter( 'devoynx_rewards_admin_tabs', array( $this, 'tabs' ) ); add_action( 'devoynx_rewards_admin_tab', array( $this, 'render_admin' ) ); add_action( 'admin_post_devoynx_rewards_spin_settings', array( $this, 'save' ) ); add_action( 'admin_post_devoynx_rewards_spin', array( $this, 'play' ) ); add_action( 'devoynx_rewards_wallet_after_activity', array( $this, 'render_wallet' ), 25 ); } /** * Add one useful merchant destination. * * @param array $tabs Existing navigation. * @return array */ public function tabs( $tabs ) { $tabs['spin'] = __( 'Spin campaign', 'devoynx-rewards' ); return $tabs; } /** Save validated configuration while holding the same campaign lock as spins. */ public function save() { if ( 'POST' !== sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) || ! current_user_can( 'manage_devoynx_rewards' ) ) { wp_die( esc_html__( 'Permission denied.', 'devoynx-rewards' ), 403 ); } check_admin_referer( 'devoynx_rewards_spin_settings' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Every field is allowlisted and bounded by the service validator before saving. $settings = $this->service->sanitize( isset( $_POST['spin'] ) ? wp_unslash( $_POST['spin'] ) : array() ); if ( ! is_wp_error( $settings ) ) { try { ( new ProgramStore() )->locked( 'spin:campaign', 'spin_campaign', static function ( $row ) use ( $settings ) { update_option( Spin::OPTION, $settings, false ); if ( 'new' === $row['status'] ) { ( new ProgramStore() )->save( $row, array( 'spent' => 0, 'spins' => 0, ), 'active' ); } } ); } catch ( \Throwable $error ) { $settings = $this->service->error( 'failed' ); } } wp_safe_redirect( add_query_arg( array( 'page' => 'devoynx-rewards', 'tab' => 'spin', 'spin_saved' => is_wp_error( $settings ) ? 'invalid' : 'saved', ), admin_url( 'admin.php' ) ) ); exit; } /** Mutate only on an authenticated, nonce-verified POST, never on a shareable GET. */ public function play() { if ( 'POST' !== sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) || ! get_current_user_id() ) { wp_die( esc_html__( 'Permission denied.', 'devoynx-rewards' ), 403 ); } check_admin_referer( 'devoynx_rewards_spin' ); $request = isset( $_POST['spin_request'] ) && is_string( $_POST['spin_request'] ) ? sanitize_text_field( wp_unslash( $_POST['spin_request'] ) ) : ''; $result = $this->service->spin( get_current_user_id(), $request ); $query = is_wp_error( $result ) ? array( 'spin_error' => $result->get_error_code() ) : array( 'spin_result' => $request ); wp_safe_redirect( add_query_arg( $query, wc_get_account_endpoint_url( 'rewards' ) ) . '#devoynx-spin' ); exit; } /** * Render actual consumption, safeguards and every configured outcome. * * @param string $tab Current merchant tab. */ public function render_admin( $tab ) { if ( 'spin' !== $tab || ! current_user_can( 'manage_devoynx_rewards' ) ) { return; } $config = $this->service->settings(); $campaign = $this->service->campaign(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only result on a capability-gated screen. $saved = isset( $_GET['spin_saved'] ) ? sanitize_key( wp_unslash( $_GET['spin_saved'] ) ) : ''; $fields = array( 'budget' => array( __( 'Lifetime campaign point budget', 'devoynx-rewards' ), 100000000 ), 'max_spins' => array( __( 'Lifetime campaign spin limit', 'devoynx-rewards' ), 100000 ), 'per_customer' => array( __( 'Lifetime spins per account', 'devoynx-rewards' ), 100 ), 'interval_days' => array( __( 'Days between spins', 'devoynx-rewards' ), 365 ), 'account_age_days' => array( __( 'Minimum account age in days', 'devoynx-rewards' ), 365 ), ); ?>

service->error( 'configuration' )->get_error_message() ); ?>

$field ) : ?>

$reward ) { $this->outcome_fields( $index, $reward ); } ?>

'', 'weight' => '', ) ); $prefix = 'spin[rewards][' . absint( $index ) . ']'; ?>
service->settings(); if ( ! $config['enabled'] ) { return; } $customer = $this->service->customer( $user_id ); $campaign = $this->service->campaign(); $user = get_userdata( $user_id ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading an owner-scoped immutable receipt, never performing a spin. $request = isset( $_GET['spin_result'] ) && is_string( $_GET['spin_result'] ) ? sanitize_text_field( wp_unslash( $_GET['spin_result'] ) ) : ''; $result = $this->service->receipt( $user_id, $request ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Allowlisted read-only public status message. $error = isset( $_GET['spin_error'] ) ? sanitize_key( wp_unslash( $_GET['spin_error'] ) ) : ''; $reason = ''; if ( time() < strtotime( $user->user_registered . ' UTC' ) + $config['account_age_days'] * DAY_IN_SECONDS ) { $reason = 'age'; } if ( $customer['next_at'] > time() ) { $reason = 'frequency'; } if ( $customer['spins'] >= $config['per_customer'] ) { $reason = 'limit'; } if ( $campaign['spins'] >= $config['max_spins'] || $config['budget'] - $campaign['spent'] < max( array_column( $config['rewards'], 'points' ) ) ) { $reason = 'budget'; } $weights = array_sum( array_column( $config['rewards'], 'weight' ) ); $wheel_rewards = $result['rewards'] ?? $config['rewards']; $wheel_weight = array_sum( array_column( $wheel_rewards, 'weight' ) ); $position = 0; $stops = array(); $angle = 0; $colors = array( '#5758c9', '#9ba9e8', '#255b70', '#7eafaa', '#aa6b9e', '#e9bc84' ); foreach ( $wheel_rewards as $index => $reward ) { $next = $position + $reward['weight'] / $wheel_weight * 360; $stops[] = $colors[ $index % count( $colors ) ] . ' ' . round( $position, 3 ) . 'deg ' . round( $next, 3 ) . 'deg'; if ( $result && $index === $result['outcome'] ) { $angle = 0 - ( $position + $next ) / 2; } $position = $next; } ?>

service->error( $error )->get_error_message() ); ?>

time() ) : ?>

service->error( $reason )->get_error_message() ); ?>