Commit realizado el 12:13:52 08-04-2024

This commit is contained in:
Pagina Web Monito
2024-04-08 12:13:55 -04:00
commit 0c33094de9
7815 changed files with 1365694 additions and 0 deletions

View File

@@ -0,0 +1,369 @@
<?php
/**
* Class Product
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2022 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Shopping;
use JsonSerializable;
/**
* Class Product
*
* @phpstan-type ProductImage array{
* url: string,
* alt: string
* }
* @phpstan-type ProductData array{
* productId?: string,
* productTitle?: string,
* productDetails?: string,
* productBrand?: string,
* productUrl?: string,
* productImages?: ProductImage[],
* productPrice?: float,
* productPriceCurrency?: string,
* aggregateRating?: array{
* ratingValue?: float,
* reviewCount?: int,
* reviewUrl?: string
* }
* }
*/
class Product implements JsonSerializable {
/**
* Product id.
*/
protected string $id = '';
/**
* Product title.
*/
protected string $title = '';
/**
* Product brand.
*/
protected string $brand = '';
/**
* Product Price.
*/
protected float $price = 0.0;
/**
* Product's price currency.
*/
protected string $price_currency = '';
/**
* Product images as an array.
*
* @var array{url: string, alt: string}[]
* @phpstan-var ProductImage[]
*/
protected array $images = [];
/**
* Product Details.
*/
protected string $details = '';
/**
* Product url.
*/
protected string $url = '';
/**
* Product rating.
*
* @var array{rating_value?: float, review_count?: int, review_url?: string}
*/
protected array $aggregate_rating = [];
/**
* Product constructor.
*
* @since 1.21.0
*
* @param array<string,mixed> $product Array of attributes.
*/
public function __construct( array $product = [] ) {
foreach ( $product as $key => $value ) {
if ( property_exists( $this, $key ) && ! \is_null( $value ) ) {
$this->$key = $value;
}
}
}
/**
* Get id.
*
* @since 1.21.0
*/
public function get_id(): string {
return $this->id;
}
/**
* Get title.
*
* @since 1.21.0
*/
public function get_title(): string {
return $this->title;
}
/**
* Get brand.
*
* @since 1.21.0
*/
public function get_brand(): string {
return $this->brand;
}
/**
* Get price.
*
* @since 1.21.0
*/
public function get_price(): float {
return $this->price;
}
/**
* Get currency.
*
* @since 1.21.0
*/
public function get_price_currency(): string {
return $this->price_currency;
}
/**
* Get images property.
*
* @since 1.21.0
*
* @return array{url: string, alt: string}[]
*/
public function get_images(): array {
return $this->images;
}
/**
* Get details.
*
* @since 1.21.0
*/
public function get_details(): string {
return $this->details;
}
/**
* Get url.
*
* @since 1.21.0
*/
public function get_url(): string {
return $this->url;
}
/**
* Get rating.
*
* @since 1.21.0
*
* @return array{rating_value?: float, review_count?: int, review_url?: string}
*/
public function get_aggregate_rating(): ?array {
return $this->aggregate_rating;
}
/**
* Retrieves the response data for JSON serialization.
*
* @since 1.21.0
*
* @return mixed Any JSON-serializable value.
*/
#[\ReturnTypeWillChange]
public function jsonSerialize() { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
$rating = $this->get_aggregate_rating();
$data = [
'productId' => $this->get_id(),
'productTitle' => $this->get_title(),
'productDetails' => $this->get_details(),
'productBrand' => $this->get_brand(),
'productUrl' => $this->get_url(),
'productImages' => $this->get_images(),
'productPrice' => $this->get_price(),
'productPriceCurrency' => $this->get_price_currency(),
];
if ( $rating ) {
$data['aggregateRating'] = [
'ratingValue' => $rating['rating_value'],
'reviewCount' => $rating['review_count'],
'reviewUrl' => $rating['review_url'],
];
}
return $data;
}
/**
* Convert array to object properties.
*
* @since 1.26.0
*
* @param array<string, mixed> $product Array of product.
* @return Product Product.
*
* @phpstan-param ProductData $product
*/
public static function load_from_array( array $product ): Product {
$product_object = new self();
$product_object->set_id( $product['productId'] ?? '' );
$product_object->set_title( $product['productTitle'] ?? '' );
$product_object->set_details( $product['productDetails'] ?? '' );
$product_object->set_brand( $product['productBrand'] ?? '' );
$product_object->set_url( $product['productUrl'] ?? '' );
$product_object->set_images( $product['productImages'] ?? [] );
$product_object->set_price( $product['productPrice'] ?? 0 );
$product_object->set_price_currency( $product['productPriceCurrency'] ?? '' );
if ( isset( $product['aggregateRating'] ) ) {
$product_object->set_aggregate_rating(
[
'rating_value' => $product['aggregateRating']['ratingValue'] ?? 0,
'review_count' => $product['aggregateRating']['reviewCount'] ?? 0,
'review_url' => $product['aggregateRating']['reviewUrl'] ?? '',
]
);
}
return $product_object;
}
/**
* Set id.
*
* @since 1.26.0
*
* @param string $id ID.
*/
protected function set_id( string $id ): void {
$this->id = $id;
}
/**
* Set title.
*
* @since 1.26.0
*
* @param string $title Title.
*/
protected function set_title( string $title ): void {
$this->title = $title;
}
/**
* Set brand.
*
* @since 1.26.0
*
* @param string $brand Brand.
*/
protected function set_brand( string $brand ): void {
$this->brand = $brand;
}
/**
* Set price.
*
* @since 1.26.0
*
* @param float $price Price.
*/
protected function set_price( float $price ): void {
$this->price = $price;
}
/**
* Set Price currency.
*
* @since 1.26.0
*
* @param string $price_currency Price Currency.
*/
protected function set_price_currency( string $price_currency ): void {
$this->price_currency = $price_currency;
}
/**
* Set Images.
*
* @since 1.26.0
*
* @param array{url: string, alt: string}[] $images Images.
*/
protected function set_images( array $images ): void {
$this->images = $images;
}
/**
* Set Details.
*
* @since 1.26.0
*
* @param string $details Details.
*/
protected function set_details( string $details ): void {
$this->details = $details;
}
/**
* Set url.
*
* @since 1.26.0
*
* @param string $url URL.
*/
protected function set_url( string $url ): void {
$this->url = $url;
}
/**
* Set aggregate rating.
*
* @since 1.26.0
*
* @param array{rating_value: float, review_count: int, review_url: string} $aggregate_rating Rating data in array.
*/
protected function set_aggregate_rating( array $aggregate_rating ): void {
$this->aggregate_rating = $aggregate_rating;
}
}

