config = $config; $this->fields = $fields; $this->spam = $spam; $this->submission = $submission; $this->own = $own; $this->templates = $templates; $this->assets = $assets; } /** * Form block for a product: a form, the reviewer's own review to edit, or a notice. * * @param \WC_Product $product Product. * @param Summary|null $summary Product summary, for the heading. * @param string $redirect_to Where the no-JavaScript form returns after posting; the * product page when empty (the Review Form block passes * its own page). */ public function render( \WC_Product $product, ?Summary $summary = null, string $redirect_to = '' ): string { if ( ! $product->get_reviews_allowed() || ! comments_open( $product->get_id() ) ) { return ''; } $user_id = get_current_user_id(); $token = self::query_token(); $link = '' !== $token ? OrderLinkField::link( $token, $product ) : null; $existing = $user_id > 0 ? $this->own->find( $product->get_id(), $user_id ) : $this->guest_pending_review( $product->get_id() ); if ( null === $existing && 0 === $user_id && null !== $link ) { $existing = $this->own->find( $product->get_id(), 0, (string) $link['email'] ); } // The signed link from a request email lets anyone update the review they wrote for it. $link_edit = null !== $link && null !== $existing; $editable = null !== $existing && ( $link_edit || ( $user_id > 0 && $this->can_edit( $existing, $user_id ) ) ); $context = $this->submission->context( $product, $user_id, $editable ? $existing : null ); if ( null !== $link ) { $context->token = $token; } $stars = self::query_stars(); if ( ! $editable && $stars > 0 ) { $context->values['rating'] = $stars; } $notice = $this->notice( $existing, $editable ); $can = $this->submission->can_review( $product, $user_id, 0 === $user_id ? $link : null ); $show = null === $notice['submitted'] && ( null === $existing || $editable ) && ! ( $can instanceof \WP_Error && 'wpipr_login_required' !== $can->get_error_code() ); $restrict = $can instanceof \WP_Error && 'wpipr_verified_only' === $can->get_error_code() ? $can->get_error_message() : ''; if ( $show ) { $this->enqueue(); } $main = $show ? $this->area_html( $context, FormField::AREA_MAIN ) : ''; $this->after = $show ? $this->area_html( $context, FormField::AREA_AFTER ) . $this->spam->render( $context ) . ClassicForm::hidden_fields( $product->get_id() ) : null; if ( null !== $this->after && '' !== $redirect_to ) { // wp-comments-post.php returns here instead of to the product page. $this->after .= ''; } add_filter( 'comment_form_submit_field', array( $this, 'submit_field' ) ); $html = $this->templates->render( 'reviews/form.php', array( 'product' => $product, 'context' => $context, 'show_form' => $show, 'restriction' => $restrict, 'notice' => $notice, 'editing' => $editable ? $existing : null, 'mode' => $this->config->mode(), 'has_reviews' => null !== $summary && $summary->review_count > 0, 'main_html' => $main, 'config' => $show ? $this->script_config( $context, ! $link_edit ) : array(), 'prefill' => 0 === $user_id && null !== $link ? array( 'author' => (string) $link['name'], 'email' => (string) $link['email'], ) : array(), 'account_url' => $user_id > 0 && function_exists( 'wc_get_account_endpoint_url' ) ? (string) wc_get_account_endpoint_url( MyReviews::ENDPOINT ) : '', ) ); remove_filter( 'comment_form_submit_field', array( $this, 'submit_field' ) ); $this->after = null; // The form is printed through wp_kses(): scripts that comment_form() hooks print inside it go to the footer. return FooterScripts::extract( $html ); } /** * Fields of one area. * * @param FormContext $context Context. * @param string $area Area. * @param list $skip Field ids to leave out. */ public function area_html( FormContext $context, string $area, array $skip = array() ): string { $html = ''; foreach ( $this->fields->for_context( $context, $area ) as $field ) { if ( ! in_array( $field->id(), $skip, true ) ) { $html .= $field->render( $context ); } } return $html; } /** * The plugin's fields for another plugin's comment form (WooCommerce's Product Review Form * block), which keeps its own rating and text inputs. The trailing fields are added before * that form's submit button until the form ends. * * @param \WC_Product $product Product. */ public function embed_fields( \WC_Product $product ): string { $user_id = get_current_user_id(); if ( $this->submission->can_review( $product, $user_id ) instanceof \WP_Error || ( $user_id > 0 && null !== $this->own->find( $product->get_id(), $user_id ) ) ) { return ''; } $context = $this->submission->context( $product, $user_id ); $this->enqueue(); $this->after = '
' . $this->area_html( $context, FormField::AREA_AFTER ) . $this->spam->render( $context ) . ClassicForm::hidden_fields( $product->get_id() ) . '
'; add_filter( 'comment_form_submit_field', array( $this, 'submit_field' ) ); add_action( 'comment_form_after', array( $this, 'end_embed' ) ); return '
' . $this->area_html( $context, FormField::AREA_MAIN, array( 'rating', 'content' ) ) . '
' . ''; } /** * Stop adding fields once the embedding form has been printed. */ public function end_embed(): void { remove_filter( 'comment_form_submit_field', array( $this, 'submit_field' ) ); remove_action( 'comment_form_after', array( $this, 'end_embed' ) ); $this->after = null; } /** * Put the trailing fields (display name, consent, spam protection) before the submit button. * * @param string $submit_field Submit button markup. */ public function submit_field( $submit_field ): string { return null === $this->after ? (string) $submit_field : $this->after . '' . $submit_field; } /** * Load the form script and the CAPTCHA script. */ public function enqueue(): void { $this->assets->enqueue_style( Assets::FRONTEND_STYLE ); $this->assets->enqueue_script( self::SCRIPT ); $this->spam->enqueue(); /** * Fires when the review form's script and styles are enqueued. * * @since 1.0.3 */ do_action( 'wpipr_form_enqueued' ); } /** * Whether a signed-in author may change their review in the form. Reviews are final * once sent unless an add-on allows editing them. * * @param \WP_Comment $review The author's review. * @param int $user_id Author. */ private function can_edit( \WP_Comment $review, int $user_id ): bool { /** * Filter whether signed-in authors may edit their own review in the form. * * @since 1.0.3 * @param bool $editable Whether they may (false). * @param \WP_Comment $review The review. * @param int $user_id Author. */ return (bool) apply_filters( 'wpipr_can_edit_own_review', false, $review, $user_id ); } /** * Settings the form script needs for one form. * * @param FormContext $context Context. * @param bool $use_put Edit through PUT /reviews/{id} (signed-in authors, where editing * is allowed). Reviews opened from an email link are updated by * posting the form again. * @return array */ public function script_config( FormContext $context, bool $use_put = true ): array { $product_id = $context->product->get_id(); $captcha = $this->spam->captcha(); $review_id = null !== $context->review && $use_put ? (int) $context->review->comment_ID : 0; $config = array( 'product' => $product_id, 'review' => $review_id, 'auth' => $context->user_id > 0, 'submitUrl' => $review_id > 0 ? rest_url( 'wpinterface-product-reviews/v1/reviews/' . $review_id ) : rest_url( 'wpinterface-product-reviews/v1/products/' . $product_id . '/reviews' ), 'method' => $review_id > 0 ? 'PUT' : 'POST', 'deleteUrl' => $review_id > 0 ? rest_url( 'wpinterface-product-reviews/v1/reviews/' . $review_id ) : '', 'mediaUrl' => rest_url( 'wpinterface-product-reviews/v1/media' ), 'tokenUrl' => add_query_arg( 'product', $product_id, rest_url( 'wpinterface-product-reviews/v1/nonce' ) ), 'restNonceUrl' => admin_url( 'admin-ajax.php?action=rest-nonce' ), 'mode' => $this->config->mode(), 'dialogClass' => '', 'captcha' => null === $captcha ? '' : $captcha->id(), 'captchaKey' => null === $captcha ? '' : $this->config->captcha_site_key(), 'media' => array( 'photos' => $this->config->max_photos(), 'videos' => $this->config->max_videos(), 'photoBytes' => $this->config->max_photo_bytes(), 'videoBytes' => $this->config->max_video_bytes(), 'videoSeconds' => $this->config->max_video_seconds(), 'maxEdge' => FormConfig::MAX_IMAGE_EDGE, 'photoTypes' => array_values( array_unique( $this->config->photo_mimes() ) ), 'videoTypes' => array_values( array_unique( $this->config->video_mimes() ) ), ), ); /** * Filter the settings the form script gets for one form. * * @since 1.0.3 * @param array $config Settings. * @param FormContext $context Form context. */ $filtered = apply_filters( 'wpipr_form_script_config', $config, $context ); return is_array( $filtered ) ? $filtered : $config; } /** * What to tell the visitor above (or instead of) the form. * * @param \WP_Comment|null $existing The visitor's review of this product. * @param bool $editable Whether they may edit it here. * @return array{submitted: ?string, message: string, own: ?\WP_Comment} */ private function notice( ?\WP_Comment $existing, bool $editable ): array { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only flag set by the redirect after posting. $submitted = isset( $_GET['wpipr_submitted'] ) ? sanitize_key( wp_unslash( $_GET['wpipr_submitted'] ) ) : ''; if ( in_array( $submitted, array( 'approved', 'pending' ), true ) ) { return array( 'submitted' => $submitted, 'message' => 'approved' === $submitted ? $this->config->success_text() : $this->config->pending_text(), 'own' => $existing, ); } $message = ''; if ( null !== $existing ) { $pending = '1' !== (string) $existing->comment_approved; if ( $editable ) { $message = $pending ? __( 'Your review is awaiting approval. You can still change it below.', 'wpinterface-product-reviews' ) : ''; } else { $message = $pending ? __( 'Thanks for your review. It is awaiting approval.', 'wpinterface-product-reviews' ) /* translators: %s: date of the review. */ : sprintf( __( 'You reviewed this product on %s.', 'wpinterface-product-reviews' ), (string) wp_date( (string) get_option( 'date_format' ), (int) strtotime( $existing->comment_date_gmt . ' UTC' ) ) ); } } return array( 'submitted' => null, 'message' => $message, 'own' => $existing, ); } /** * Review link token in the URL, when it is well formed. */ public static function query_token(): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Signed token, verified before use. $token = isset( $_GET[ OrderLinkField::INPUT ] ) && is_string( $_GET[ OrderLinkField::INPUT ] ) ? sanitize_text_field( wp_unslash( $_GET[ OrderLinkField::INPUT ] ) ) : ''; return 1 === preg_match( '/^\d{1,20}\.\d{9,11}\.[A-Za-z0-9_-]{43}$/', $token ) ? $token : ''; } /** * Rating chosen by clicking a star in the email (1–5), or 0. */ private static function query_stars(): int { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Pre-selects a form value only. $stars = isset( $_GET['wpipr_stars'] ) && is_scalar( $_GET['wpipr_stars'] ) ? absint( $_GET['wpipr_stars'] ) : 0; return $stars >= 1 && $stars <= 5 ? $stars : 0; } /** * A guest's pending review of this product, from WordPress's unapproved-comment cookie or link. * * @param int $product_id Product id. */ private function guest_pending_review( int $product_id ): ?\WP_Comment { $email = function_exists( 'wp_get_unapproved_comment_author_email' ) ? wp_get_unapproved_comment_author_email() : ''; if ( '' === $email ) { return null; } $review = $this->own->find( $product_id, 0, $email ); return null !== $review && '1' !== (string) $review->comment_approved ? $review : null; } }