settings->get()['classic']['position'] ?? 'before_order_review' ); return 'after_customer_details' === $position ? 'woocommerce_checkout_after_customer_details' : 'woocommerce_checkout_before_order_review'; } /** * Context for the classic checkout. */ private function context(): Context { return $this->contexts->current( 'checkout_classic' ); } /** * Renders the extras container (outside #order_review, so AJAX refreshes keep focus). */ public function render_extras(): void { echo $this->extras_html( $this->context() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built from escaped template output and filtered with wp_kses(). } /** * Renders the goal container. Refreshed through order review fragments. */ public function render_goal(): void { echo $this->goal_html( $this->context() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Template output, escaped inside. } /** * Stores the selection sent by `update_order_review` (`woocommerce_checkout_update_order_review`). * * @param string $post_data Serialized form (already unslashed by WooCommerce). */ public function on_update_order_review( $post_data ): void { $data = array(); wp_parse_str( is_string( $post_data ) ? $post_data : '', $data ); $this->client_hash = isset( $data['extrova_hash'] ) && is_string( $data['extrova_hash'] ) ? sanitize_key( $data['extrova_hash'] ) : null; if ( empty( $data['extrova_present'] ) ) { return; } $raw = isset( $data[ self::FIELD ] ) && is_array( $data[ self::FIELD ] ) ? $data[ self::FIELD ] : array(); $this->store_posted( $raw, $this->context() ); } /** * Adds fragments: the goal always, the extras only when the visible set changed. * * @param array $fragments Fragments. */ public function fragments( $fragments ): array { $fragments = is_array( $fragments ) ? $fragments : array(); $context = $this->context(); $fragments['.extrova-classic-goal'] = $this->goal_html( $context ); if ( null !== $this->client_hash && $this->client_hash !== $this->visible_hash( $context ) ) { $fragments['.extrova-classic-extras'] = $this->extras_html( $context ); } return $fragments; } /** * Syncs the posted values on submit and recalculates when they changed (`woocommerce_checkout_process`). */ public function on_checkout_process(): void { // phpcs:disable WordPress.Security.NonceVerification.Missing -- WooCommerce verified woocommerce-process-checkout-nonce before this hook. if ( empty( $_POST['extrova_present'] ) ) { return; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Each value is sanitised by its extra type in ExtraService::set_selections(). $raw = isset( $_POST[ self::FIELD ] ) && is_array( $_POST[ self::FIELD ] ) ? self::unslash_deep( wp_unslash( $_POST[ self::FIELD ] ) ) : array(); // phpcs:enable WordPress.Security.NonceVerification.Missing $before = $this->selection_fingerprint(); $this->store_posted( $raw, $this->context() ); if ( $before !== $this->selection_fingerprint() && WC()->cart ) { WC()->cart->calculate_totals(); } } /** * Adds validation errors (`woocommerce_after_checkout_validation`). * * @param array $data Posted data. * @param \WP_Error $errors Errors. */ public function after_validation( $data, $errors ): void { if ( ! $errors instanceof \WP_Error ) { return; } $result = $this->service->validate_all( $this->context() ); foreach ( $result->get_error_codes() as $code ) { $data = $result->get_error_data( $code ); $extra_id = is_array( $data ) && isset( $data['extra_id'] ) ? (string) $data['extra_id'] : ''; foreach ( $result->get_error_messages( $code ) as $message ) { // `id` links the notice to the field: WooCommerce turns it into a link and marks // the control with aria-invalid (checkout.js show_inline_errors()). $errors->add( (string) $code, $message, '' !== $extra_id ? array( 'id' => 'extrova-' . $extra_id ) : null ); } } } /** * Stores posted values for every visible extra (a missing field means "unselected"). * * @param array $raw Posted values keyed by extra id. * @param Context $context Context. */ private function store_posted( array $raw, Context $context ): void { $values = array(); foreach ( array_keys( $this->service->get_visible_extras( $context ) ) as $extra_id ) { $values[ $extra_id ] = $raw[ $extra_id ] ?? null; } $this->service->set_selections( $values, $context, 'classic' ); } /** * Fingerprint of the stored selections. */ private function selection_fingerprint(): string { $values = array(); foreach ( $this->service->get_selections_for( $this->service->get_visible_extras( $this->context() ) ) as $id => $selection ) { $values[ $id ] = $selection->value; } return md5( (string) wp_json_encode( $values ) ); } /** * Hash of the visible extras set. * * @param Context $context Context. */ private function visible_hash( Context $context ): string { return substr( md5( implode( ',', array_keys( $this->service->get_visible_extras( $context ) ) ) ), 0, 12 ); } /** * Extras container markup. * * @param Context $context Context. */ private function extras_html( Context $context ): string { $extras = $this->service->get_visible_extras( $context ); $selections = $this->service->get_selections_for( $extras ); $previews = array(); foreach ( $extras as $extra_id => $extra ) { $previews[ $extra_id ] = $this->service->price_previews( $extra, $context ); } $context = $context->with_offered( $extras )->with_data( 'price_previews', $previews ); $markup = array(); foreach ( $extras as $extra_id => $extra ) { $type = $this->types->get( $extra->type ); if ( $type ) { $markup[] = wp_kses( $type->render_classic( $extra, $selections[ $extra_id ] ?? null, $context ), self::allowed_html() ); } } ob_start(); wc_get_template( 'classic/extras.php', array( 'extras_markup' => $markup, 'hash' => $this->visible_hash( $context ), ), 'extrova/', EXTROVA_PATH . 'templates/' ); return (string) ob_get_clean(); } /** * Goal container markup. * * @param Context $context Context. */ private function goal_html( Context $context ): string { $goals = $this->goals->evaluate_all( $context ); ob_start(); wc_get_template( 'classic/free-shipping-bar.php', array( 'goals' => $goals ), 'extrova/', EXTROVA_PATH . 'templates/' ); return (string) ob_get_clean(); } /** * Keeps only strings and arrays of strings from posted data. * * @param mixed $value Value. * @return mixed */ private static function unslash_deep( mixed $value ): mixed { if ( is_array( $value ) ) { return array_map( array( self::class, 'unslash_deep' ), $value ); } return is_scalar( $value ) ? (string) $value : null; } /** * Tags and attributes allowed in the classic extras markup. */ public static function allowed_html(): array { $common = array( 'class' => true, 'id' => true, 'hidden' => true, 'aria-describedby' => true, 'aria-required' => true, 'aria-controls' => true, 'aria-live' => true, 'aria-hidden' => true, 'data-*' => true, ); $allowed = array( 'div' => $common, 'p' => $common, 'span' => $common, 'ul' => $common, 'li' => $common, 'bdi' => $common, 'fieldset' => $common, 'legend' => $common, 'abbr' => array_merge( $common, array( 'title' => true ) ), 'label' => array_merge( $common, array( 'for' => true ) ), 'input' => array_merge( $common, array( 'type' => true, 'name' => true, 'value' => true, 'checked' => true, 'min' => true, 'max' => true, 'step' => true, 'maxlength' => true, 'placeholder' => true, 'inputmode' => true, ) ), 'textarea' => array_merge( $common, array( 'name' => true, 'rows' => true, 'maxlength' => true, 'placeholder' => true, ) ), 'select' => array_merge( $common, array( 'name' => true ) ), 'option' => array( 'value' => true, 'selected' => true, ), ); /** * Filters the tags and attributes allowed (wp_kses()) in the classic checkout markup of * the extras. Lets add-on types keep what they need, e.g. `img` for image options or * `accept` on a file input. Values are still escaped by each type. * * @since 1.0.0 * * @param array $allowed Allowed tags => attributes, in wp_kses() format. */ $filtered = apply_filters( 'extrova_classic_allowed_html', $allowed ); return is_array( $filtered ) ? $filtered : $allowed; } }