AddToWishlistButton.php
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
use Automattic\WooCommerce\Internal\ShopperLists\ShopperListRenderer;
/**
* Add to Wishlist Button block.
*
* Single-product trigger UI for the wishlist. Shipped as an inner block of
* `woocommerce/add-to-cart-with-options` (ATCWO) via the per-product-type
* template parts, so it always renders inside the form's iAPI scope and can
* read its `selectedAttributes` context directly. The `ancestor` restriction
* in `block.json` prevents merchants from inserting the block outside ATCWO
* (where it'd lose iAPI scope and the variation-attribute read would break).
*
* Hidden for guests and gated by the `product_wishlist` feature flag. On
* click, toggles the currently configured product (parent or selected
* variation) in the shopper's wishlist via the shared
* `woocommerce/shopper-lists` iAPI store. Errors are surfaced through the
* page's existing `woocommerce/store-notices` region — no inline notices
* area of its own.
*/
final class AddToWishlistButton extends AbstractBlock {
/**
* The list slug this block writes to.
*/
private const LIST_SLUG = 'wishlist';
/**
* Block name.
*
* @var string
*/
protected $block_name = 'add-to-wishlist-button';
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param \WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
// Guests can't have a wishlist — bail before enqueuing assets or
// seeding state.
if ( ! is_user_logged_in() ) {
return '';
}
$post_id = isset( $block->context['postId'] ) ? absint( $block->context['postId'] ) : 0;
if ( ! $post_id ) {
return '';
}
$product = wc_get_product( $post_id );
if ( ! $product instanceof \WC_Product ) {
return '';
}
wp_enqueue_script_module( $this->get_full_block_name() );
$consent = 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WooCommerce';
BlocksSharedState::load_store_config( $consent );
$items = $this->prefetch_items();
// Seed the shared shopper-lists store the same way the Wishlist
// block does — restUrl + starter nonce + prefetched items. The
// two blocks may both render on the same page (e.g. the merchant
// drops the Wishlist block into a sidebar of single-product); iAPI's
// deep-merge keeps the first writer's payload, so seeding identical
// values here is a no-op when Wishlist already ran.
wp_interactivity_state(
'woocommerce/shopper-lists',
array(
'restUrl' => get_rest_url(),
'nonce' => wp_create_nonce( 'wc_store_api' ),
'lists' => array(
self::LIST_SLUG => array(
'items' => $items,
'isLoading' => false,
),
),
)
);
// Visible labels flow through `wp_interactivity_config` so the
// JS-side getter can pick the right one based on
// `state.isInWishlist`. PHP renders the matching one as the
// initial server-side label.
wp_interactivity_config(
'woocommerce/add-to-wishlist-button',
array(
'addLabel' => $this->get_add_label(),
'savedLabel' => $this->get_saved_label(),
'selectOptionsLabel' => $this->get_select_options_label(),
)
);
$is_variable = $product->is_type( 'variable' );
$initial_is_in_wishlist = $this->is_initial_in_wishlist( $items, $product );
$initial_disabled = $is_variable;
$initial_label = $is_variable
? $this->get_select_options_label()
: ( $initial_is_in_wishlist ? $this->get_saved_label() : $this->get_add_label() );
$wrapper_attributes = array(
'class' => 'wc-block-add-to-wishlist-button',
'data-wp-interactive' => 'woocommerce/add-to-wishlist-button',
'data-wp-context' => (string) wp_json_encode(
array(
'productId' => $product->get_id(),
'isVariableType' => $is_variable,
'isPending' => false,
)
),
);
ob_start();
?>
<div <?php echo get_block_wrapper_attributes( $wrapper_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes returns escaped attribute markup. ?>>
<button
type="button"
class="wc-block-add-to-wishlist-button__toggle"
data-wp-on--click="actions.onClickToggle"
data-wp-bind--aria-pressed="state.isInWishlist"
data-wp-bind--disabled="state.isDisabled"
<?php echo $initial_is_in_wishlist ? 'aria-pressed="true"' : 'aria-pressed="false"'; ?>
<?php
if ( $initial_disabled ) {
echo 'disabled';
}
?>
>
<span class="wc-block-add-to-wishlist-button__icon wc-block-add-to-wishlist-button__icon--empty" data-wp-bind--hidden="state.isInWishlist"
<?php
if ( $initial_is_in_wishlist ) {
echo ' hidden';
}
?>
>
<?php echo ShopperListRenderer::get_star_empty_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?>
</span>
<span class="wc-block-add-to-wishlist-button__icon wc-block-add-to-wishlist-button__icon--filled" data-wp-bind--hidden="!state.isInWishlist"
<?php
if ( ! $initial_is_in_wishlist ) {
echo ' hidden';
}
?>
>
<?php echo ShopperListRenderer::get_star_filled_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?>
</span>
<span class="wc-block-add-to-wishlist-button__label" data-wp-text="state.currentLabel"><?php echo esc_html( $initial_label ); ?></span>
</button>
</div>
<?php
return (string) ob_get_clean();
}
/**
* Prefetch the wishlist items via `rest_do_request()`. Logged-out
* users short-circuit to an empty list — the route requires
* authentication and we don't want to fire an API call that's only
* going to 401.
*
* @return array<int, array<string, mixed>> Items in the schema response shape.
*/
private function prefetch_items(): array {
if ( ! is_user_logged_in() ) {
return array();
}
$request = new \WP_REST_Request( 'GET', '/wc/store/v1/shopper-lists/' . self::LIST_SLUG . '/items' );
$response = rest_do_request( $request );
if ( $response->is_error() ) {
$error = $response->as_error();
$message = $error instanceof \WP_Error ? $error->get_error_message() : 'Unknown error';
wc_get_logger()->debug(
sprintf( 'Add to Wishlist button prefetch failed: %s', $message ),
array(
'source' => 'add-to-wishlist-button',
'data' => array( 'slug' => self::LIST_SLUG ),
)
);
return array();
}
$data = $response->get_data();
if ( ! is_array( $data ) && ! is_object( $data ) ) {
return array();
}
$decoded = json_decode( (string) wp_json_encode( $data ), true );
return is_array( $decoded ) ? $decoded : array();
}
/**
* Whether the current product (or its parent, for a variable parent
* with no selection yet) is already in the prefetched wishlist. For
* variable products the SSR star is always empty — we can't know which
* variation the shopper will pick before JS hydrates.
*
* @param array<int, array<string, mixed>> $items Schema-shape items.
* @param \WC_Product $product The product being viewed.
*/
private function is_initial_in_wishlist( array $items, \WC_Product $product ): bool {
if ( $product->is_type( 'variable' ) ) {
return false;
}
$product_id = $product->get_id();
foreach ( $items as $item ) {
if ( isset( $item['id'] ) && (int) $item['id'] === $product_id ) {
return true;
}
}
return false;
}
/**
* Visible label when the product is not in the wishlist.
*/
private function get_add_label(): string {
return __( 'Add to wishlist', 'woocommerce' );
}
/**
* Visible label when the product is already in the wishlist.
*/
private function get_saved_label(): string {
return __( 'Saved to wishlist', 'woocommerce' );
}
/**
* Visible label when the shopper still needs to pick variation
* attributes before the wishlist toggle can resolve to a specific
* variation.
*/
private function get_select_options_label(): string {
return __( 'Select options first', 'woocommerce' );
}
/**
* Get the frontend script handle for this block type. Scripts are
* loaded via `viewScriptModule` in block.json.
*
* @param string|null $key The key of the script to get.
* @return null
*/
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Get the frontend style handle for this block type. Returning null
* lets WP use the `style` array from block.json.
*
* @return null
*/
protected function get_block_type_style() {
return null;
}
/**
* Disable the editor style handle for this block type.
*
* @return null
*/
protected function get_block_type_editor_style() {
return null;
}
}