type, where no entry means a dropdown), each term's colour and image (term meta * `swatchline_color` and `swatchline_image`), the swatches a product's attribute shows, and the * compact list of a variable product's variations the product page uses to cross out what can't * be bought. */ class Swatches { const TYPES_OPTION = 'swatchline_attribute_types'; const COLOR_META = 'swatchline_color'; const IMAGE_META = 'swatchline_image'; /** The types the product page can draw. Any other type (an add-on's, when it's inactive) is a dropdown. */ const DRAWN = array( 'color', 'image', 'label' ); /** @return array type => label, for the attribute and product screens. */ public static function types(): array { $own = array( 'select' => __( 'Dropdown', 'swatchline' ), 'color' => __( 'Colour', 'swatchline' ), 'image' => __( 'Image', 'swatchline' ), 'label' => __( 'Label', 'swatchline' ), ); /** * The swatch types offered on the attribute screen (type => label). An add-on that adds a * type turns it into colours, images or labels in `swatchline_attribute_swatches`. * * @param array $types */ $types = (array) apply_filters( 'swatchline_swatch_types', $own ); return array_merge( $types, $own ); } /** The stored type of a global attribute ('select' when none is set). */ public static function attribute_type( int $attribute_id ): string { $types = get_option( self::TYPES_OPTION, array() ); $type = is_array( $types ) && isset( $types[ $attribute_id ] ) && is_string( $types[ $attribute_id ] ) ? $types[ $attribute_id ] : ''; return '' !== $type ? $type : 'select'; } /** * Saves a global attribute's type. A type no add-on offers right now is kept when it's the one * already stored (an inactive add-on's, so reactivating the add-on finds it), and never newly set. */ public static function set_attribute_type( int $attribute_id, string $type ): void { $types = get_option( self::TYPES_OPTION, array() ); $types = is_array( $types ) ? $types : array(); $type = sanitize_key( $type ); $stored = isset( $types[ $attribute_id ] ) && is_string( $types[ $attribute_id ] ) ? $types[ $attribute_id ] : ''; if ( '' === $type || 'select' === $type || ( ! array_key_exists( $type, self::types() ) && $type !== $stored ) ) { unset( $types[ $attribute_id ] ); } else { $types[ $attribute_id ] = $type; } if ( $types ) { update_option( self::TYPES_OPTION, $types ); } else { delete_option( self::TYPES_OPTION ); } } /** The type of an attribute taxonomy (pa_colour), or 'select' for anything else. */ public static function taxonomy_type( string $taxonomy ): string { if ( ! taxonomy_is_product_attribute( $taxonomy ) ) { return 'select'; } $id = (int) wc_attribute_taxonomy_id_by_name( $taxonomy ); return $id ? self::attribute_type( $id ) : 'select'; } /** A term's swatch colour (#rrggbb, or '' when none is set). */ public static function term_color( int $term_id ): string { return (string) sanitize_hex_color( (string) get_term_meta( $term_id, self::COLOR_META, true ) ); } /** A term's swatch image (an attachment id, 0 when none is set). */ public static function term_image( int $term_id ): int { $image = absint( get_term_meta( $term_id, self::IMAGE_META, true ) ); return $image && wp_attachment_is_image( $image ) ? $image : 0; } /** Saves a term's colour and image; empty values delete them. */ public static function set_term_swatch( int $term_id, string $color, int $image ): void { $color = (string) sanitize_hex_color( $color ); if ( '' !== $color ) { update_term_meta( $term_id, self::COLOR_META, strtolower( $color ) ); } else { delete_term_meta( $term_id, self::COLOR_META ); } if ( $image > 0 && wp_attachment_is_image( $image ) ) { update_term_meta( $term_id, self::IMAGE_META, $image ); } else { delete_term_meta( $term_id, self::IMAGE_META ); } } /** * A value's name as WooCommerce's dropdown shows it. * * @param \WP_Term|null $term */ private static function option_name( string $name, $term, string $attribute, WC_Product $product ): string { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce's filter, applied as its dropdown does. return (string) apply_filters( 'woocommerce_variation_option_name', $name, $term, $attribute, $product ); } /** The field name of an attribute in the add-to-cart form (attribute_pa_colour). */ public static function field_name( string $attribute ): string { return 'attribute_' . sanitize_title( $attribute ); } /** * The swatches one attribute of a product shows, or null when it stays a dropdown. * * @param WC_Product $product The variable product. * @param string $attribute The attribute: a taxonomy (pa_colour) or a custom attribute's name. * @param array $options Its values for the product, as WooCommerce passes them to the dropdown. * @return array|null `attribute`, `name` (form field), `label`, `type` and `options` (`value`, `label`, * `colors` (#rrggbb list, one or two), `image` (attachment id), `term_id`). */ public static function for_attribute( WC_Product $product, string $attribute, array $options ): ?array { $is_taxonomy = taxonomy_exists( $attribute ); if ( $is_taxonomy ) { $type = self::taxonomy_type( $attribute ); } else { $type = Settings::custom_attributes() ? 'label' : 'select'; } /** * The swatch type of an attribute on a product, before its swatches are built. * * @param string $type 'select', 'color', 'image', 'label' or an add-on's type. * @param string $attribute Taxonomy (pa_colour) or custom attribute name. * @param WC_Product $product */ $type = (string) apply_filters( 'swatchline_attribute_type', $type, $attribute, $product ); if ( 'select' === $type || '' === $type ) { return null; } $built = array(); if ( $is_taxonomy ) { $wanted = array_map( 'strval', $options ); $terms = wc_get_product_terms( $product->get_id(), $attribute, array( 'fields' => 'all' ) ); if ( $terms ) { update_meta_cache( 'term', wp_list_pluck( $terms, 'term_id' ) ); } foreach ( $terms as $term ) { if ( ! in_array( $term->slug, $wanted, true ) ) { continue; } $color = self::term_color( $term->term_id ); $built[] = array( 'value' => $term->slug, 'label' => self::option_name( $term->name, $term, $attribute, $product ), 'colors' => '' !== $color ? array( $color ) : array(), 'image' => self::term_image( $term->term_id ), 'term_id' => (int) $term->term_id, ); } } else { foreach ( $options as $option ) { $built[] = array( 'value' => (string) $option, 'label' => self::option_name( (string) $option, null, $attribute, $product ), 'colors' => array(), 'image' => 0, 'term_id' => 0, ); } } $swatches = array( 'attribute' => $attribute, 'name' => self::field_name( $attribute ), 'label' => wc_attribute_label( $attribute, $product ), 'type' => $type, 'options' => $built, ); /** * The swatches an attribute shows on a product, or null for a dropdown. An add-on can change * the type and each option's `colors` (one or two), `image` (attachment id) and `label`. * * @param array|null $swatches * @param WC_Product $product * @param string $attribute */ $swatches = apply_filters( 'swatchline_attribute_swatches', $swatches, $product, $attribute ); if ( ! is_array( $swatches ) || ! isset( $swatches['type'], $swatches['options'] ) || ! in_array( $swatches['type'], self::DRAWN, true ) || ! is_array( $swatches['options'] ) || ! $swatches['options'] ) { return null; } return $swatches; } /** * The product's variations that can be bought, compactly: which values each one has ('' for * "any") and whether it's in stock. Read from primed caches (two queries for any number of * variations), so it's there also above WooCommerce's AJAX variation threshold. * * @return array, in_stock: bool}> */ public static function variations( WC_Product $product ): array { if ( ! $product->is_type( 'variable' ) ) { return array(); } /** * Whether the product page gets the list of variations to mark unavailable swatches. Return * false to skip it for a product (its swatches are then never crossed out). * * @param bool $load * @param WC_Product $product */ if ( ! apply_filters( 'swatchline_load_variations', true, $product ) ) { return array(); } $ids = array_map( 'intval', $product->get_visible_children() ); if ( ! $ids ) { return array(); } _prime_post_caches( $ids, false, true ); $fields = array(); foreach ( array_keys( $product->get_variation_attributes() ) as $attribute ) { $fields[] = self::field_name( (string) $attribute ); } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce's filter, as its variation list applies it. $hide_invisible = (bool) apply_filters( 'woocommerce_hide_invisible_variations', true, $product->get_id(), null ); $rows = array(); foreach ( $ids as $id ) { $meta = get_post_meta( $id ); if ( $hide_invisible && '' === (string) ( $meta['_price'][0] ?? '' ) ) { continue; } $values = array(); foreach ( $fields as $field ) { $values[ $field ] = (string) ( $meta[ $field ][0] ?? '' ); } $row = array( 'id' => $id, 'attributes' => $values, 'in_stock' => 'outofstock' !== (string) ( $meta['_stock_status'][0] ?? 'instock' ), ); /** * One variation in the product page's list. An add-on can add data (e.g. its image). * * @param array $row `id`, `attributes`, `in_stock`. * @param int $id Variation id. * @param WC_Product $product The parent. */ $rows[] = (array) apply_filters( 'swatchline_variation', $row, $id, $product ); } return $rows; } }