View File

@@ -0,0 +1,118 @@
<?php
/**
* Class Product_Meta
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2022 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Shopping;
use Google\Web_Stories\Infrastructure\HasMeta;
use Google\Web_Stories\Infrastructure\PluginUninstallAware;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\Story_Post_Type;
/**
* Class Product_Meta.
*/
class Product_Meta extends Service_Base implements HasMeta, PluginUninstallAware {
/**
* The products meta key.
*/
public const PRODUCTS_POST_META_KEY = 'web_stories_products';
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Constructor.
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
*/
public function __construct( Story_Post_Type $story_post_type ) {
$this->story_post_type = $story_post_type;
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story post type needs to be registered first.
*
* @since 1.22.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'story_post_type' ];
}
/**
* Init.
*
* @since 1.22.0
*/
public function register(): void {
$this->register_meta();
}
/**
* Register meta
*
* @since 1.22.0
*/
public function register_meta(): void {
register_meta(
'post',
self::PRODUCTS_POST_META_KEY,
[
'type' => 'object',
'description' => __( 'Products', 'web-stories' ),
'show_in_rest' => [
'schema' => [
'type' => 'object',
'properties' => [],
'additionalProperties' => true,
],
],
'default' => [],
'single' => true,
'object_subtype' => $this->story_post_type::POST_TYPE_SLUG,
]
);
}
/**
* Act on plugin uninstall.
*
* @since 1.26.0
*/
public function on_plugin_uninstall(): void {
delete_post_meta_by_key( self::PRODUCTS_POST_META_KEY );
}
}

