Commit realizado el 12:13:52 08-04-2024
This commit is contained in:
369
wp-content/plugins/web-stories/includes/Shopping/Product.php
Normal file
369
wp-content/plugins/web-stories/includes/Shopping/Product.php
Normal 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;
|
||||
}
|
||||
}
|
@@ -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 );
|
||||
}
|
||||
}
|
@@ -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 );
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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' );
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user