repository->all(), $context ); return array_values( array_filter( (array) $extras, static fn( mixed $extra ): bool => $extra instanceof Extra ) ); } /** * Whether every building block of the extra is registered (fail-closed otherwise, D8). * * @param Extra $extra Extra. */ public function is_supported( Extra $extra ): bool { if ( ! $this->types->has( $extra->type ) ) { return false; } foreach ( $this->pricing_methods_used( $extra ) as $method ) { if ( ! $this->pricing->has( $method ) ) { return false; } } if ( $this->evaluator->has_unknown( $extra->conditions ) ) { return false; } foreach ( $extra->locations as $location ) { if ( $this->locations->has( (string) $location ) ) { return true; } } return false; } /** * Ids of extras that cannot be evaluated (shown as a notice in the plugin screen). * * @return string[] */ public function unsupported_ids(): array { $ids = array(); foreach ( $this->repository->all() as $extra ) { if ( ! $this->is_supported( $extra ) ) { $ids[] = $extra->id; } } return $ids; } /** * Whether at least one active, supported extra exists for a context location * (used to decide whether to load frontend assets; no cart needed). * * @param string $location Context location. */ public function has_active_extras( string $location ): bool { foreach ( $this->repository->all() as $extra ) { if ( $extra->is_active() && $this->locations->matches( $extra->locations, $location ) && $this->is_supported( $extra ) ) { return true; } } return false; } /** * Visible extras keyed by id. * * Flow: active → location → registered blocks (D8) → rules → `extrova_extra_is_visible`. * * @param Context $context Context. * @return array */ public function get_visible_extras( Context $context ): array { $visible = array(); foreach ( $this->get_extras( $context ) as $extra ) { if ( ! $extra->is_active() || ! $this->locations->matches( $extra->locations, $context->location ) || ! $this->is_supported( $extra ) ) { continue; } $is_visible = $this->evaluator->passes( $extra->conditions, $context ); /** * Filters whether an extra is visible. * * @since 1.0.0 * * @param bool $visible Visible. * @param Extra $extra Extra. * @param Context $context Context. */ if ( apply_filters( 'extrova_extra_is_visible', $is_visible, $extra, $context ) ) { $visible[ $extra->id ] = $extra; } } return $visible; } /** * Stored selections for the given extras. * * @param array $extras Extras keyed by id. * @return array */ public function get_selections_for( array $extras ): array { $selections = array(); foreach ( $this->store->all() as $extra_id => $selection ) { if ( isset( $extras[ $extra_id ] ) ) { $selections[ $extra_id ] = $selection; } } return $selections; } /** * Visible extras that have a selection, as [ Extra, Selection ] pairs. * * @param Context $context Context. * @return array */ public function get_selected( Context $context ): array { $extras = $this->get_visible_extras( $context ); $pairs = array(); foreach ( $this->get_selections_for( $extras ) as $extra_id => $selection ) { $pairs[] = array( $extras[ $extra_id ], $selection ); } return $pairs; } /** * Price of a selection. * * @param Extra $extra Extra. * @param Selection $selection Selection. * @param Context $context Context. */ public function get_price( Extra $extra, Selection $selection, Context $context ): PriceResult { return $this->calculator->calculate( $extra, $selection, $context ); } /** * Prices shown before choosing: `preview key => PriceResult`. * * @param Extra $extra Extra. * @param Context $context Context. * @return array */ public function price_previews( Extra $extra, Context $context ): array { $type = $this->types->get( $extra->type ); if ( ! $type instanceof AbstractExtraType ) { return array(); } $previews = array(); foreach ( $type->preview_selections( $extra ) as $key => $value ) { $previews[ (string) $key ] = $this->calculator->calculate( $extra, new Selection( $extra->id, $value ), $context ); } return $previews; } /** * Fee name for a selection. * * @param Extra $extra Extra. * @param Selection $selection Selection. */ public function fee_name( Extra $extra, Selection $selection ): string { $type = $this->types->get( $extra->type ); return $type instanceof AbstractExtraType ? $type->fee_name( $extra, $selection ) : $extra->label; } /** * Human readable value. * * @param Extra $extra Extra. * @param Selection $selection Selection. */ public function display_value( Extra $extra, Selection $selection ): string { $type = $this->types->get( $extra->type ); return $type ? $type->display_value( $extra, $selection ) : ''; } /** * Validates one extra. * * @param Extra $extra Extra. * @param Selection|null $selection Selection. * @param Context $context Context. */ public function validate_extra( Extra $extra, ?Selection $selection, Context $context ): true|\WP_Error { $type = $this->types->get( $extra->type ); $result = $type ? $type->validate( $extra, $selection, $context ) : new \WP_Error( 'extrova_unknown_type', __( 'This option is not available right now.', 'extrova' ) ); /** * Filters the validation result of a selection. * * @since 1.0.0 * * @param true|\WP_Error $result Result. * @param Extra $extra Extra. * @param Selection|null $selection Selection. * @param Context $context Context. */ $result = apply_filters( 'extrova_validate_selection', $result, $extra, $selection, $context ); return $result instanceof \WP_Error ? $result : true; } /** * Validates every visible extra. Returns an empty WP_Error when everything is valid. * Error codes are `extrova_{extra_id}`. * * @param Context $context Context. */ public function validate_all( Context $context ): \WP_Error { $errors = new \WP_Error(); $extras = $this->get_visible_extras( $context ); $selections = $this->get_selections_for( $extras ); foreach ( $extras as $extra_id => $extra ) { $result = $this->validate_extra( $extra, $selections[ $extra_id ] ?? null, $context ); if ( $result instanceof \WP_Error ) { $errors->add( 'extrova_' . $extra_id, $result->get_error_message(), array( 'extra_id' => $extra_id ) ); } } return $errors; } /** * Updates selections (partial). Values for unknown or hidden extras are discarded; * a null/empty sanitised value removes the selection. * * @param array $raw `extra_id => raw value`. * @param Context $context Context. * @param string $source `store_api` or `classic`. * @return \WP_Error|array New selections. */ public function set_selections( array $raw, Context $context, string $source ): \WP_Error|array { $previous = $this->store->all(); $visible = $this->get_visible_extras( $context ); $next = $previous; foreach ( $raw as $extra_id => $value ) { $extra_id = (string) $extra_id; if ( ! isset( $visible[ $extra_id ] ) ) { continue; } $extra = $visible[ $extra_id ]; $type = $this->types->get( $extra->type ); if ( ! $type ) { continue; } $clean = $type->sanitize_value( $value, $extra ); /** * Filters a sanitised customer value. * * @since 1.0.0 * * @param mixed $value Sanitised value. * @param mixed $raw Raw value. * @param Extra $extra Extra. */ $clean = apply_filters( 'extrova_sanitized_value', $clean, $value, $extra ); if ( null === $clean || false === $clean || '' === $clean || array() === $clean ) { unset( $next[ $extra_id ] ); } else { $next[ $extra_id ] = new Selection( $extra_id, $clean, time() ); } } // Drop selections of extras that no longer exist. $existing = array(); foreach ( $this->repository->all() as $extra ) { $existing[ $extra->id ] = true; } $next = array_intersect_key( $next, $existing ); $this->store->replace( $next ); /** * Fires after the customer's selections change. * * @since 1.0.0 * * @param array $selections New selections. * @param array $previous Previous selections. * @param string $source `store_api` or `classic`. * @param Context $context Context. */ do_action( 'extrova_selections_updated', $next, $previous, $source, $context ); return $next; } /** * Clears every selection. * * @param Context $context Context. * @param string $source Source. */ public function clear_selections( Context $context, string $source ): void { $previous = $this->store->all(); $this->store->clear(); /** This action is documented in src/Core/ExtraService.php */ do_action( 'extrova_selections_updated', array(), $previous, $source, $context ); } /** * Context parts the extra visibility depends on. * * @param Extra $extra Extra. * @return string[] */ public function rule_dependencies( Extra $extra ): array { return $this->evaluator->dependencies( $extra->conditions ); } /** * Pricing method slugs used by the extra (extra-level and per option). * * @param Extra $extra Extra. * @return string[] */ private function pricing_methods_used( Extra $extra ): array { $methods = array(); if ( 'tip' !== $extra->type && ! empty( $extra->pricing['method'] ) ) { $methods[] = (string) $extra->pricing['method']; } foreach ( (array) ( $extra->type_config['options'] ?? array() ) as $option ) { if ( is_array( $option ) && ! empty( $option['pricing']['method'] ) ) { $methods[] = (string) $option['pricing']['method']; } } return array_values( array_unique( $methods ) ); } }