role => the names it answers to; the first is the one links are written with. */ function addon_boss_preselect_param_names(): array { $names = array( 'colour' => array( 'colour', 'color' ), 'size' => array( 'size' ), ); /** * The URL names of the colour and size questions. * * @param array $names role => names; the first name is the one AddOnBoss writes in its own links. */ $names = (array) apply_filters( 'addon_boss_preselect_param_names', $names ); $out = array(); foreach ( array( 'colour', 'size' ) as $role ) { $list = array(); foreach ( (array) ( $names[ $role ] ?? array() ) as $n ) { $n = addon_boss_preselect_slug( (string) $n ); // A name WordPress reads first could never reach the question. if ( '' !== $n && ! in_array( $n, $list, true ) && ! addon_boss_preselect_reserved( $n ) ) { $list[] = $n; } } $out[ $role ] = $list; } return $out; } /** * One spelling for names and values: lower case, accents off, every run of * anything that is not a letter or a digit becomes one hyphen. "Light Blue", * "light_blue" and "LIGHT-BLUE" all read light-blue. * * @param string $text Any name or value. * @return string */ function addon_boss_preselect_slug( string $text ): string { $text = remove_accents( wp_strip_all_tags( $text ) ); $text = strtolower( $text ); $text = (string) preg_replace( '/[^a-z0-9]+/', '-', $text ); return trim( $text, '-' ); } /** * The long and short names a garment size goes by. Each row is one size; a * value in a row reaches the choice whose own name is any other value in it, * and only when no choice on the product is called the value exactly. * * @return array */ function addon_boss_preselect_size_aliases(): array { $rows = array( array( 'xxs', '2xs', 'xx-small', 'xxsmall', 'extra-extra-small' ), array( 'xs', 'x-small', 'xsmall', 'extra-small' ), array( 's', 'sm', 'small' ), array( 'm', 'md', 'med', 'medium' ), array( 'l', 'lg', 'large' ), array( 'xl', '1xl', 'x-large', 'xlarge', 'extra-large' ), array( 'xxl', '2xl', 'xx-large', 'xxlarge', '2x-large', 'extra-extra-large' ), array( 'xxxl', '3xl', 'xxx-large', '3x-large' ), array( 'xxxxl', '4xl', 'xxxx-large', '4x-large' ), array( 'xxxxxl', '5xl', 'xxxxx-large', '5x-large' ), ); /** * The size names that stand for each other in a link. * * @param array $rows One row per size, every spelling slugged. */ return (array) apply_filters( 'addon_boss_preselect_size_aliases', $rows ); } /** * Every spelling a link may use for one question's choices, mapped to the * choice's key: the name, then the key itself, then (only where no choice * already took it) the long or short name of a size and the other spelling of * grey. Built once, so the page, the structured data and the feed's links all * agree on what a value means. * * @param array $options The set's options (key, label). * @return array spelling => option key. */ function addon_boss_preselect_value_map( array $options ): array { $map = array(); foreach ( $options as $opt ) { $key = (string) ( $opt['key'] ?? '' ); $slug = addon_boss_preselect_slug( (string) ( $opt['label'] ?? '' ) ); if ( '' !== $key && '' !== $slug && ! isset( $map[ $slug ] ) ) { $map[ $slug ] = $key; } } foreach ( $options as $opt ) { $key = (string) ( $opt['key'] ?? '' ); $ks = addon_boss_preselect_slug( $key ); if ( '' !== $key && '' !== $ks && ! isset( $map[ $ks ] ) ) { $map[ $ks ] = $key; } } // The long and short names, never over an exact one. $rows = addon_boss_preselect_size_aliases(); foreach ( $options as $opt ) { $key = (string) ( $opt['key'] ?? '' ); $slug = addon_boss_preselect_slug( (string) ( $opt['label'] ?? '' ) ); if ( '' === $key || '' === $slug ) { continue; } $also = array(); foreach ( $rows as $row ) { if ( in_array( $slug, (array) $row, true ) ) { $also = array_merge( $also, (array) $row ); } } if ( false !== strpos( $slug, 'grey' ) ) { $also[] = str_replace( 'grey', 'gray', $slug ); } elseif ( false !== strpos( $slug, 'gray' ) ) { $also[] = str_replace( 'gray', 'grey', $slug ); } foreach ( $also as $alt ) { $alt = addon_boss_preselect_slug( (string) $alt ); if ( '' !== $alt && ! isset( $map[ $alt ] ) ) { $map[ $alt ] = $key; } } } return $map; } /** * The value a link should carry for one choice: its name, unless another * choice in the same question shares that name, then its key. * * @param array $options The set's options. * @param string $key The chosen option's key. * @return string */ function addon_boss_preselect_value_for( array $options, string $key ): string { $map = addon_boss_preselect_value_map( $options ); foreach ( $options as $opt ) { if ( (string) ( $opt['key'] ?? '' ) === $key ) { $slug = addon_boss_preselect_slug( (string) ( $opt['label'] ?? '' ) ); if ( '' !== $slug && ( $map[ $slug ] ?? '' ) === $key ) { return $slug; } } } // Its key, but only where the key leads back to it: a key that reads as // another choice's name cannot be linked to at all. $by_key = addon_boss_preselect_slug( $key ); return ( $map[ $by_key ] ?? '' ) === $key ? $by_key : ''; } /** * Names a link can never use for a question: WordPress reads its own public * query words before any plugin does (?name= or ?page= on a product address * changes WHICH page loads), and WooCommerce and this plugin read theirs. * * @param string $slug A slugged name. * @return bool */ function addon_boss_preselect_reserved( string $slug ): bool { static $taken = null; if ( null === $taken ) { $taken = array( 'add-to-cart', 'quantity', 'variation-id', 'product-id', 'bb-edit', 'bb-edit-line', 'p', 'page', 's', 'name' ); global $wp; if ( isset( $wp ) && is_object( $wp ) && ! empty( $wp->public_query_vars ) ) { foreach ( (array) $wp->public_query_vars as $qv ) { $taken[] = addon_boss_preselect_slug( (string) $qv ); } } $taken = array_flip( array_filter( $taken ) ); } return isset( $taken[ $slug ] ) || 0 === strpos( $slug, 'attribute-' ) || 0 === strpos( $slug, 'filter-' ) || 0 === strpos( $slug, 'utm-' ); } /** * How strongly a set reads as a colour (0), a size (1) or anything else (2). * The quantity grid's own ranking, kept here so a build without the grid * still knows which question a colour link means. * * @param array $set A resolved set. * @return int */ function addon_boss_preselect_set_rank( array $set ): int { $name = strtolower( (string) ( $set['name'] ?? '' ) ); if ( false !== strpos( $name, 'colour' ) || false !== strpos( $name, 'color' ) ) { return 0; } foreach ( (array) ( $set['options'] ?? array() ) as $o ) { if ( ! empty( $o['swatch'] ) ) { return 0; } } if ( false !== strpos( $name, 'size' ) || addon_boss_preselect_looks_like_size( $set ) ) { return 1; } return 2; } /** * Whether a set's choices read like garment sizes (XS to 7XL, numeric runs). * * @param array $set A resolved set. * @return bool */ function addon_boss_preselect_looks_like_size( array $set ): bool { $tokens = array( 'xs', 's', 'm', 'l', 'xl', 'xxl', 'xxxl', 'small', 'medium', 'large' ); $opts = (array) ( $set['options'] ?? array() ); if ( count( $opts ) < 2 ) { return false; } $hits = 0; foreach ( $opts as $o ) { $lab = strtolower( trim( (string) ( $o['label'] ?? '' ) ) ); if ( in_array( $lab, $tokens, true ) || preg_match( '/^\d+xl$/', $lab ) || preg_match( '/^(uk|eu|us)?\s*\d{1,2}(\.\d)?$/', $lab ) ) { ++$hits; } } return $hits >= (int) ceil( count( $opts ) / 2 ); } /** * Which of a product's questions is THE colour and which is THE size. * * Only the product's own pick-one choices that could be a colour or a size * axis: the first that reads as a colour (its name, or swatches on its * choices) and the first that reads as a size, in the order the shop * attached them, so a thread colour further down is never "the colour". On a * quantity grid the grid module answers instead, from the grid's own two * axes as the merchant arranged them. * * @param WC_Product $product The product. * @return array{colour:?array,size:?array} The resolved sets, or null. */ function addon_boss_preselect_roles( $product ): array { $roles = array( 'colour' => null, 'size' => null, ); if ( ! $product instanceof WC_Product || ! function_exists( 'addon_boss_virtual_axes' ) ) { return $roles; } $axes = array(); foreach ( addon_boss_virtual_axes( $product ) as $axis ) { $set = (array) ( $axis['set'] ?? array() ); if ( '' !== (string) ( $set['id'] ?? '' ) ) { $axes[] = $set; } } // The first that reads as a colour, the first that reads as a size, in the // order the shop attached them. foreach ( $axes as $set ) { $rank = addon_boss_preselect_set_rank( $set ); if ( 0 === $rank && null === $roles['colour'] ) { $roles['colour'] = $set; } elseif ( 1 === $rank && null === $roles['size'] ) { $roles['size'] = $set; } } /** * Which set is the colour and which the size, for links that name them. * * @param array{colour:?array,size:?array} $roles The resolved sets. * @param WC_Product $product The product. */ return (array) apply_filters( 'addon_boss_preselect_roles', $roles, $product ); } /** * Every question a link can answer on this product's one-at-a-time page: * the colour and the size under their short names, then each pick-one * question under the name the shopper reads above it (and its library name). * The first question to claim a name keeps it. * * @param WC_Product $product The product. * @return array slugged name => the set it answers. */ function addon_boss_preselect_targets( $product ): array { if ( ! $product instanceof WC_Product ) { return array(); } $out = array(); $names = addon_boss_preselect_param_names(); foreach ( addon_boss_preselect_roles( $product ) as $role => $set ) { if ( ! is_array( $set ) ) { continue; } foreach ( (array) ( $names[ $role ] ?? array() ) as $n ) { if ( ! isset( $out[ $n ] ) && ! addon_boss_preselect_reserved( $n ) ) { $out[ $n ] = $set; } } } foreach ( addon_boss_standalone_option_sets( $product ) as $set ) { if ( 'choices' !== (string) ( $set['kind'] ?? 'choices' ) || ! empty( $set['multi'] ) ) { continue; // a several-of question, or one priced off its own panel, has no single answer to name. } $own = array_unique( array_filter( array( addon_boss_preselect_slug( addon_boss_set_shopper_name( $set ) ), addon_boss_preselect_slug( (string) ( $set['name'] ?? '' ) ), ) ) ); foreach ( $own as $n ) { if ( ! isset( $out[ $n ] ) && ! addon_boss_preselect_reserved( $n ) ) { $out[ $n ] = $set; } } } return $out; } /** * The link's own words, read once: slugged name => the raw value, for a plain * GET of a product page only. Nothing when the cart's Edit link is in play * (it fills the page from the line), when a form was posted (it keeps what * the shopper typed), or when the shop switched the feature off. * * @return array */ function addon_boss_preselect_query(): array { $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( (string) $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; if ( 'GET' !== $method && 'HEAD' !== $method ) { return array(); } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- a read-only display choice; every value is matched against the product's own stored choices. foreach ( array( 'bb_edit', 'bb_edit_line', 'add-to-cart' ) as $bb_stop ) { if ( isset( $_GET[ $bb_stop ] ) || isset( $_POST[ $bb_stop ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- presence check only. return array(); } } $out = array(); foreach ( (array) $_GET as $bb_name => $bb_val ) { if ( ! is_string( $bb_name ) || is_array( $bb_val ) ) { continue; } $name = addon_boss_preselect_slug( $bb_name ); $val = addon_boss_preselect_slug( mb_substr( sanitize_text_field( wp_unslash( (string) $bb_val ) ), 0, 120 ) ); if ( '' !== $name && '' !== $val && ! isset( $out[ $name ] ) ) { $out[ $name ] = $val; } } // phpcs:enable WordPress.Security.NonceVerification.Recommended return $out; } /** * The choices a link names on this product: set id => option key. Unknown * names and values, and sold-out choices, are simply not there. * * @param WC_Product $product The product. * @param array|null $query slugged name => slugged value; the current request when null. * @param bool $sellable Only choices that can still be sold (the page never * chooses one that cannot); false to describe what the * link named, sold out or not (the page's markup does). * @return array */ function addon_boss_preselect_picks( $product, ?array $query = null, bool $sellable = true ): array { if ( ! $product instanceof WC_Product ) { return array(); } /** * Whether a link may choose this product's options. * * @param bool $on True. * @param WC_Product $product The product. */ if ( ! apply_filters( 'addon_boss_preselect_enabled', true, $product ) ) { return array(); } $query = null === $query ? addon_boss_preselect_query() : $query; if ( ! $query ) { return array(); } $role_ids = array(); foreach ( addon_boss_preselect_roles( $product ) as $bb_rs ) { if ( is_array( $bb_rs ) ) { $role_ids[] = (string) ( $bb_rs['id'] ?? '' ); } } $picks = array(); foreach ( addon_boss_preselect_targets( $product ) as $name => $set ) { if ( ! isset( $query[ $name ] ) ) { continue; } $sid = (string) ( $set['id'] ?? '' ); if ( '' === $sid || isset( $picks[ $sid ] ) ) { continue; } $map = addon_boss_preselect_value_map( (array) ( $set['options'] ?? array() ) ); $key = $map[ addon_boss_preselect_slug( $query[ $name ] ) ] ?? ''; if ( '' === $key ) { continue; } if ( ! in_array( $sid, $role_ids, true ) && empty( $set['required'] ) && ! addon_boss_preselect_free_choice( $set, $key ) ) { continue; // a paid extra is the shopper's to add, never a link's. } if ( $sellable && function_exists( 'addon_boss_option_stock_left' ) ) { $left = addon_boss_option_stock_left( $sid, $key ); if ( null !== $left && $left <= 0 ) { continue; // never chosen for the shopper when it cannot be sold. } } $picks[ $sid ] = $key; } return $picks; } /** * Whether a choice adds nothing to the price. An OPTIONAL question answered * by a link may only be answered with one of these: a paid extra chosen on * the shopper's behalf is a pre-ticked box (consumer law across the UK and * the EU says an extra payment needs the shopper's own yes), so a link can * choose "No print" but never "Large back print". The colour, the size and * any REQUIRED question are the product itself, not an extra, and a link may * name any of their choices. * * @param array $set A resolved set. * @param string $key The option's key. * @return bool */ function addon_boss_preselect_free_choice( array $set, string $key ): bool { foreach ( (array) ( $set['options'] ?? array() ) as $opt ) { if ( (string) ( $opt['key'] ?? '' ) === $key ) { $free = 0.0 === (float) ( $opt['delta'] ?? 0 ) && empty( $opt['fee'] ) && empty( $opt['price'] ); /** * Whether a link may choose this optional choice for the shopper. * * @param bool $free True when the choice adds nothing to the price. * @param array $set The question. * @param string $key The choice. */ return (bool) apply_filters( 'addon_boss_preselect_optional_choice', $free, $set, $key ); } } return false; } /** * The colour and size a link names, resolved to their sets and options. * * @param WC_Product $product The product. * @param array|null $query As addon_boss_preselect_picks(). * @return array{colour:?array,size:?array} Each ['set' => array, 'option' => array] or null. */ function addon_boss_preselect_role_picks( $product, ?array $query = null, bool $sellable = true ): array { $out = array( 'colour' => null, 'size' => null, ); $picks = addon_boss_preselect_picks( $product, $query, $sellable ); if ( ! $picks ) { return $out; } foreach ( addon_boss_preselect_roles( $product ) as $role => $set ) { $sid = is_array( $set ) ? (string) ( $set['id'] ?? '' ) : ''; if ( '' === $sid || ! isset( $picks[ $sid ] ) ) { continue; } foreach ( (array) ( $set['options'] ?? array() ) as $opt ) { if ( (string) ( $opt['key'] ?? '' ) === $picks[ $sid ] ) { $out[ $role ] = array( 'set' => $set, 'option' => $opt, ); } } } return $out; } /** * The link for one colour and size of a product, in the words this plugin * reads back: ?colour=white&size=m. Used for the feed's items, so a feed and * the page can never disagree about what a link means. * * @param WC_Product $product The product. * @param array $colour The colour set's option (or empty). * @param array $size The size set's option (or empty). * @return string */ function addon_boss_preselect_link( $product, array $colour = array(), array $size = array() ): string { if ( ! $product instanceof WC_Product ) { return ''; } $roles = addon_boss_preselect_roles( $product ); $names = addon_boss_preselect_param_names(); $args = array(); foreach ( array( 'colour' => $colour, 'size' => $size, ) as $role => $opt ) { $set = $roles[ $role ] ?? null; if ( ! $opt || ! is_array( $set ) || empty( $names[ $role ][0] ) ) { continue; } $args[ $names[ $role ][0] ] = addon_boss_preselect_value_for( (array) ( $set['options'] ?? array() ), (string) ( $opt['key'] ?? '' ) ); } return add_query_arg( $args, $product->get_permalink() ); } add_action( 'template_redirect', 'addon_boss_preselect_seed', 11 ); /** * Arriving from a link that names choices: write them into the request where * the page's own prefill reads, so the one-at-a-time page draws itself with * those chips chosen, its total and its rules already settled, in the first * response. A question the request already answers is left alone, and a * quantity grid draws its own (its module reads the same picks). */ function addon_boss_preselect_seed(): void { if ( ! function_exists( 'is_product' ) || ! is_product() ) { return; } $product = wc_get_product( (int) get_queried_object_id() ); if ( ! $product instanceof WC_Product ) { return; } if ( function_exists( 'addon_boss_product_uses_grid' ) && addon_boss_product_uses_grid( $product ) ) { return; } foreach ( addon_boss_preselect_picks( $product ) as $sid => $key ) { $field = addon_boss_standalone_field_name( (string) $sid ); if ( ! isset( $_REQUEST[ $field ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- presence check only: a value the request already carries wins. $_REQUEST[ $field ] = wp_slash( (string) $key ); } } }