get_status() ? $product : null; } /** * What the form starts with: the attributes the settings name when they exist, and the product's name and prices, * those of the product an earlier run made when there is one. * * @return array */ public static function defaults(): array { $shirt = Designer_Data::shirt(); $product = self::product(); $price = $shirt['price']; if ( $product ) { foreach ( $product->get_children() as $child ) { $variation = wc_get_product( $child ); if ( $variation && '' !== $variation->get_regular_price() ) { $price = $variation->get_regular_price(); break; } } } $print = $product ? (string) $product->get_meta( Product_Settings::META_PRICE . 'front' ) : ''; return array( 'colour_attribute' => taxonomy_exists( Settings::color_attribute() ) ? Settings::color_attribute() : self::NEW_ATTRIBUTE, 'size_attribute' => taxonomy_exists( Settings::size_attribute() ) ? Settings::size_attribute() : self::NEW_ATTRIBUTE, 'product_name' => $product ? $product->get_name() : __( 'T-shirt', 'printblob' ), 'price' => $price, 'print_price' => '' !== $print ? $print : $shirt['print_price'], ); } public static function handle(): void { check_admin_referer( self::ACTION ); Admin_Menu::allow(); $fields = array( '_wpnonce' => isset( $_POST['_wpnonce'] ) ? sanitize_key( wp_unslash( $_POST['_wpnonce'] ) ) : '' ); foreach ( self::FIELDS as $field ) { $fields[ $field ] = isset( $_POST[ $field ] ) ? sanitize_text_field( wp_unslash( $_POST[ $field ] ) ) : ''; } $result = self::submit( $fields ); update_user_meta( get_current_user_id(), self::RESULT, is_wp_error( $result ) ? array( 'errors' => array( $result->get_error_message() ) ) : $result ); wp_safe_redirect( self::url() ); exit; } /** * The form's submission: its nonce and the user's capability checked, then the set-up made. * * @param array $fields The form's fields (FIELDS) and its nonce (`_wpnonce`), sanitised. * @return array{made:array[], kept:array[], errors:string[]}|\WP_Error */ public static function submit( array $fields ) { if ( ! wp_verify_nonce( $fields['_wpnonce'] ?? '', self::ACTION ) ) { return new \WP_Error( 'printblob_quick_setup_nonce', __( 'The form had expired. Open Quick setup again and run it from there.', 'printblob' ) ); } if ( ! current_user_can( Admin_Menu::CAPABILITY ) ) { return new \WP_Error( 'printblob_quick_setup_forbidden', __( 'Only shop managers can run Quick setup.', 'printblob' ) ); } return self::run( $fields ); } /** * Makes what is missing of the set-up and keeps what is there. * * @param array $fields The form's fields (FIELDS). * @return array{made:array[], kept:array[], errors:string[]} Each item: its label and where to see it. */ public static function run( array $fields ): array { $log = array( 'made' => array(), 'kept' => array(), 'errors' => array(), ); $add = static function ( bool $made, string $label, string $url ) use ( &$log ): void { $log[ $made ? 'made' : 'kept' ][] = array( 'label' => $label, 'url' => $url, ); }; $price = self::price( $fields['price'] ?? '' ); $print = self::price( $fields['print_price'] ?? '' ); $name = trim( $fields['product_name'] ?? '' ); if ( null === $price || null === $print || '' === $name ) { $log['errors'][] = __( 'Give the product a name, and type its price and the print price as numbers, such as 18 and 6.', 'printblob' ); return $log; } $mockup = (int) get_option( Mockups::DEFAULT_OPTION ); if ( $mockup && Mockups::POST_TYPE === get_post_type( $mockup ) ) { /* translators: %s: mockup title. */ $add( false, sprintf( __( 'Mockup “%s”', 'printblob' ), get_the_title( $mockup ) ), Mockups::screen_url() ); } else { $mockup = Mockups::install_default(); if ( ! $mockup ) { $log['errors'][] = __( 'The bundled product mockup could not be added to the media library. Check that WordPress can write to the uploads folder (Tools › Site Health), then run Quick setup again.', 'printblob' ); return $log; } /* translators: %s: mockup title. */ $add( true, sprintf( __( 'Mockup “%s”', 'printblob' ), get_the_title( $mockup ) ), Mockups::screen_url() ); } $shirt = Designer_Data::shirt(); $colours = array(); foreach ( $shirt['colours'] as $colour ) { $colours[ $colour['slug'] ] = array( 'name' => $colour['name'], 'hex' => $colour['hex'], ); } $sizes = array(); foreach ( $shirt['sizes'] as $size ) { $sizes[ sanitize_title( $size ) ] = array( 'name' => $size ); } $colour_taxonomy = self::attribute( $fields['colour_attribute'] ?? '', 'color', __( 'Colour', 'printblob' ), $colours, $add, $log['errors'] ); $size_taxonomy = self::attribute( $fields['size_attribute'] ?? '', 'size', __( 'Size', 'printblob' ), $sizes, $add, $log['errors'] ); if ( '' === $colour_taxonomy || '' === $size_taxonomy ) { return $log; } update_option( Settings::COLOR_ATTRIBUTE, $colour_taxonomy ); update_option( Settings::SIZE_ATTRIBUTE, $size_taxonomy ); $attributes = array( $colour_taxonomy => array_keys( $colours ), $size_taxonomy => array_keys( $sizes ), ); $product = self::ensure_product( $name, $mockup, $print, $attributes, $add ); if ( ! $product ) { $log['errors'][] = __( 'The product could not be saved. Check that WooCommerce is up to date and that Products › Add new works, then run Quick setup again.', 'printblob' ); return $log; } $product_id = $product->get_id(); $added = self::sync_variations( $product, $price, $colour_taxonomy, $size_taxonomy, array_keys( $colours ), array_keys( $sizes ) ); /* translators: 1: number of variations, 2: number of colours, 3: number of sizes. */ $add( $added > 0, sprintf( __( '%1$d variations: %2$d colours in %3$d sizes', 'printblob' ), count( $colours ) * count( $sizes ), count( $colours ), count( $sizes ) ), (string) get_edit_post_link( $product_id, 'raw' ) ); $page = self::designer_page( $product_id, $add ); update_option( self::OPTION, array( 'product' => $product_id, 'page' => $page, ), false ); return $log; } /** * The product an earlier run made, or a new variable product on the mockup with its photos, either way with the * attributes, the designer on, the mockup and the front print price. * * @param string $name The new product's name. * @param int $mockup The mockup. * @param string $print_price The front print price. * @param array $attributes Attribute taxonomy => its term slugs; the first is the colour. * @param callable $add Lists what was made or kept. * @return \WC_Product_Variable|null The product, saved; null when it could not be saved. */ private static function ensure_product( string $name, int $mockup, string $print_price, array $attributes, callable $add ): ?\WC_Product_Variable { $product = self::product(); $is_new = null === $product; if ( $is_new ) { $product = new \WC_Product_Variable(); $product->set_status( 'publish' ); $product->set_name( $name ); $product->set_short_description( __( 'Printed with your own design.', 'printblob' ) ); $product->set_image_id( Mockups::photo( $mockup, 'front' ) ); $back = Mockups::photo( $mockup, 'back' ); $product->set_gallery_image_ids( $back ? array( $back ) : array() ); } $set = array(); foreach ( $attributes as $taxonomy => $slugs ) { $attribute = new \WC_Product_Attribute(); $attribute->set_id( wc_attribute_taxonomy_id_by_name( $taxonomy ) ); $attribute->set_name( $taxonomy ); $attribute->set_options( array_map( static fn( string $slug ): int => (int) get_term_by( 'slug', $slug, $taxonomy )->term_id, $slugs ) ); $attribute->set_visible( true ); $attribute->set_variation( true ); $set[] = $attribute; } $product->set_attributes( $set ); $colour = (string) array_key_first( $attributes ); $product->set_default_attributes( array( $colour => (string) $attributes[ $colour ][0] ) ); $product->update_meta_data( Product_Settings::META_ENABLED, 'yes' ); $product->update_meta_data( Mockups::PRODUCT_META, $mockup ); $product->update_meta_data( Product_Settings::META_PRICE . 'front', $print_price ); $product_id = $product->save(); if ( ! $product_id ) { return null; } /* translators: %s: product name. */ $add( $is_new, sprintf( __( 'Product “%s”, with the designer on', 'printblob' ), $product->get_name() ), (string) get_edit_post_link( $product_id, 'raw' ) ); return $product; } /** * A variation for every colour and size at the price: those missing are added, a price that differs is set, and a * variation of attributes this run does not use, or a second one of the same colour and size, is deleted, as the * designer could not offer it. * * @param \WC_Product_Variable $product The product. * @param string $price The price. * @param string $colour The colour attribute's taxonomy. * @param string $size The size attribute's taxonomy. * @param string[] $colours Colour term slugs. * @param string[] $sizes Size term slugs. * @return int How many variations were added. */ private static function sync_variations( \WC_Product_Variable $product, string $price, string $colour, string $size, array $colours, array $sizes ): int { $product_id = $product->get_id(); $have = array(); foreach ( $product->get_children() as $child ) { $variation = wc_get_product( $child ); if ( ! $variation instanceof \WC_Product_Variation ) { continue; } $chosen = $variation->get_attributes(); $key = ( $chosen[ $colour ] ?? '' ) . '|' . ( $chosen[ $size ] ?? '' ); if ( array_diff( array_keys( $chosen ), array( $colour, $size ) ) || isset( $have[ $key ] ) ) { $variation->delete( true ); continue; } $have[ $key ] = $variation; } $added = 0; foreach ( $colours as $colour_slug ) { foreach ( $sizes as $size_slug ) { $variation = $have[ $colour_slug . '|' . $size_slug ] ?? null; if ( ! $variation ) { $variation = new \WC_Product_Variation(); $variation->set_parent_id( $product_id ); $variation->set_attributes( array( $colour => $colour_slug, $size => $size_slug, ) ); $variation->set_status( 'publish' ); ++$added; } elseif ( $variation->get_regular_price() === $price ) { continue; } $variation->set_regular_price( $price ); $variation->save(); } } \WC_Product_Variable::sync( $product_id ); wc_delete_product_transients( $product_id ); return $added; } /** * A price as typed, in the shop's decimal separator: as WooCommerce stores it, or null when it is not a price. * * @param string $typed What was typed. */ private static function price( string $typed ): ?string { $typed = trim( $typed ); $price = wc_format_decimal( $typed ); return '' !== $typed && is_numeric( $price ) && (float) $price >= 0 ? (string) $price : null; } /** * The attribute a step uses, with every one of its terms: a new attribute (or the one of its slug an earlier run or the * shop made) or one the shop chose; terms it lacks are added, and a colour without a colour value gets one. * * @param string $choice NEW_ATTRIBUTE, or an attribute taxonomy of the shop. * @param string $slug The new attribute's slug. * @param string $label The new attribute's name. * @param array> $terms slug => name, and hex for a colour. * @param callable $add Lists what was made or kept. * @param string[] $errors What went wrong, with how to fix it. * @return string The attribute's taxonomy, or '' when it cannot be used. */ private static function attribute( string $choice, string $slug, string $label, array $terms, callable $add, array &$errors ): string { $list = admin_url( 'edit.php?post_type=product&page=product_attributes' ); if ( self::NEW_ATTRIBUTE !== $choice ) { if ( ! array_key_exists( $choice, Settings::attribute_choices() ) || ! taxonomy_exists( $choice ) ) { /* translators: %s: attribute name, e.g. Colour. */ $errors[] = sprintf( __( 'The %s attribute chosen is not one of the shop’s product attributes. Choose one from the list, or a new one.', 'printblob' ), $label ); return ''; } $taxonomy = $choice; /* translators: %s: attribute name. */ $add( false, sprintf( __( 'Attribute “%s”', 'printblob' ), wc_attribute_label( $taxonomy ) ), $list ); } else { $taxonomy = wc_attribute_taxonomy_name( $slug ); $made = ! wc_attribute_taxonomy_id_by_name( $taxonomy ); if ( $made ) { $id = wc_create_attribute( array( 'name' => $label, 'slug' => $slug, 'type' => 'select', 'order_by' => 'menu_order', 'has_archives' => false, ) ); if ( is_wp_error( $id ) ) { /* translators: 1: attribute name, 2: WooCommerce's reason. */ $errors[] = sprintf( __( 'WooCommerce did not add the %1$s attribute: %2$s Add it under Products › Attributes, then choose it here.', 'printblob' ), $label, $id->get_error_message() ); return ''; } } // WooCommerce registers attribute taxonomies on init: one made in this request is registered as it does. if ( ! taxonomy_exists( $taxonomy ) ) { register_taxonomy( $taxonomy, array( 'product' ), array( 'hierarchical' => false ) ); } /* translators: %s: attribute name. */ $add( $made, sprintf( __( 'Attribute “%s”', 'printblob' ), wc_attribute_label( $taxonomy ) ), $list ); } $order = 0; $added = array(); foreach ( $terms as $term_slug => $term ) { $found = get_term_by( 'slug', $term_slug, $taxonomy ); if ( $found ) { $id = (int) $found->term_id; } else { $inserted = wp_insert_term( $term['name'], $taxonomy, array( 'slug' => $term_slug ) ); if ( is_wp_error( $inserted ) ) { /* translators: 1: term name, e.g. Navy, 2: WordPress's reason. */ $errors[] = sprintf( __( '“%1$s” could not be added: %2$s', 'printblob' ), $term['name'], $inserted->get_error_message() ); return ''; } $id = (int) $inserted['term_id']; update_term_meta( $id, 'order', $order ); $added[] = $term['name']; } if ( isset( $term['hex'] ) && '' === (string) sanitize_hex_color( (string) get_term_meta( $id, Shirt_Colors::META_KEY, true ) ) ) { update_term_meta( $id, Shirt_Colors::META_KEY, $term['hex'] ); } ++$order; } $terms_url = admin_url( 'edit-tags.php?taxonomy=' . $taxonomy . '&post_type=product' ); $names = implode( ', ', array_column( $terms, 'name' ) ); /* translators: 1: attribute name, 2: term names, e.g. White, Black, Navy. */ $add( (bool) $added, sprintf( __( '%1$s: %2$s', 'printblob' ), wc_attribute_label( $taxonomy ), $names ), $terms_url ); return $taxonomy; } /** * The designer page: the one the settings name while it shows the designer, else the one an earlier run made, else a * new page with the designer block on the product; it becomes the designer page. * * @param int $product_id The product Quick setup made. * @param callable $add Lists what was made or kept. * @return int The page's ID. */ private static function designer_page( int $product_id, callable $add ): int { $current = Settings::designer_page(); if ( $current && Overview::designer_page()['ok'] ) { /* translators: %s: page title. */ $add( false, sprintf( __( 'Designer page “%s”', 'printblob' ), get_the_title( $current ) ), (string) get_permalink( $current ) ); return $current; } $made = (int) ( ( (array) get_option( self::OPTION, array() ) )['page'] ?? 0 ); $is_new = ! ( $made && 'publish' === get_post_status( $made ) && has_block( 'printblob/designer', $made ) ); if ( $is_new ) { $made = (int) wp_insert_post( array( 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => __( 'Design your own', 'printblob' ), 'post_content' => serialize_block( Product_Settings::designer_block( $product_id, array( 'align' => 'wide' ) ) ), ) ); } update_option( Settings::DESIGNER_PAGE, $made ); /* translators: %s: page title. */ $add( $is_new, sprintf( __( 'Designer page “%s”', 'printblob' ), get_the_title( $made ) ), (string) get_permalink( $made ) ); return $made; } /** * A list of what a run made or kept, with links. * * @param array[] $items Each its label and url. */ private static function items_html( array $items ): string { return ''; } /** * The attribute select of a step: a new attribute, or each the shop has. * * @param string $name The field. * @param string $chosen The choice shown. * @param string $label The new attribute's name. */ private static function attribute_select( string $name, string $chosen, string $label ): void { ?>