Completed
Push — master ( 527840...a5d62b )
by Mike
53:46 queued 43:33
created

WC_Structured_Data::generate_breadcrumblist_data()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 27

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
cc 5
nc 4
nop 1
dl 0
loc 27
ccs 0
cts 14
cp 0
crap 30
rs 9.1768
c 0
b 0
f 0
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
10
defined( 'ABSPATH' ) || exit;
11
12
/**
13
 * Structured data class.
14
 */
15
class WC_Structured_Data {
16
17
	/**
18
	 * Stores the structured data.
19
	 *
20
	 * @var array $_data Array of structured data.
21
	 */
22
	private $_data = array();
23
24
	/**
25
	 * Constructor.
26
	 */
27
	public function __construct() {
28
		// Generate structured data.
29
		add_action( 'woocommerce_before_main_content', array( $this, 'generate_website_data' ), 30 );
30
		add_action( 'woocommerce_breadcrumb', array( $this, 'generate_breadcrumblist_data' ), 10 );
31
		add_action( 'woocommerce_shop_loop', array( $this, 'generate_product_data' ), 10 );
32
		add_action( 'woocommerce_single_product_summary', array( $this, 'generate_product_data' ), 60 );
33
		add_action( 'woocommerce_review_meta', array( $this, 'generate_review_data' ), 20 );
34
		add_action( 'woocommerce_email_order_details', array( $this, 'generate_order_data' ), 20, 3 );
35
36
		// Output structured data.
37
		add_action( 'woocommerce_email_order_details', array( $this, 'output_email_structured_data' ), 30, 3 );
38
		add_action( 'wp_footer', array( $this, 'output_structured_data' ), 10 );
39
	}
40
41
	/**
42
	 * Sets data.
43
	 *
44
	 * @param  array $data  Structured data.
45
	 * @param  bool  $reset Unset data (default: false).
46
	 * @return bool
47
	 */
48
	public function set_data( $data, $reset = false ) {
49
		if ( ! isset( $data['@type'] ) || ! preg_match( '|^[a-zA-Z]{1,20}$|', $data['@type'] ) ) {
50
			return false;
51
		}
52
53
		if ( $reset && isset( $this->_data ) ) {
54
			unset( $this->_data );
55
		}
56
57
		$this->_data[] = $data;
58
59
		return true;
60
	}
61
62
	/**
63
	 * Gets data.
64
	 *
65
	 * @return array
66
	 */
67
	public function get_data() {
68
		return $this->_data;
69
	}
70
71
	/**
72
	 * Structures and returns data.
73
	 *
74
	 * List of types available by default for specific request:
75
	 *
76
	 * 'product',
77
	 * 'review',
78
	 * 'breadcrumblist',
79
	 * 'website',
80
	 * 'order',
81
	 *
82
	 * @param  array $types Structured data types.
83
	 * @return array
84
	 */
85
	public function get_structured_data( $types ) {
86
		$data = array();
87
88
		// Put together the values of same type of structured data.
89
		foreach ( $this->get_data() as $value ) {
90
			$data[ strtolower( $value['@type'] ) ][] = $value;
91
		}
92
93
		// Wrap the multiple values of each type inside a graph... Then add context to each type.
94
		foreach ( $data as $type => $value ) {
95
			$data[ $type ] = count( $value ) > 1 ? array( '@graph' => $value ) : $value[0];
96
			$data[ $type ] = apply_filters( 'woocommerce_structured_data_context', array( '@context' => 'https://schema.org/' ), $data, $type, $value ) + $data[ $type ];
97
		}
98
99
		// If requested types, pick them up... Finally change the associative array to an indexed one.
100
		$data = $types ? array_values( array_intersect_key( $data, array_flip( $types ) ) ) : array_values( $data );
101
102
		if ( ! empty( $data ) ) {
103
			if ( 1 < count( $data ) ) {
104
				$data = apply_filters( 'woocommerce_structured_data_context', array( '@context' => 'https://schema.org/' ), $data, '', '' ) + array( '@graph' => $data );
105
			} else {
106
				$data = $data[0];
107
			}
108
		}
109
110
		return $data;
111
	}
112
113
	/**
114
	 * Get data types for pages.
115
	 *
116
	 * @return array
117
	 */
118
	protected function get_data_type_for_page() {
119
		$types   = array();
120
		$types[] = is_shop() || is_product_category() || is_product() ? 'product' : '';
121
		$types[] = is_shop() && is_front_page() ? 'website' : '';
122
		$types[] = is_product() ? 'review' : '';
123
		$types[] = ! is_shop() ? 'breadcrumblist' : '';
124
		$types[] = 'order';
125
126
		return array_filter( apply_filters( 'woocommerce_structured_data_type_for_page', $types ) );
127
	}
128
129
	/**
130
	 * Makes sure email structured data only outputs on non-plain text versions.
131
	 *
132
	 * @param WP_Order $order         Order data.
133
	 * @param bool     $sent_to_admin Send to admin (default: false).
134
	 * @param bool     $plain_text    Plain text email (default: false).
135
	 */
136
	public function output_email_structured_data( $order, $sent_to_admin = false, $plain_text = false ) {
137
		if ( $plain_text ) {
138
			return;
139
		}
140
		echo '<div style="display: none; font-size: 0; max-height: 0; line-height: 0; padding: 0; mso-hide: all;">';
141
		$this->output_structured_data();
142
		echo '</div>';
143
	}
144
145
	/**
146
	 * Sanitizes, encodes and outputs structured data.
147
	 *
148
	 * Hooked into `wp_footer` action hook.
149
	 * Hooked into `woocommerce_email_order_details` action hook.
150
	 */
151
	public function output_structured_data() {
152
		$types = $this->get_data_type_for_page();
153
		$data  = $this->get_structured_data( $types );
154
155
		if ( $data ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
156
			echo '<script type="application/ld+json">' . wp_json_encode( $data ) . '</script>';
157
		}
158
	}
159
160
	/*
161
	|--------------------------------------------------------------------------
162
	| Generators
163
	|--------------------------------------------------------------------------
164
	|
165
	| Methods for generating specific structured data types:
166
	|
167
	| - Product
168
	| - Review
169
	| - BreadcrumbList
170
	| - WebSite
171
	| - Order
172
	|
173
	| The generated data is stored into `$this->_data`.
174
	| See the methods above for handling `$this->_data`.
175
	|
176
	*/
177
178
	/**
179
	 * Generates Product structured data.
180
	 *
181
	 * Hooked into `woocommerce_single_product_summary` action hook.
182
	 * Hooked into `woocommerce_shop_loop` action hook.
183
	 *
184
	 * @param WC_Product $product Product data (default: null).
185
	 */
186
	public function generate_product_data( $product = null ) {
187
		if ( ! is_object( $product ) ) {
188
			global $product;
189
		}
190
191
		if ( ! is_a( $product, 'WC_Product' ) ) {
192
			return;
193
		}
194
195
		$shop_name = get_bloginfo( 'name' );
196
		$shop_url  = home_url();
197
		$currency  = get_woocommerce_currency();
198
		$permalink = get_permalink( $product->get_id() );
199
200
		$markup = array(
201
			'@type' => 'Product',
202
			'@id'   => $permalink . '#product', // Append '#product' to differentiate between this @id and the @id generated for the Breadcrumblist.
203
			'name'  => $product->get_name(),
0 ignored issues
show
Bug introduced by
It seems like $product is not always an object, but can also be of type null. Maybe add an additional type check?

If a variable is not always an object, we recommend to add an additional type check to ensure your method call is safe:

function someFunction(A $objectMaybe = null)
{
    if ($objectMaybe instanceof A) {
        $objectMaybe->doSomething();
    }
}
Loading history...
204
		);
205
206
		if ( apply_filters( 'woocommerce_structured_data_product_limit', is_product_taxonomy() || is_shop() ) ) {
207
			$markup['url'] = $permalink;
208
209
			$this->set_data( apply_filters( 'woocommerce_structured_data_product_limited', $markup, $product ) );
210
			return;
211
		}
212
213
		$markup['image']       = wp_get_attachment_url( $product->get_image_id() );
214
		$markup['description'] = wpautop( do_shortcode( $product->get_short_description() ? $product->get_short_description() : $product->get_description() ) );
215
		$markup['sku']         = $product->get_sku();
216
217
		if ( '' !== $product->get_price() ) {
218
			if ( $product->is_type( 'variable' ) ) {
219
				$lowest  = $product->get_variation_price( 'min', false );
220
				$highest = $product->get_variation_price( 'max', false );
221
222
				if ( $lowest === $highest ) {
223
					$markup_offer = array(
224
						'@type'              => 'Offer',
225
						'price'              => wc_format_decimal( $lowest, wc_get_price_decimals() ),
226
						'priceSpecification' => array(
227
							'price'                 => wc_format_decimal( $lowest, wc_get_price_decimals() ),
228
							'priceCurrency'         => $currency,
229
							'valueAddedTaxIncluded' => wc_prices_include_tax() ? 'true' : 'false',
230
						),
231
					);
232
				} else {
233
					$markup_offer = array(
234
						'@type'     => 'AggregateOffer',
235
						'lowPrice'  => wc_format_decimal( $lowest, wc_get_price_decimals() ),
236
						'highPrice' => wc_format_decimal( $highest, wc_get_price_decimals() ),
237
					);
238
				}
239
			} else {
240
				$markup_offer = array(
241
					'@type'              => 'Offer',
242
					'price'              => wc_format_decimal( $product->get_price(), wc_get_price_decimals() ),
243
					'priceSpecification' => array(
244
						'price'                 => wc_format_decimal( $product->get_price(), wc_get_price_decimals() ),
245
						'priceCurrency'         => $currency,
246
						'valueAddedTaxIncluded' => wc_prices_include_tax() ? 'true' : 'false',
247
					),
248
				);
249
			}
250
251
			$markup_offer += array(
252
				'priceCurrency' => $currency,
253
				'availability'  => 'https://schema.org/' . ( $product->is_in_stock() ? 'InStock' : 'OutOfStock' ),
254
				'url'           => $permalink,
255
				'seller'        => array(
256
					'@type' => 'Organization',
257
					'name'  => $shop_name,
258
					'url'   => $shop_url,
259
				),
260
			);
261
262
			$markup['offers'] = array( apply_filters( 'woocommerce_structured_data_product_offer', $markup_offer, $product ) );
263
		}
264
265
		if ( $product->get_review_count() && wc_review_ratings_enabled() ) {
266
			$markup['aggregateRating'] = array(
267
				'@type'       => 'AggregateRating',
268
				'ratingValue' => $product->get_average_rating(),
269
				'reviewCount' => $product->get_review_count(),
270
			);
271
		}
272
273
		$this->set_data( apply_filters( 'woocommerce_structured_data_product', $markup, $product ) );
274
	}
275
276
	/**
277
	 * Generates Review structured data.
278
	 *
279
	 * Hooked into `woocommerce_review_meta` action hook.
280
	 *
281
	 * @param WP_Comment $comment Comment data.
282
	 */
283
	public function generate_review_data( $comment ) {
284
		$markup                  = array();
285
		$markup['@type']         = 'Review';
286
		$markup['@id']           = get_comment_link( $comment->comment_ID );
287
		$markup['datePublished'] = get_comment_date( 'c', $comment->comment_ID );
288
		$markup['description']   = get_comment_text( $comment->comment_ID );
289
		$markup['itemReviewed']  = array(
290
			'@type' => 'Product',
291
			'name'  => get_the_title( $comment->comment_post_ID ),
292
		);
293
294
		// Skip replies unless they have a rating.
295
		$rating = get_comment_meta( $comment->comment_ID, 'rating', true );
296
297
		if ( $rating ) {
298
			$markup['reviewRating'] = array(
299
				'@type'       => 'rating',
300
				'ratingValue' => $rating,
301
			);
302
		} elseif ( $comment->comment_parent ) {
303
			return;
304
		}
305
306
		$markup['author'] = array(
307
			'@type' => 'Person',
308
			'name'  => get_comment_author( $comment->comment_ID ),
309
		);
310
311
		$this->set_data( apply_filters( 'woocommerce_structured_data_review', $markup, $comment ) );
312
	}
313
314
	/**
315
	 * Generates BreadcrumbList structured data.
316
	 *
317
	 * Hooked into `woocommerce_breadcrumb` action hook.
318
	 *
319
	 * @param WC_Breadcrumb $breadcrumbs Breadcrumb data.
320
	 */
321
	public function generate_breadcrumblist_data( $breadcrumbs ) {
322
		$crumbs = $breadcrumbs->get_breadcrumb();
323
324
		if ( empty( $crumbs ) || ! is_array( $crumbs ) ) {
325
			return;
326
		}
327
328
		$markup                    = array();
329
		$markup['@type']           = 'BreadcrumbList';
330
		$markup['itemListElement'] = array();
331
332
		foreach ( $crumbs as $key => $crumb ) {
333
			$markup['itemListElement'][ $key ] = array(
334
				'@type'    => 'ListItem',
335
				'position' => $key + 1,
336
				'item'     => array(
337
					'name' => $crumb[0],
338
				),
339
			);
340
341
			if ( ! empty( $crumb[1] ) ) {
342
				$markup['itemListElement'][ $key ]['item'] += array( '@id' => $crumb[1] );
343
			}
344
		}
345
346
		$this->set_data( apply_filters( 'woocommerce_structured_data_breadcrumblist', $markup, $breadcrumbs ) );
347
	}
348
349
	/**
350
	 * Generates WebSite structured data.
351
	 *
352
	 * Hooked into `woocommerce_before_main_content` action hook.
353
	 */
354
	public function generate_website_data() {
355
		$markup                    = array();
356
		$markup['@type']           = 'WebSite';
357
		$markup['name']            = get_bloginfo( 'name' );
358
		$markup['url']             = home_url();
359
		$markup['potentialAction'] = array(
360
			'@type'       => 'SearchAction',
361
			'target'      => home_url( '?s={search_term_string}&post_type=product' ),
362
			'query-input' => 'required name=search_term_string',
363
		);
364
365
		$this->set_data( apply_filters( 'woocommerce_structured_data_website', $markup ) );
366
	}
367
368
	/**
369
	 * Generates Order structured data.
370
	 *
371
	 * Hooked into `woocommerce_email_order_details` action hook.
372
	 *
373
	 * @param WP_Order $order         Order data.
374
	 * @param bool     $sent_to_admin Send to admin (default: false).
375
	 * @param bool     $plain_text    Plain text email (default: false).
376
	 */
377
	public function generate_order_data( $order, $sent_to_admin = false, $plain_text = false ) {
378
		if ( $plain_text || ! is_a( $order, 'WC_Order' ) ) {
379
			return;
380
		}
381
382
		$shop_name      = get_bloginfo( 'name' );
383
		$shop_url       = home_url();
384
		$order_url      = $sent_to_admin ? $order->get_edit_order_url() : $order->get_view_order_url();
385
		$order_statuses = array(
386
			'pending'    => 'https://schema.org/OrderPaymentDue',
387
			'processing' => 'https://schema.org/OrderProcessing',
388
			'on-hold'    => 'https://schema.org/OrderProblem',
389
			'completed'  => 'https://schema.org/OrderDelivered',
390
			'cancelled'  => 'https://schema.org/OrderCancelled',
391
			'refunded'   => 'https://schema.org/OrderReturned',
392
			'failed'     => 'https://schema.org/OrderProblem',
393
		);
394
395
		$markup_offers = array();
396
		foreach ( $order->get_items() as $item ) {
397
			if ( ! apply_filters( 'woocommerce_order_item_visible', true, $item ) ) {
398
				continue;
399
			}
400
401
			$product        = $order->get_product_from_item( $item );
402
			$product_exists = is_object( $product );
403
			$is_visible     = $product_exists && $product->is_visible();
404
405
			$markup_offers[] = array(
406
				'@type'              => 'Offer',
407
				'price'              => $order->get_line_subtotal( $item ),
408
				'priceCurrency'      => $order->get_currency(),
409
				'priceSpecification' => array(
410
					'price'            => $order->get_line_subtotal( $item ),
411
					'priceCurrency'    => $order->get_currency(),
412
					'eligibleQuantity' => array(
413
						'@type' => 'QuantitativeValue',
414
						'value' => apply_filters( 'woocommerce_email_order_item_quantity', $item['qty'], $item ),
415
					),
416
				),
417
				'itemOffered'        => array(
418
					'@type' => 'Product',
419
					'name'  => apply_filters( 'woocommerce_order_item_name', $item['name'], $item, $is_visible ),
420
					'sku'   => $product_exists ? $product->get_sku() : '',
421
					'image' => $product_exists ? wp_get_attachment_image_url( $product->get_image_id() ) : '',
422
					'url'   => $is_visible ? get_permalink( $product->get_id() ) : get_home_url(),
423
				),
424
				'seller'             => array(
425
					'@type' => 'Organization',
426
					'name'  => $shop_name,
427
					'url'   => $shop_url,
428
				),
429
			);
430
		}
431
432
		$markup                       = array();
433
		$markup['@type']              = 'Order';
434
		$markup['url']                = $order_url;
435
		$markup['orderStatus']        = isset( $order_statuses[ $order->get_status() ] ) ? $order_statuses[ $order->get_status() ] : '';
436
		$markup['orderNumber']        = $order->get_order_number();
437
		$markup['orderDate']          = $order->get_date_created()->format( 'c' );
438
		$markup['acceptedOffer']      = $markup_offers;
439
		$markup['discount']           = $order->get_total_discount();
440
		$markup['discountCurrency']   = $order->get_currency();
441
		$markup['price']              = $order->get_total();
442
		$markup['priceCurrency']      = $order->get_currency();
443
		$markup['priceSpecification'] = array(
444
			'price'                 => $order->get_total(),
445
			'priceCurrency'         => $order->get_currency(),
446
			'valueAddedTaxIncluded' => 'true',
447
		);
448
		$markup['billingAddress']     = array(
449
			'@type'           => 'PostalAddress',
450
			'name'            => $order->get_formatted_billing_full_name(),
451
			'streetAddress'   => $order->get_billing_address_1(),
452
			'postalCode'      => $order->get_billing_postcode(),
453
			'addressLocality' => $order->get_billing_city(),
454
			'addressRegion'   => $order->get_billing_state(),
455
			'addressCountry'  => $order->get_billing_country(),
456
			'email'           => $order->get_billing_email(),
457
			'telephone'       => $order->get_billing_phone(),
458
		);
459
		$markup['customer']           = array(
460
			'@type' => 'Person',
461
			'name'  => $order->get_formatted_billing_full_name(),
462
		);
463
		$markup['merchant']           = array(
464
			'@type' => 'Organization',
465
			'name'  => $shop_name,
466
			'url'   => $shop_url,
467
		);
468
		$markup['potentialAction']    = array(
469
			'@type'  => 'ViewAction',
470
			'name'   => 'View Order',
471
			'url'    => $order_url,
472
			'target' => $order_url,
473
		);
474
475
		$this->set_data( apply_filters( 'woocommerce_structured_data_order', $markup, $sent_to_admin, $order ), true );
476
	}
477
}
478