View File

@@ -0,0 +1,441 @@
<?php
/**
* Class Shopify_Query
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2022 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Shopping;
use Google\Web_Stories\Interfaces\Product_Query;
use Google\Web_Stories\Settings;
use WP_Error;
use WP_Http;
/**
* Class Shopify_Query
*
* @phpstan-type ShopifyGraphQLError array{
* message: string,
* extensions: array{code: string, requestId: string}
* }[]
* @phpstan-type ShopifyGraphQLPriceRange array{
* minVariantPrice: array{
* amount: int,
* currencyCode: string
* }
* }
* @phpstan-type ShopifyGraphQLProductImage array{
* url: string,
* altText?: string
* }
* @phpstan-type ShopifyGraphQLProduct array{
* id: string,
* handle: string,
* title: string,
* vendor: string,
* description: string,
* onlineStoreUrl?: string,
* images: array{
* edges: array{
* node: ShopifyGraphQLProductImage
* }[]
* },
* priceRange: ShopifyGraphQLPriceRange
* }
* @phpstan-type ShopifyGraphQLResponse array{
* errors?: ShopifyGraphQLError,
* data: array{
* products: array{
* edges: array{
* node: ShopifyGraphQLProduct
* }[],
* pageInfo: array{
* hasNextPage: bool,
* endCursor: string
* }
* }
* }
* }
*/
class Shopify_Query implements Product_Query {
protected const API_VERSION = '2022-04';
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Constructor.
*
* @param Settings $settings Settings instance.
*/
public function __construct( Settings $settings ) {
$this->settings = $settings;
}
/**
* Get products by search term.
*
* @since 1.21.0
*
* @param string $search_term Search term.
* @param int $page Number of page for paginated requests.
* @param int $per_page Number of products to be fetched.
* @param string $orderby Sort retrieved products by parameter. Default 'date'.
* @param string $order Whether to order products in ascending or descending order.
* Accepts 'asc' (ascending) or 'desc' (descending). Default 'desc'.
* @return array{products: array<Product>, has_next_page: bool}|WP_Error
*/
public function get_search( string $search_term, int $page = 1, int $per_page = 100, string $orderby = 'date', string $order = 'desc' ) {
$result = $this->fetch_remote_products( $search_term, $page, $per_page, $orderby, $order );
if ( is_wp_error( $result ) ) {
return $result;
}
$products = [];
$has_next_page = $result['data']['products']['pageInfo']['hasNextPage'];
foreach ( $result['data']['products']['edges'] as $edge ) {
$product = $edge['node'];
$images = [];
foreach ( $product['images']['edges'] as $image_edge ) {
$image = $image_edge['node'];
$images[] = [
'url' => $image['url'],
'alt' => $image['altText'] ?? '',
];
}
// URL is null if the resource is currently not published to the Online Store sales channel,
// or if the shop is password-protected.
// In this case, we can fall back to a manually constructed product URL.
$product_url = $product['onlineStoreUrl'] ?? sprintf( 'https://%1$s/products/%2$s/', $this->get_host(), $product['handle'] );
$products[] = new Product(
[
'id' => $product['id'],
'title' => $product['title'],
'brand' => $product['vendor'],
// TODO: Maybe eventually provide full price range.
// See https://github.com/ampproject/amphtml/issues/37957.
'price' => (float) $product['priceRange']['minVariantPrice']['amount'],
'price_currency' => $product['priceRange']['minVariantPrice']['currencyCode'],
'images' => $images,
'details' => $product['description'],
// URL is null if the resource is currently not published to the Online Store sales channel,
// or if the shop is password-protected.
'url' => $product_url,
]
);
}
return compact( 'products', 'has_next_page' );
}
/**
* Returns the Shopify host name.
*
* @since 1.21.0
*
* @return string Shopify host.
*/
protected function get_host(): string {
/**
* Host name.
*
* @var string $host
*/
$host = $this->settings->get_setting( Settings::SETTING_NAME_SHOPIFY_HOST );
return $host;
}
/**
* Returns the Shopify access token.
*
* @since 1.21.0
*
* @return string Shopify access token.
*/
protected function get_access_token(): string {
/**
* Access token.
*
* @var string $access_token
*/
$access_token = $this->settings->get_setting( Settings::SETTING_NAME_SHOPIFY_ACCESS_TOKEN );
return $access_token;
}
/**
* Remotely executes a GraphQL query.
*
* @since 1.21.0
*
* @param string $query GraphQL query to execute.
* @return string|WP_Error Query result or error object on failure.
*/
protected function execute_query( string $query ) {
$host = $this->get_host();
$access_token = $this->get_access_token();
if ( empty( $host ) || empty( $access_token ) ) {
return new WP_Error( 'rest_missing_credentials', __( 'Missing API credentials.', 'web-stories' ), [ 'status' => 400 ] );
}
if ( ! preg_match( '/^[\w-]+\.myshopify\.com$/i', $host ) ) {
return new WP_Error( 'rest_invalid_hostname', __( 'Invalid Shopify hostname.', 'web-stories' ), [ 'status' => 400 ] );
}
$url = esc_url_raw(
sprintf(
'https://%1$s/api/%2$s/graphql.json',
$host,
self::API_VERSION
)
);
$response = wp_remote_post(
$url,
[
'headers' => [
'Content-Type' => 'application/graphql',
'X-Shopify-Storefront-Access-Token' => $access_token,
],
'body' => $query,
]
);
$status_code = wp_remote_retrieve_response_code( $response );
if ( WP_Http::UNAUTHORIZED === $status_code || WP_Http::NOT_FOUND === $status_code ) {
return new WP_Error( 'rest_invalid_credentials', __( 'Invalid API credentials.', 'web-stories' ), [ 'status' => $status_code ] );
}
if ( WP_Http::OK !== $status_code ) {
return new WP_Error( 'rest_unknown', __( 'Error fetching products', 'web-stories' ), [ 'status' => $status_code ] );
}
return wp_remote_retrieve_body( $response );
}
/**
* Returns the GraphQL query for getting all products from the store.
*
* @since 1.21.0
*
* @param string $search_term Search term to filter products by.
* @param string $after The cursor to retrieve nodes after in the connection.
* @param int $per_page Number of products to be fetched.
* @param string $orderby Sort collection by product attribute.
* @param string $order Order sort attribute ascending or descending.
* @return string The assembled GraphQL query.
*/
protected function get_products_query( string $search_term, string $after, int $per_page, string $orderby, string $order ): string {
$search_string = empty( $search_term ) ? '*' : '*' . $search_term . '*';
$sortkey = 'date' === $orderby ? 'CREATED_AT' : strtoupper( $orderby );
$reverse = 'asc' === $order ? 'false' : 'true';
$after = empty( $after ) ? 'null' : sprintf( '"%s"', $after );
return <<<QUERY
{
products(first: $per_page, after: $after, sortKey: $sortkey, reverse: $reverse, query: "title:$search_string") {
edges {
node {
id
handle
title
vendor
description
priceRange {
minVariantPrice {
amount
currencyCode
}
}
onlineStoreUrl
images(first: 10) {
edges {
node {
url(transform:{maxWidth:1000,maxHeight:1000})
altText
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
QUERY;
}
/**
* Remotely fetches all products from the store.
*
* Retrieves cached data if available.
*
* @since 1.21.0
*
* @param string $search_term Search term to filter products by.
* @param string $after The cursor to retrieve nodes after in the connection.
* @param int $per_page Number of products to be fetched.
* @param string $orderby Sort retrieved products by parameter.
* @param string $order Whether to order products in ascending or descending order.
* Accepts 'asc' (ascending) or 'desc' (descending).
* @return array|WP_Error Response data or error object on failure.
*
* @phpstan-return ShopifyGraphQLResponse|WP_Error
*/
protected function get_remote_products( string $search_term, string $after, int $per_page, string $orderby, string $order ) {
/**
* Filters the Shopify products data TTL value.
*
* @since 1.21.0
*
* @param int $time Time to live (in seconds). Default is 5 minutes.
*/
$cache_ttl = apply_filters( 'web_stories_shopify_data_cache_ttl', 5 * MINUTE_IN_SECONDS );
$cache_key = $this->get_cache_key( $search_term, $after, $per_page, $orderby, $order );
$data = get_transient( $cache_key );
if ( \is_string( $data ) && ! empty( $data ) ) {
/**
* Cached response.
*
* @phpstan-var ShopifyGraphQLResponse $cached_result
*/
$cached_result = (array) json_decode( $data, true );
return $cached_result;
}
$query = $this->get_products_query( $search_term, $after, $per_page, $orderby, $order );
$body = $this->execute_query( $query );
if ( is_wp_error( $body ) ) {
return $body;
}
/**
* Shopify GraphQL API response.
*
* @var array $result
* @phpstan-var ShopifyGraphQLResponse $result
*/
$result = json_decode( $body, true );
if ( isset( $result['errors'] ) ) {
$wp_error = new WP_Error();
foreach ( $result['errors'] as $error ) {
$error_code = $error['extensions']['code'];
// https://shopify.dev/api/storefront#status_and_error_codes.
switch ( $error_code ) {
case 'THROTTLED':
$wp_error->add( 'rest_throttled', __( 'Shopify API rate limit exceeded. Try again later.', 'web-stories' ), [ 'status' => 429 ] );
break;
case 'ACCESS_DENIED':
$wp_error->add( 'rest_invalid_credentials', __( 'Invalid Shopify API credentials provided.', 'web-stories' ), [ 'status' => 401 ] );
break;
case 'SHOP_INACTIVE':
$wp_error->add( 'rest_inactive_shop', __( 'Inactive Shopify shop.', 'web-stories' ), [ 'status' => 403 ] );
break;
case 'INTERNAL_SERVER_ERROR':
$wp_error->add( 'rest_internal_error', __( 'Shopify experienced an internal server error.', 'web-stories' ), [ 'status' => 500 ] );
break;
default:
$wp_error->add( 'rest_unknown', __( 'Error fetching products from Shopify.', 'web-stories' ), [ 'status' => 500 ] );
}
}
return $wp_error;
}
// TODO: Maybe cache errors too?
set_transient( $cache_key, $body, $cache_ttl );
return $result;
}
/**
* Get cache key for properties.
*
* @since 1.22.0
*
* @param string $search_term Search term to filter products by.
* @param string $after The cursor to retrieve nodes after in the connection.
* @param int $per_page Number of products to be fetched.
* @param string $orderby Sort retrieved products by parameter.
* @param string $order Whether to order products in ascending or descending order.
* Accepts 'asc' (ascending) or 'desc' (descending).
*/
protected function get_cache_key( string $search_term, string $after, int $per_page, string $orderby, string $order ): string {
$cache_args = (string) wp_json_encode( compact( 'search_term', 'after', 'per_page', 'orderby', 'order' ) );
return 'web_stories_shopify_data_' . md5( $cache_args );
}
/**
* Remotely fetches all products from the store.
*
* @since 1.22.0
*
* @param string $search_term Search term to filter products by.
* @param int $page Number of page for paginated requests.
* @param int $per_page Number of products to be fetched.
* @param string $orderby Sort retrieved products by parameter.
* @param string $order Whether to order products in ascending or descending order.
* Accepts 'asc' (ascending) or 'desc' (descending).
* @return array|WP_Error Response data or error object on failure.
*
* @phpstan-return ShopifyGraphQLResponse|WP_Error
*/
protected function fetch_remote_products( string $search_term, int $page, int $per_page, string $orderby, string $order ) {
$after = '';
if ( $page > 1 ) {
// Loop around all the pages, getting the endCursor of each page, until you get the last one.
for ( $i = 1; $i < $page; $i++ ) {
$result = $this->get_remote_products( $search_term, $after, $per_page, $orderby, $order );
if ( is_wp_error( $result ) ) {
return $result;
}
$has_next_page = $result['data']['products']['pageInfo']['hasNextPage'];
if ( ! $has_next_page ) {
return new WP_Error( 'rest_no_page', __( 'Error fetching products from Shopify.', 'web-stories' ), [ 'status' => 404 ] );
}
$after = (string) $result['data']['products']['pageInfo']['endCursor'];
}
}
return $this->get_remote_products( $search_term, $after, $per_page, $orderby, $order );
}
}

View File

@@ -0,0 +1,115 @@
<?php
/**
* Shopping vendors class.
*
* A central class to register shopping vendors.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2022 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Shopping;
use Google\Web_Stories\Infrastructure\Injector;
use Google\Web_Stories\Interfaces\Product_Query;
/**
* Class Shopping_Vendors.
*/
class Shopping_Vendors {
/**
* Injector instance.
*
* @var Injector Injector instance.
*/
private Injector $injector;
/**
* Shopping_Vendors constructor.
*
* @since 1.21.0
*
* @param Injector $injector Injector instance.
*/
public function __construct( Injector $injector ) {
$this->injector = $injector;
}
/**
* Get an instance of product query class by vendor's name.
*
* @since 1.21.0
*
* @param string $name Name of vendor.
*/
public function get_vendor_class( string $name ): ?Product_Query {
$vendors = $this->get_vendors();
if ( ! isset( $vendors[ $name ]['class'] ) || ! class_exists( $vendors[ $name ]['class'] ) ) {
return null;
}
$query = $this->injector->make( $vendors[ $name ]['class'] );
if ( ! $query instanceof Product_Query ) {
return null;
}
return $query;
}
/**
* Get an array of registered vendors.
*
* @since 1.21.0
*
* @return array<string,array{label: string, class?: class-string}> Array of vendors.
*/
public function get_vendors(): array {
$vendors = [
'none' => [
'label' => __( 'None', 'web-stories' ),
],
'shopify' => [
'label' => __( 'Shopify', 'web-stories' ),
'class' => Shopify_Query::class,
],
'woocommerce' => [
'label' => __( 'WooCommerce', 'web-stories' ),
'class' => WooCommerce_Query::class,
],
];
/**
* Filter the array of vendors.
*
* @since 1.21.0
*
* @param array $vendors Associative array of vendor, including label and class.
*/
$vendors = apply_filters( 'web_stories_shopping_vendors', $vendors );
return $vendors;
}
}

View File

@@ -0,0 +1,199 @@
<?php
/**
* Class WooCommerce_Query
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2022 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Shopping;
use Google\Web_Stories\Integrations\WooCommerce;
use Google\Web_Stories\Interfaces\Product_Query;
use WC_Product;
use WC_Query;
use WP_Error;
/**
* Class WooCommerce_Query.
*/
class WooCommerce_Query implements Product_Query {
/**
* WooCommerce instance.
*
* @var WooCommerce WooCommerce instance.
*/
private WooCommerce $woocommerce;
/**
* Constructor.
*
* @param WooCommerce $woocommerce WooCommerce instance.
*/
public function __construct( WooCommerce $woocommerce ) {
$this->woocommerce = $woocommerce;
}
/**
* Get products by search term.
*
* @since 1.21.0
*
* @param string $search_term Search term.
* @param int $page Number of page for paginated requests.
* @param int $per_page Number of products to be fetched.
* @param string $orderby Sort collection by product attribute.
* @param string $order Order sort attribute ascending or descending.
* @return array{products: array<Product>, has_next_page: bool}|WP_Error
*/
public function get_search( string $search_term, int $page = 1, int $per_page = 100, string $orderby = 'date', string $order = 'desc' ) {
$status = $this->woocommerce->get_plugin_status();
if ( ! $status['installed'] ) {
return new WP_Error( 'rest_woocommerce_not_installed', __( 'WooCommerce is not installed.', 'web-stories' ), [ 'status' => 400 ] );
}
if ( ! $status['active'] ) {
return new WP_Error( 'rest_woocommerce_not_activated', __( 'WooCommerce is not activated. Please activate it again try again.', 'web-stories' ), [ 'status' => 400 ] );
}
$args = [
'status' => 'publish',
'page' => $page,
'limit' => $per_page,
's' => $search_term,
'orderby' => $orderby,
'order' => $order,
'paginate' => true,
];
if ( 'price' === $orderby ) {
$wc_query = new WC_Query();
$wc_args = $wc_query->get_catalog_ordering_args( $orderby, strtoupper( $order ) );
$args = array_merge( $args, $wc_args );
}
/**
* Product query object.
*
* @var \stdClass $product_query
*/
$product_query = wc_get_products( $args );
$has_next_page = ( $product_query->max_num_pages > $page );
/**
* Products.
*
* @var WC_Product[] $wc_products
*/
$wc_products = $product_query->products;
$product_image_ids = [];
foreach ( $wc_products as $product ) {
$product_image_ids[] = $this->get_product_image_ids( $product );
}
$products_image_ids = array_merge( [], ...$product_image_ids );
/**
* Warm the object cache with post and meta information for all found
* images to avoid making individual database calls.
*/
_prime_post_caches( $products_image_ids, false, true );
$products = [];
foreach ( $wc_products as $product ) {
$images = array_map(
[ $this, 'get_product_image' ],
$this->get_product_image_ids( $product )
);
$product_object = new Product(
[
// amp-story-shopping requires non-numeric IDs.
'id' => 'wc-' . $product->get_id(),
'title' => $product->get_title(),
'brand' => '', // TODO: Figure out how to best provide that.
'price' => (float) $product->get_price(),
'price_currency' => get_woocommerce_currency(),
'images' => $images,
'aggregate_rating' => [
'rating_value' => (float) $product->get_average_rating(),
'review_count' => $product->get_rating_count(),
'review_url' => $product->get_permalink(),
],
'details' => wp_strip_all_tags( $product->get_short_description() ),
'url' => $product->get_permalink(),
]
);
$products[] = $product_object;
}
return compact( 'products', 'has_next_page' );
}
/**
* Get all product image ids (feature image + gallery_images).
*
* @since 1.21.0
*
* @param WC_Product $product Product.
* @return int[]
*/
protected function get_product_image_ids( WC_Product $product ): array {
$product_image_ids = $product->get_gallery_image_ids();
array_unshift( $product_image_ids, $product->get_image_id() );
$product_image_ids = array_map( 'absint', $product_image_ids );
return array_unique( array_filter( $product_image_ids ) );
}
/**
* Get product image, url and alt.
*
* @since 1.21.0
*
* @param int $image_id Attachment ID.
* @return array{url?: string, alt?: string}
*/
protected function get_product_image( int $image_id ): array {
$url = wp_get_attachment_image_url( $image_id, 'large' );
if ( ! $url ) {
return [];
}
/**
* Alt text.
*
* @var string $alt
*/
$alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
if ( empty( $alt ) ) {
$alt = '';
}
return compact( 'url', 'alt' );
}
}