class-wc-structured-data.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. <?php
  2. /**
  3. * Structured data's handler and generator using JSON-LD format.
  4. *
  5. * @package WooCommerce/Classes
  6. * @since 3.0.0
  7. * @version 3.0.0
  8. */
  9. defined( 'ABSPATH' ) || exit;
  10. /**
  11. * Structured data class.
  12. */
  13. class WC_Structured_Data {
  14. /**
  15. * Stores the structured data.
  16. *
  17. * @var array $_data Array of structured data.
  18. */
  19. private $_data = array();
  20. /**
  21. * Constructor.
  22. */
  23. public function __construct() {
  24. // Generate structured data.
  25. add_action( 'woocommerce_before_main_content', array( $this, 'generate_website_data' ), 30 );
  26. add_action( 'woocommerce_breadcrumb', array( $this, 'generate_breadcrumblist_data' ), 10 );
  27. add_action( 'woocommerce_shop_loop', array( $this, 'generate_product_data' ), 10 );
  28. add_action( 'woocommerce_single_product_summary', array( $this, 'generate_product_data' ), 60 );
  29. add_action( 'woocommerce_review_meta', array( $this, 'generate_review_data' ), 20 );
  30. add_action( 'woocommerce_email_order_details', array( $this, 'generate_order_data' ), 20, 3 );
  31. // Output structured data.
  32. add_action( 'woocommerce_email_order_details', array( $this, 'output_email_structured_data' ), 30, 3 );
  33. add_action( 'wp_footer', array( $this, 'output_structured_data' ), 10 );
  34. }
  35. /**
  36. * Sets data.
  37. *
  38. * @param array $data Structured data.
  39. * @param bool $reset Unset data (default: false).
  40. * @return bool
  41. */
  42. public function set_data( $data, $reset = false ) {
  43. if ( ! isset( $data['@type'] ) || ! preg_match( '|^[a-zA-Z]{1,20}$|', $data['@type'] ) ) {
  44. return false;
  45. }
  46. if ( $reset && isset( $this->_data ) ) {
  47. unset( $this->_data );
  48. }
  49. $this->_data[] = $data;
  50. return true;
  51. }
  52. /**
  53. * Gets data.
  54. *
  55. * @return array
  56. */
  57. public function get_data() {
  58. return $this->_data;
  59. }
  60. /**
  61. * Structures and returns data.
  62. *
  63. * List of types available by default for specific request:
  64. *
  65. * 'product',
  66. * 'review',
  67. * 'breadcrumblist',
  68. * 'website',
  69. * 'order',
  70. *
  71. * @param array $types Structured data types.
  72. * @return array
  73. */
  74. public function get_structured_data( $types ) {
  75. $data = array();
  76. // Put together the values of same type of structured data.
  77. foreach ( $this->get_data() as $value ) {
  78. $data[ strtolower( $value['@type'] ) ][] = $value;
  79. }
  80. // Wrap the multiple values of each type inside a graph... Then add context to each type.
  81. foreach ( $data as $type => $value ) {
  82. $data[ $type ] = count( $value ) > 1 ? array( '@graph' => $value ) : $value[0];
  83. $data[ $type ] = apply_filters( 'woocommerce_structured_data_context', array( '@context' => 'https://schema.org/' ), $data, $type, $value ) + $data[ $type ];
  84. }
  85. // If requested types, pick them up... Finally change the associative array to an indexed one.
  86. $data = $types ? array_values( array_intersect_key( $data, array_flip( $types ) ) ) : array_values( $data );
  87. if ( ! empty( $data ) ) {
  88. if ( 1 < count( $data ) ) {
  89. $data = apply_filters( 'woocommerce_structured_data_context', array( '@context' => 'https://schema.org/' ), $data, '', '' ) + array( '@graph' => $data );
  90. } else {
  91. $data = $data[0];
  92. }
  93. }
  94. return $data;
  95. }
  96. /**
  97. * Get data types for pages.
  98. *
  99. * @return array
  100. */
  101. protected function get_data_type_for_page() {
  102. $types = array();
  103. $types[] = is_shop() || is_product_category() || is_product() ? 'product' : '';
  104. $types[] = is_shop() && is_front_page() ? 'website' : '';
  105. $types[] = is_product() ? 'review' : '';
  106. $types[] = ! is_shop() ? 'breadcrumblist' : '';
  107. $types[] = 'order';
  108. return array_filter( apply_filters( 'woocommerce_structured_data_type_for_page', $types ) );
  109. }
  110. /**
  111. * Makes sure email structured data only outputs on non-plain text versions.
  112. *
  113. * @param WP_Order $order Order data.
  114. * @param bool $sent_to_admin Send to admin (default: false).
  115. * @param bool $plain_text Plain text email (default: false).
  116. */
  117. public function output_email_structured_data( $order, $sent_to_admin = false, $plain_text = false ) {
  118. if ( $plain_text ) {
  119. return;
  120. }
  121. echo '<div style="display: none; font-size: 0; max-height: 0; line-height: 0; padding: 0; mso-hide: all;">';
  122. $this->output_structured_data();
  123. echo '</div>';
  124. }
  125. /**
  126. * Sanitizes, encodes and outputs structured data.
  127. *
  128. * Hooked into `wp_footer` action hook.
  129. * Hooked into `woocommerce_email_order_details` action hook.
  130. */
  131. public function output_structured_data() {
  132. $types = $this->get_data_type_for_page();
  133. $data = wc_clean( $this->get_structured_data( $types ) );
  134. if ( $data ) {
  135. echo '<script type="application/ld+json">' . wp_json_encode( $data ) . '</script>';
  136. }
  137. }
  138. /*
  139. |--------------------------------------------------------------------------
  140. | Generators
  141. |--------------------------------------------------------------------------
  142. |
  143. | Methods for generating specific structured data types:
  144. |
  145. | - Product
  146. | - Review
  147. | - BreadcrumbList
  148. | - WebSite
  149. | - Order
  150. |
  151. | The generated data is stored into `$this->_data`.
  152. | See the methods above for handling `$this->_data`.
  153. |
  154. */
  155. /**
  156. * Generates Product structured data.
  157. *
  158. * Hooked into `woocommerce_single_product_summary` action hook.
  159. * Hooked into `woocommerce_shop_loop` action hook.
  160. *
  161. * @param WC_Product $product Product data (default: null).
  162. */
  163. public function generate_product_data( $product = null ) {
  164. if ( ! is_object( $product ) ) {
  165. global $product;
  166. }
  167. if ( ! is_a( $product, 'WC_Product' ) ) {
  168. return;
  169. }
  170. $shop_name = get_bloginfo( 'name' );
  171. $shop_url = home_url();
  172. $currency = get_woocommerce_currency();
  173. $markup = array(
  174. '@type' => 'Product',
  175. '@id' => get_permalink( $product->get_id() ),
  176. 'name' => $product->get_name(),
  177. );
  178. if ( apply_filters( 'woocommerce_structured_data_product_limit', is_product_taxonomy() || is_shop() ) ) {
  179. $markup['url'] = $markup['@id'];
  180. $this->set_data( apply_filters( 'woocommerce_structured_data_product_limited', $markup, $product ) );
  181. return;
  182. }
  183. $markup['image'] = wp_get_attachment_url( $product->get_image_id() );
  184. $markup['description'] = wpautop( do_shortcode( $product->get_short_description() ? $product->get_short_description() : $product->get_description() ) );
  185. $markup['sku'] = $product->get_sku();
  186. if ( '' !== $product->get_price() ) {
  187. if ( $product->is_type( 'variable' ) ) {
  188. $lowest = $product->get_variation_price( 'min', false );
  189. $highest = $product->get_variation_price( 'max', false );
  190. if ( $lowest === $highest ) {
  191. $markup_offer = array(
  192. '@type' => 'Offer',
  193. 'price' => wc_format_decimal( $lowest, wc_get_price_decimals() ),
  194. 'priceSpecification' => array(
  195. 'price' => wc_format_decimal( $lowest, wc_get_price_decimals() ),
  196. 'priceCurrency' => $currency,
  197. 'valueAddedTaxIncluded' => wc_prices_include_tax() ? 'true' : 'false',
  198. ),
  199. );
  200. } else {
  201. $markup_offer = array(
  202. '@type' => 'AggregateOffer',
  203. 'lowPrice' => wc_format_decimal( $lowest, wc_get_price_decimals() ),
  204. 'highPrice' => wc_format_decimal( $highest, wc_get_price_decimals() ),
  205. );
  206. }
  207. } else {
  208. $markup_offer = array(
  209. '@type' => 'Offer',
  210. 'price' => wc_format_decimal( $product->get_price(), wc_get_price_decimals() ),
  211. 'priceSpecification' => array(
  212. 'price' => wc_format_decimal( $product->get_price(), wc_get_price_decimals() ),
  213. 'priceCurrency' => $currency,
  214. 'valueAddedTaxIncluded' => wc_prices_include_tax() ? 'true' : 'false',
  215. ),
  216. );
  217. }
  218. $markup_offer += array(
  219. 'priceCurrency' => $currency,
  220. 'availability' => 'https://schema.org/' . ( $product->is_in_stock() ? 'InStock' : 'OutOfStock' ),
  221. 'url' => $markup['@id'],
  222. 'seller' => array(
  223. '@type' => 'Organization',
  224. 'name' => $shop_name,
  225. 'url' => $shop_url,
  226. ),
  227. );
  228. $markup['offers'] = array( apply_filters( 'woocommerce_structured_data_product_offer', $markup_offer, $product ) );
  229. }
  230. if ( $product->get_review_count() && 'yes' === get_option( 'woocommerce_enable_review_rating' ) ) {
  231. $markup['aggregateRating'] = array(
  232. '@type' => 'AggregateRating',
  233. 'ratingValue' => $product->get_average_rating(),
  234. 'reviewCount' => $product->get_review_count(),
  235. );
  236. }
  237. $this->set_data( apply_filters( 'woocommerce_structured_data_product', $markup, $product ) );
  238. }
  239. /**
  240. * Generates Review structured data.
  241. *
  242. * Hooked into `woocommerce_review_meta` action hook.
  243. *
  244. * @param WP_Comment $comment Comment data.
  245. */
  246. public function generate_review_data( $comment ) {
  247. $markup = array();
  248. $markup['@type'] = 'Review';
  249. $markup['@id'] = get_comment_link( $comment->comment_ID );
  250. $markup['datePublished'] = get_comment_date( 'c', $comment->comment_ID );
  251. $markup['description'] = get_comment_text( $comment->comment_ID );
  252. $markup['itemReviewed'] = array(
  253. '@type' => 'Product',
  254. 'name' => get_the_title( $comment->comment_post_ID ),
  255. );
  256. // Skip replies unless they have a rating.
  257. $rating = get_comment_meta( $comment->comment_ID, 'rating', true );
  258. if ( $rating ) {
  259. $markup['reviewRating'] = array(
  260. '@type' => 'rating',
  261. 'ratingValue' => $rating,
  262. );
  263. } elseif ( $comment->comment_parent ) {
  264. return;
  265. }
  266. $markup['author'] = array(
  267. '@type' => 'Person',
  268. 'name' => get_comment_author( $comment->comment_ID ),
  269. );
  270. $this->set_data( apply_filters( 'woocommerce_structured_data_review', $markup, $comment ) );
  271. }
  272. /**
  273. * Generates BreadcrumbList structured data.
  274. *
  275. * Hooked into `woocommerce_breadcrumb` action hook.
  276. *
  277. * @param WC_Breadcrumb $breadcrumbs Breadcrumb data.
  278. */
  279. public function generate_breadcrumblist_data( $breadcrumbs ) {
  280. $crumbs = $breadcrumbs->get_breadcrumb();
  281. if ( empty( $crumbs ) || ! is_array( $crumbs ) ) {
  282. return;
  283. }
  284. $markup = array();
  285. $markup['@type'] = 'BreadcrumbList';
  286. $markup['itemListElement'] = array();
  287. foreach ( $crumbs as $key => $crumb ) {
  288. $markup['itemListElement'][ $key ] = array(
  289. '@type' => 'ListItem',
  290. 'position' => $key + 1,
  291. 'item' => array(
  292. 'name' => $crumb[0],
  293. ),
  294. );
  295. if ( ! empty( $crumb[1] ) && count( $crumbs ) !== $key + 1 ) {
  296. $markup['itemListElement'][ $key ]['item'] += array( '@id' => $crumb[1] );
  297. }
  298. }
  299. $this->set_data( apply_filters( 'woocommerce_structured_data_breadcrumblist', $markup, $breadcrumbs ) );
  300. }
  301. /**
  302. * Generates WebSite structured data.
  303. *
  304. * Hooked into `woocommerce_before_main_content` action hook.
  305. */
  306. public function generate_website_data() {
  307. $markup = array();
  308. $markup['@type'] = 'WebSite';
  309. $markup['name'] = get_bloginfo( 'name' );
  310. $markup['url'] = home_url();
  311. $markup['potentialAction'] = array(
  312. '@type' => 'SearchAction',
  313. 'target' => home_url( '?s={search_term_string}&post_type=product' ),
  314. 'query-input' => 'required name=search_term_string',
  315. );
  316. $this->set_data( apply_filters( 'woocommerce_structured_data_website', $markup ) );
  317. }
  318. /**
  319. * Generates Order structured data.
  320. *
  321. * Hooked into `woocommerce_email_order_details` action hook.
  322. *
  323. * @param WP_Order $order Order data.
  324. * @param bool $sent_to_admin Send to admin (default: false).
  325. * @param bool $plain_text Plain text email (default: false).
  326. */
  327. public function generate_order_data( $order, $sent_to_admin = false, $plain_text = false ) {
  328. if ( $plain_text || ! is_a( $order, 'WC_Order' ) ) {
  329. return;
  330. }
  331. $shop_name = get_bloginfo( 'name' );
  332. $shop_url = home_url();
  333. $order_url = $sent_to_admin ? $order->get_edit_order_url() : $order->get_view_order_url();
  334. $order_statuses = array(
  335. 'pending' => 'https://schema.org/OrderPaymentDue',
  336. 'processing' => 'https://schema.org/OrderProcessing',
  337. 'on-hold' => 'https://schema.org/OrderProblem',
  338. 'completed' => 'https://schema.org/OrderDelivered',
  339. 'cancelled' => 'https://schema.org/OrderCancelled',
  340. 'refunded' => 'https://schema.org/OrderReturned',
  341. 'failed' => 'https://schema.org/OrderProblem',
  342. );
  343. $markup_offers = array();
  344. foreach ( $order->get_items() as $item ) {
  345. if ( ! apply_filters( 'woocommerce_order_item_visible', true, $item ) ) {
  346. continue;
  347. }
  348. $product = $order->get_product_from_item( $item );
  349. $product_exists = is_object( $product );
  350. $is_visible = $product_exists && $product->is_visible();
  351. $markup_offers[] = array(
  352. '@type' => 'Offer',
  353. 'price' => $order->get_line_subtotal( $item ),
  354. 'priceCurrency' => $order->get_currency(),
  355. 'priceSpecification' => array(
  356. 'price' => $order->get_line_subtotal( $item ),
  357. 'priceCurrency' => $order->get_currency(),
  358. 'eligibleQuantity' => array(
  359. '@type' => 'QuantitativeValue',
  360. 'value' => apply_filters( 'woocommerce_email_order_item_quantity', $item['qty'], $item ),
  361. ),
  362. ),
  363. 'itemOffered' => array(
  364. '@type' => 'Product',
  365. 'name' => apply_filters( 'woocommerce_order_item_name', $item['name'], $item, $is_visible ),
  366. 'sku' => $product_exists ? $product->get_sku() : '',
  367. 'image' => $product_exists ? wp_get_attachment_image_url( $product->get_image_id() ) : '',
  368. 'url' => $is_visible ? get_permalink( $product->get_id() ) : get_home_url(),
  369. ),
  370. 'seller' => array(
  371. '@type' => 'Organization',
  372. 'name' => $shop_name,
  373. 'url' => $shop_url,
  374. ),
  375. );
  376. }
  377. $markup = array();
  378. $markup['@type'] = 'Order';
  379. $markup['url'] = $order_url;
  380. $markup['orderStatus'] = isset( $order_statuses[ $order->get_status() ] ) ? $order_statuses[ $order->get_status() ] : '';
  381. $markup['orderNumber'] = $order->get_order_number();
  382. $markup['orderDate'] = $order->get_date_created()->format( 'c' );
  383. $markup['acceptedOffer'] = $markup_offers;
  384. $markup['discount'] = $order->get_total_discount();
  385. $markup['discountCurrency'] = $order->get_currency();
  386. $markup['price'] = $order->get_total();
  387. $markup['priceCurrency'] = $order->get_currency();
  388. $markup['priceSpecification'] = array(
  389. 'price' => $order->get_total(),
  390. 'priceCurrency' => $order->get_currency(),
  391. 'valueAddedTaxIncluded' => 'true',
  392. );
  393. $markup['billingAddress'] = array(
  394. '@type' => 'PostalAddress',
  395. 'name' => $order->get_formatted_billing_full_name(),
  396. 'streetAddress' => $order->get_billing_address_1(),
  397. 'postalCode' => $order->get_billing_postcode(),
  398. 'addressLocality' => $order->get_billing_city(),
  399. 'addressRegion' => $order->get_billing_state(),
  400. 'addressCountry' => $order->get_billing_country(),
  401. 'email' => $order->get_billing_email(),
  402. 'telephone' => $order->get_billing_phone(),
  403. );
  404. $markup['customer'] = array(
  405. '@type' => 'Person',
  406. 'name' => $order->get_formatted_billing_full_name(),
  407. );
  408. $markup['merchant'] = array(
  409. '@type' => 'Organization',
  410. 'name' => $shop_name,
  411. 'url' => $shop_url,
  412. );
  413. $markup['potentialAction'] = array(
  414. '@type' => 'ViewAction',
  415. 'name' => 'View Order',
  416. 'url' => $order_url,
  417. 'target' => $order_url,
  418. );
  419. $this->set_data( apply_filters( 'woocommerce_structured_data_order', $markup, $sent_to_admin, $order ), true );
  420. }
  421. }