repo = $repo;
$this->settings = $settings;
}
/**
* Hooks registration form handling and shortcode.
*
* @since 1.0.0
*/
public function register() {
add_shortcode( 'ppl_afw_registration', array( $this, 'registration_shortcode' ) );
add_action( 'admin_post_ppl_afw_register', array( $this, 'handle_registration' ) );
add_filter( 'woocommerce_login_redirect', array( $this, 'login_redirect' ), 10, 1 );
}
/**
* Sends a customer back to the page they logged in from (the affiliate
* page) when a safe redirect_to was passed on the My Account URL.
*
* @since 1.0.0
* @param string $redirect Default WooCommerce redirect.
* @return string
*/
public function login_redirect( $redirect ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only redirect target validated below.
$target = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : '';
if ( $target ) {
$safe = wp_validate_redirect( $target, '' );
if ( $safe ) {
return $safe;
}
}
return $redirect;
}
/**
* Returns the repository.
*
* @since 1.0.0
* @return Affiliate_Repository
*/
public function repository() {
return $this->repo;
}
/**
* Renders the registration form shortcode.
*
* @since 1.0.0
* @return string
*/
public function registration_shortcode() {
$this->enqueue_styles();
if ( ! is_user_logged_in() ) {
$login_url = $this->login_url();
$register_url = $this->register_url();
$intro = '
' . esc_html__( 'Earn commission by referring customers to our store. Log in to apply for the affiliate program.', 'affireo-affiliates-for-woocommerce' ) . '
';
$intro .= '' . esc_html__( 'Log in to apply', 'affireo-affiliates-for-woocommerce' ) . '';
if ( $register_url ) {
$intro .= ' ' . esc_html__( 'Create an account', 'affireo-affiliates-for-woocommerce' ) . '';
}
$intro .= '
';
/**
* Filters what a visitor who is not logged in sees in place of
* the signup form, for example a form that also creates the
* account.
*
* @since 1.3.0
* @param string $intro Escaped HTML: the login and create account buttons.
* @param string $login_url Login URL.
* @param string $register_url Account creation URL, empty when registration is off.
*/
$intro = (string) apply_filters( 'ppl_afw_registration_logged_out', $intro, $login_url, $register_url );
return '' . $intro . '
';
}
$existing = $this->repo->find_by_user( get_current_user_id() );
if ( $existing ) {
$messages = array(
'pending' => __( 'Thanks for applying! Your application is under review and we will email you once it is approved.', 'affireo-affiliates-for-woocommerce' ),
'active' => __( 'You are an active affiliate.', 'affireo-affiliates-for-woocommerce' ),
'rejected' => __( 'Your application was not approved. Contact us if you believe this is a mistake.', 'affireo-affiliates-for-woocommerce' ),
'suspended' => __( 'Your affiliate account is currently suspended.', 'affireo-affiliates-for-woocommerce' ),
);
$link = '';
$page = (int) $this->settings->get( 'portal_page_id' );
if ( 'active' === $existing->status && $page && (int) get_queried_object_id() !== $page ) {
$link = '' . esc_html__( 'Open your affiliate dashboard', 'affireo-affiliates-for-woocommerce' ) . '
';
}
return '' . esc_html( $messages[ $existing->status ] ?? '' ) . '
' . $link . '
';
}
return '' . $this->registration_form_html() . '
';
}
/**
* The registration form markup, from the overridable template.
*
* @since 1.3.0
* @return string
*/
public function registration_form_html() {
$this->enqueue_styles();
ob_start();
$template = $this->locate_template( 'registration-form.php' );
include $template;
return ob_get_clean();
}
/**
* The login URL, preferring the WooCommerce My Account page so customers
* use the same account they shop with, and returning to the affiliate
* page after signing in.
*
* @since 1.0.0
* @return string
*/
private function login_url() {
$url = '';
if ( function_exists( 'wc_get_page_permalink' ) ) {
$account = wc_get_page_permalink( 'myaccount' );
if ( $account && ! is_wp_error( $account ) ) {
$url = add_query_arg( 'redirect_to', rawurlencode( get_permalink() ?: home_url( '/' ) ), $account );
}
}
if ( ! $url ) {
$url = wp_login_url( get_permalink() );
}
/**
* Filters the affiliate signup login URL.
*
* @since 1.0.0
* @param string $url Login URL.
*/
return apply_filters( 'ppl_afw_login_url', $url );
}
/**
* The account creation URL. Prefers the WooCommerce My Account page when
* WooCommerce registration is on, otherwise the WordPress register page.
*
* @since 1.0.0
* @return string Empty when registration is disabled everywhere.
*/
private function register_url() {
$url = '';
if ( function_exists( 'wc_get_page_permalink' ) && 'yes' === get_option( 'woocommerce_enable_myaccount_registration' ) ) {
$account = wc_get_page_permalink( 'myaccount' );
if ( $account && ! is_wp_error( $account ) ) {
$url = $account;
}
}
if ( ! $url && get_option( 'users_can_register' ) ) {
$url = wp_registration_url();
}
/**
* Filters the affiliate signup account creation URL.
*
* @since 1.0.0
* @param string $url Registration URL, empty when disabled.
*/
return apply_filters( 'ppl_afw_register_url', $url );
}
/**
* Enqueues the signup styles for markup printed outside the shortcode.
*
* @since 1.3.0
*/
public function registration_styles() {
$this->enqueue_styles();
}
/**
* Enqueues the shared portal stylesheet so the registration form and
* gate messages are styled with the store's accent color.
*
* @since 1.0.0
*/
private function enqueue_styles() {
$css = PPL_AFW_DIR . 'assets/css/portal.css';
wp_enqueue_style( 'ppl-afw-portal', PPL_AFW_URL . 'assets/css/portal.css', array(), file_exists( $css ) ? md5_file( $css ) : PPL_AFW_VERSION );
$primary = sanitize_hex_color( (string) $this->settings->get( 'portal_primary_color' ) ) ?: '#4f46e5';
wp_add_inline_style( 'ppl-afw-portal', ':root{--ppl-afw-primary:' . $primary . ';}' );
}
/**
* Handles the registration form POST.
*
* @since 1.0.0
*/
public function handle_registration() {
if ( ! is_user_logged_in() || ! isset( $_POST['ppl_afw_register_nonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['ppl_afw_register_nonce'] ) ), 'ppl_afw_register' ) ) {
wp_die( esc_html__( 'Security check failed.', 'affireo-affiliates-for-woocommerce' ) );
}
$user_id = get_current_user_id();
if ( $this->rate_limited( 'register' ) ) {
wp_die( esc_html__( 'Too many attempts. Please try again later.', 'affireo-affiliates-for-woocommerce' ) );
}
if ( $this->repo->find_by_user( $user_id ) ) {
wp_safe_redirect( wp_get_referer() ?: home_url() );
exit;
}
$redirect = wp_get_referer() ?: home_url();
$fields = $this->collect_registration_fields();
if ( is_wp_error( $fields ) ) {
wp_safe_redirect( add_query_arg( 'ppl_afw_reg_error', rawurlencode( $fields->get_error_message() ), $redirect ) );
exit;
}
$this->register_application( $user_id, $fields );
wp_safe_redirect( add_query_arg( 'ppl_afw_registered', '1', $redirect ) );
exit;
}
/**
* Reads and validates the posted signup answers and terms. The caller
* verifies the nonce.
*
* @since 1.3.0
* @return array|\WP_Error Answers by field key, or the first error.
*/
public function collect_registration_fields() {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- verified by the callers.
if ( empty( $_POST['ppl_afw_terms'] ) ) {
return new \WP_Error( 'terms', __( 'You must accept the program terms.', 'affireo-affiliates-for-woocommerce' ) );
}
$fields = array();
foreach ( (array) $this->settings->get( 'registration_fields' ) as $field ) {
$field = is_array( $field ) ? $field : array( 'key' => $field );
$key = sanitize_key( $field['key'] ?? '' );
if ( '' === $key ) {
continue;
}
$raw = isset( $_POST[ 'ppl_afw_' . $key ] ) ? sanitize_textarea_field( wp_unslash( $_POST[ 'ppl_afw_' . $key ] ) ) : '';
$value = 'textarea' === ( $field['type'] ?? '' ) ? $raw : sanitize_text_field( $raw );
if ( ! empty( $field['required'] ) && '' === trim( $value ) ) {
return new \WP_Error(
'required',
sprintf(
/* translators: %s: field label */
__( 'Please fill in the %s field.', 'affireo-affiliates-for-woocommerce' ),
\PPL_AFW\Core\Settings::field_label( $field )
)
);
}
$fields[ $key ] = $value;
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
$fields['terms_version'] = (int) $this->settings->get( 'terms_version' );
return $fields;
}
/**
* Creates the application of a user and announces it.
*
* @since 1.3.0
* @param int $user_id User id.
* @param array $fields Signup answers.
* @return int Affiliate id.
*/
public function register_application( $user_id, array $fields ) {
$id = $this->create_affiliate( $user_id, $fields );
/**
* Fires after a new affiliate application is submitted.
*
* @since 1.0.0
* @param int $id Affiliate id.
* @param array $fields Submitted registration fields.
*/
do_action( 'ppl_afw_affiliate_registered', $id, $fields );
return $id;
}
/**
* Creates an affiliate record for a user.
*
* @since 1.0.0
* @param int $user_id User id.
* @param array $fields Registration data.
* @return int Affiliate id.
*/
public function create_affiliate( $user_id, array $fields = array() ) {
$auto = (bool) $this->settings->get( 'auto_approve_affiliates' );
$status = $auto ? 'active' : 'pending';
$id = $this->repo->insert(
array(
'user_id' => absint( $user_id ),
'status' => $status,
'slug' => $this->generate_slug( $user_id ),
'registration_data' => wp_json_encode( $fields ),
'updated_at' => current_time( 'mysql', true ),
)
);
$this->repo->log_activity( $id, 'registered', $auto ? 'Auto approved on registration' : 'Application submitted' );
if ( $auto ) {
/** This action is documented in includes/affiliates/class-ppl-afw-affiliates-service.php */
do_action( 'ppl_afw_affiliate_approved', $id );
}
return $id;
}
/**
* Changes an affiliate status with audit logging.
*
* @since 1.0.0
* @param int $id Affiliate id.
* @param string $status New status.
* @param string $note Reason note.
* @return bool
*/
public function set_status( $id, $status, $note = '' ) {
if ( ! in_array( $status, array( 'pending', 'active', 'rejected', 'suspended' ), true ) ) {
return false;
}
$this->repo->update( $id, array( 'status' => $status ) );
$this->repo->log_activity( $id, 'status_' . $status, $note );
if ( 'active' === $status ) {
/**
* Fires when an affiliate is approved or reactivated.
*
* @since 1.0.0
* @param int $id Affiliate id.
*/
do_action( 'ppl_afw_affiliate_approved', $id );
} elseif ( 'rejected' === $status ) {
/**
* Fires when an affiliate application is rejected.
*
* @since 1.0.0
* @param int $id Affiliate id.
* @param string $note Rejection reason.
*/
do_action( 'ppl_afw_affiliate_rejected', $id, $note );
}
return true;
}
/**
* Validates and updates an affiliate's vanity slug.
*
* @since 1.0.0
* @param int $id Affiliate id.
* @param string $slug Requested slug.
* @return true|\WP_Error
*/
public function set_slug( $id, $slug ) {
$slug = strtolower( trim( $slug ) );
if ( ! preg_match( '/^[a-z0-9-]{3,32}$/', $slug ) ) {
return new \WP_Error( 'invalid_slug', __( 'Slugs must be 3 to 32 characters: lowercase letters, numbers, and hyphens only.', 'affireo-affiliates-for-woocommerce' ) );
}
/**
* Filters the reserved slug blacklist.
*
* @since 1.0.0
* @param string[] $reserved Reserved slugs.
*/
$reserved = apply_filters( 'ppl_afw_reserved_slugs', self::RESERVED_SLUGS );
if ( in_array( $slug, $reserved, true ) ) {
return new \WP_Error( 'reserved_slug', __( 'That slug is reserved. Please choose another.', 'affireo-affiliates-for-woocommerce' ) );
}
if ( $this->repo->slug_exists( $slug, $id ) ) {
return new \WP_Error( 'taken_slug', __( 'That slug is already taken.', 'affireo-affiliates-for-woocommerce' ) );
}
$this->repo->update( $id, array( 'slug' => $slug ) );
$this->repo->log_activity( $id, 'slug_changed', $slug );
return true;
}
/**
* Generates a unique default slug from the user login.
*
* @since 1.0.0
* @param int $user_id User id.
* @return string
*/
private function generate_slug( $user_id ) {
$user = get_userdata( $user_id );
$base = $user ? preg_replace( '/[^a-z0-9-]/', '', strtolower( $user->user_login ) ) : 'aff';
$base = substr( str_pad( $base, 3, '0' ), 0, 26 );
$slug = $base;
$i = 1;
while ( $this->repo->slug_exists( $slug ) || in_array( $slug, self::RESERVED_SLUGS, true ) ) {
$slug = $base . '-' . $i++;
}
return $slug;
}
/**
* Transient based rate limiting keyed on hashed IP.
*
* @since 1.0.0
* @param string $action Action bucket.
* @param int $limit Allowed hits per window.
* @param int $window Window seconds.
* @return bool True when the caller is over the limit.
*/
public function rate_limited( $action, $limit = 5, $window = 300 ) {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$key = 'ppl_afw_rl_' . $action . '_' . substr( \PPL_AFW\Core\Crypto::hash( $ip ), 0, 24 );
$count = (int) get_transient( $key );
if ( $count >= $limit ) {
return true;
}
set_transient( $key, $count + 1, $window );
return false;
}
/**
* Locates a template, preferring theme overrides in yourtheme/ppl-afw/.
*
* @since 1.0.0
* @param string $name Template file name.
* @return string Absolute path.
*/
public function locate_template( $name ) {
$theme = locate_template( array( 'ppl-afw/' . $name ) );
return $theme ? $theme : PPL_AFW_DIR . 'templates/' . $name;
}
}