PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 All 51 releases
fluent-cart / app / Services / Schema / ProductSchema.php

ProductSchema.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.7.1, at app/Services/Schema/ProductSchema.php

646 lines 23.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Schema;
4
5 use FluentCart\Api\CurrencySettings;
6 use FluentCart\Api\ModuleSettings;
7 use FluentCart\App\Helpers\Helper;
8 use FluentCart\App\Helpers\Status;
9 use FluentCart\App\Models\ProductMeta;
10 use FluentCart\App\Models\ProductReview;
11 use FluentCart\App\Models\ProductVariation;
12 use FluentCart\App\Services\ProductReviewService;
13 use FluentCart\App\Services\Renderer\ProductReviewRenderer;
14 use FluentCart\Framework\Support\Arr;
15
16 /**
17 * Product JSON-LD for the single product page.
18 *
19 * One schema.org Product node carrying what the product page sells — an
20 * Offer per active variation, with price, currency and availability — and,
21 * when the reviews module is on, the product's aggregate rating and the
22 * individual reviews the page shows. Nothing is stored: the node is built
23 * on every render from the product post, fct_product_variations, the
24 * rating summary that recalculateProductRatings() maintains, and
25 * fct_product_reviews. Change a price, sell out, approve or trash a review,
26 * and the next request reflects it.
27 *
28 * Google's review snippet rules shape what goes in:
29 * - only content the reader can see on the page — approved, top-level
30 * reviews, as many as the list renders and never more than MAX_REVIEWS;
31 * - a Review needs a rating, so unrated reviews are left out of review[]
32 * while still counted in aggregateRating;
33 * - numeric ratingValue against bestRating 5 / worstRating 1, never the
34 * Poor/Excellent labels the UI shows.
35 */
36 class ProductSchema
37 {
38 /** Ceiling on review[] regardless of the store's page length. */
39 public const MAX_REVIEWS = 20;
40
41 /** Ceiling on the variations read for offers. */
42 public const MAX_OFFERS = 100;
43
44 /** Legacy hook name retained for existing integrations. */
45 public const FILTER_HOOK = 'fluent_cart/review/json_ld';
46
47 /**
48 * The Product node for a product, or [] when there is nothing worth
49 * emitting — no product, or nothing to sell and no approved reviews. A
50 * Product node without offers, rating or review is not eligible for
51 * anything, and emitting one would only invite a second, competing
52 * node from an SEO plugin.
53 *
54 * @param int $postId
55 * @return array
56 */
57 public static function get($postId): array
58 {
59 $postId = (int) $postId;
60
61 /**
62 * Enable Product JSON-LD generation. Return false to skip the entire
63 * node before loading product, offer or review data. For example:
64 * add_filter('fluent_cart/product/schema_enabled', '__return_false');
65 *
66 * @param bool $enabled Whether to generate schema. Defaults to true.
67 * @param array $context Product post_id.
68 */
69 if (!apply_filters('fluent_cart/product/schema_enabled', true, ['post_id' => $postId])) {
70 return [];
71 }
72
73 $product = $postId ? get_post($postId) : null;
74
75 // Only what a visitor can see. A draft or pending product is a
76 // preview for its editor; a private one is for logged-in readers; a
77 // password-protected one shows its content to nobody until the
78 // password is given — and the crawler gives none. Structured data
79 // for any of them would publish prices and reviews the page keeps
80 // back. 'private' is admitted because the buy button sells private
81 // products to the readers who can open them (ProductVariation's
82 // purchasability check), and a crawler never reaches the page.
83 if (!$product
84 || !in_array($product->post_status, ['publish', 'private'], true)
85 || post_password_required($product)
86 ) {
87 return [];
88 }
89
90 $url = (string) get_permalink($product);
91 $variations = static::activeVariations($postId);
92 $offers = static::offersNode($variations, $url, static::offerBounds($postId));
93
94 $summary = static::reviewsVisible($postId)
95 ? ProductReviewService::getProductRatingSummary($postId)
96 : ['total' => 0, 'average' => 0, 'breakdown' => []];
97 $hasReviews = (int) Arr::get($summary, 'total', 0) > 0;
98
99 if (!$offers && !$hasReviews) {
100 return [];
101 }
102
103 $schema = [
104 '@context' => 'https://schema.org',
105 '@type' => 'Product',
106 'name' => static::text($product->post_title),
107 'url' => $url,
108 ];
109
110 $brands = static::brandNodes($postId);
111 if ($brands) {
112 $schema['brand'] = count($brands) === 1 ? $brands[0] : $brands;
113 }
114
115 $image = static::featuredImageUrl($postId, $variations);
116 if ($image !== '') {
117 $schema['image'] = [$image];
118 }
119
120 $description = static::description($product);
121 if ($description !== '') {
122 $schema['description'] = $description;
123 }
124
125 // A product-level SKU only when one variation is the product. With
126 // several, each SKU sits on its own offer, and lifting the first
127 // to the product would mislabel it.
128 if ($offers && $offers['@type'] === 'Offer') {
129 $sku = trim((string) $variations[0]->sku);
130 if ($sku !== '') {
131 $schema['sku'] = $sku;
132 }
133 }
134
135 if ($offers) {
136 $schema['offers'] = $offers;
137 }
138
139 if ($hasReviews) {
140 $schema['aggregateRating'] = [
141 '@type' => 'AggregateRating',
142 'ratingValue' => (string) Arr::get($summary, 'average', 0),
143 'reviewCount' => (int) Arr::get($summary, 'total', 0),
144 'bestRating' => '5',
145 'worstRating' => '1',
146 ];
147
148 $reviews = static::reviewNodes($postId);
149 if ($reviews) {
150 $schema['review'] = $reviews;
151 }
152 }
153
154 $context = [
155 'post_id' => $postId,
156 'summary' => $summary,
157 ];
158
159 /**
160 * Filter the entire Product JSON-LD node, including offers and reviews.
161 * Return an empty array to suppress output. For example:
162 * add_filter('fluent_cart/product/json_ld', '__return_empty_array');
163 *
164 * @param array $schema Product node.
165 * @param array $context Product post_id and visible review summary.
166 */
167 $schema = apply_filters('fluent_cart/product/json_ld', $schema, $context);
168
169 // Keep the original hook last so existing customizations still apply.
170 return apply_filters(static::FILTER_HOOK, $schema, $context);
171 }
172
173 /** Only brands assigned to this product, never the store name as a fallback. */
174 protected static function brandNodes(int $postId): array
175 {
176 $terms = get_the_terms($postId, 'product-brands');
177 if (!$terms || is_wp_error($terms)) {
178 return [];
179 }
180
181 $brands = [];
182 foreach ($terms as $term) {
183 $name = static::text($term->name);
184 if ($name !== '') {
185 $brands[] = ['@type' => 'Brand', 'name' => $name];
186 }
187 }
188
189 return $brands;
190 }
191
192 /**
193 * Whether the page shows this product's reviews: the module on — the
194 * gate TemplateActions puts before the reviews section — and then the
195 * renderer's own policy: the store's Enable Product Reviews switch,
196 * the product's toggle, and the single-page setting with its filter.
197 * The same decision the list makes, so the schema never names a
198 * review the page does not render.
199 */
200 protected static function reviewsVisible($postId): bool
201 {
202 return ModuleSettings::isActive('reviews')
203 && ProductReviewRenderer::isVisibleFor($postId);
204 }
205
206 /**
207 * The variations the product page offers, in its order. Active rows
208 * only: a draft variation is not on the page, so it is not for sale.
209 * The product and its detail come along in two queries because the
210 * stock checks consult them.
211 *
212 * @param int $postId
213 * @return ProductVariation[]
214 */
215 protected static function activeVariations($postId): array
216 {
217 $rows = ProductVariation::query()
218 ->where('post_id', $postId)
219 ->where('item_status', 'active')
220 ->with(['product', 'product.detail'])
221 ->orderBy('serial_index', 'ASC')
222 ->orderBy('id', 'ASC')
223 ->limit(static::MAX_OFFERS)
224 ->get();
225
226 return $rows ? $rows->all() : [];
227 }
228
229 /**
230 * What the product sells as a whole, over every active variation and
231 * not only the MAX_OFFERS the nested offers expand: how many, and the
232 * cheapest and dearest. One aggregate query, so offerCount, lowPrice
233 * and highPrice are right however many variations the product has.
234 *
235 * @param int $postId
236 * @return array{count:int, low:float, high:float}
237 */
238 protected static function offerBounds($postId): array
239 {
240 $row = ProductVariation::query()
241 ->where('post_id', $postId)
242 ->where('item_status', 'active')
243 ->selectRaw('COUNT(*) as offer_count, MIN(item_price) as low_price, MAX(item_price) as high_price')
244 ->first();
245
246 return [
247 'count' => $row ? (int) $row->offer_count : 0,
248 'low' => $row ? (float) $row->low_price : 0.0,
249 'high' => $row ? (float) $row->high_price : 0.0,
250 ];
251 }
252
253 /**
254 * offers: one Offer for a single variation, an AggregateOffer for
255 * several. The aggregate's count and price bounds come from every
256 * active variation; its nested offers are the first MAX_OFFERS in
257 * page order, so a product past that cap still states the complete
258 * offering while the crawler is not handed hundreds of nodes. Prices
259 * are the stored cents as a decimal string in the store currency —
260 * never the formatted display price, which carries signs, separators
261 * and translated digits the crawler cannot parse.
262 *
263 * @param ProductVariation[] $variations
264 * @param string $url
265 * @param array $bounds from offerBounds()
266 * @return array
267 */
268 protected static function offersNode(array $variations, string $url, array $bounds): array
269 {
270 if (!$variations || (int) Arr::get($bounds, 'count', 0) < 1) {
271 return [];
272 }
273
274 // CurrencySettings directly, not Helper::shopConfig(): that helper
275 // also reads the store's shipping packages, which no offer needs.
276 $currencySettings = CurrencySettings::get();
277 $currency = strtoupper((string) Arr::get($currencySettings, 'currency', 'USD'));
278 $decimals = Arr::get($currencySettings, 'is_zero_decimal') ? 0 : 2;
279
280 // Availability the way the buy button decides it: with the stock
281 // module off everything on the page is for sale, whatever the
282 // stock columns hold; with it on, the product and the variation
283 // both have to be in stock. Same rule as ProductRenderer's
284 // direct-checkout button, so the schema never says OutOfStock for
285 // a variation the page sells, or InStock for one it refuses.
286 $stockManaged = ModuleSettings::isActive('stock_management');
287 $product = $variations[0]->product;
288 $detail = $product ? $product->detail : null;
289
290 // The product-level flag, read from the detail already loaded with
291 // the variations — the same rule as Product::isStock() short of its
292 // bundle branch. That branch is not called here: it lazy-loads the
293 // whole variants relation and queries the default variation's
294 // children, and both are covered below, once, for every variation.
295 $productInStock = !$stockManaged
296 || !$detail
297 || !$detail->manage_stock
298 || $detail->stock_availability === Helper::IN_STOCK;
299
300 // A bundle's stock is its children's. One query for every child of
301 // every variation on the page, instead of one per variation from
302 // isStock() on its own — a bundle with many variations is the case
303 // MAX_OFFERS exists for. Non-bundles read nothing.
304 $bundleChildren = $stockManaged && $product && $product->isBundleProduct()
305 ? ProductVariation::loadBundleChildren($variations)
306 : [];
307
308 // Only pricing flags are needed here. TaxModule::getSettings() also
309 // loads every EU VAT registration, which schema never uses.
310 $taxSettings = wp_parse_args(get_option('fluent_cart_tax_configuration_settings', []), [
311 'enable_tax' => 'no',
312 'tax_inclusion' => 'included',
313 ]);
314 $offers = [];
315
316 foreach ($variations as $variation) {
317 $cents = (float) $variation->item_price;
318 $inStock = !$stockManaged || ($productInStock && $variation->isStock($bundleChildren));
319
320 $offer = [
321 '@type' => 'Offer',
322 'url' => $url,
323 'price' => static::price($cents, $decimals),
324 'priceCurrency' => $currency,
325 'availability' => $inStock
326 ? 'https://schema.org/InStock'
327 : 'https://schema.org/OutOfStock',
328 ];
329
330 $priceSpecification = static::priceSpecification($variation, $offer, $taxSettings);
331 if ($priceSpecification) {
332 $offer['priceSpecification'] = $priceSpecification;
333 }
334
335 $title = static::text($variation->variation_title);
336 if ($bounds['count'] > 1 && $title !== '') {
337 $offer['name'] = $title;
338 }
339
340 $sku = trim((string) $variation->sku);
341 if ($sku !== '') {
342 $offer['sku'] = $sku;
343 }
344
345 $offers[] = $offer;
346 }
347
348 if ((int) $bounds['count'] === 1) {
349 return $offers[0];
350 }
351
352 return [
353 '@type' => 'AggregateOffer',
354 'url' => $url,
355 'priceCurrency' => $currency,
356 'lowPrice' => static::price($bounds['low'], $decimals),
357 'highPrice' => static::price($bounds['high'], $decimals),
358 'offerCount' => (int) $bounds['count'],
359 'offers' => $offers,
360 ];
361 }
362
363 /**
364 * Describe the stored offer price without applying visitor-specific taxes.
365 * Variation tax overrides follow the same precedence as TaxCalculator.
366 * Recurring prices use referenceQuantity for the period they purchase;
367 * billingDuration is reserved for a known, finite payment term.
368 */
369 protected static function priceSpecification(ProductVariation $variation, array $offer, array $taxSettings): array
370 {
371 $subscription = $variation->payment_type === 'subscription';
372 $taxEnabled = Arr::get($taxSettings, 'enable_tax', 'no') === 'yes';
373 if (!$subscription && !$taxEnabled) {
374 return [];
375 }
376
377 $specification = [
378 '@type' => $subscription ? 'UnitPriceSpecification' : 'PriceSpecification',
379 'price' => $offer['price'],
380 'priceCurrency' => $offer['priceCurrency'],
381 ];
382 $otherInfo = $variation->other_info;
383
384 if ($taxEnabled) {
385 $inclusion = Arr::get($otherInfo, 'tax_inclusion');
386 if (!in_array($inclusion, ['included', 'excluded'], true)) {
387 $inclusion = Arr::get($taxSettings, 'tax_inclusion');
388 }
389 $specification['valueAddedTaxIncluded'] = $inclusion === 'included';
390 }
391
392 if ($subscription) {
393 // Resolve the billing unit through the same filtered map as frontend terms.
394 $intervalMaps = Helper::getAvailableSubscriptionIntervalMaps();
395 $interval = Arr::get($otherInfo, 'repeat_interval');
396 $billingUnit = Arr::get($intervalMaps, $interval, '');
397
398 // Convert frontend units to UN/CEFACT units without localized labels.
399 $periods = [
400 'day' => [1, 'DAY'],
401 'week' => [1, 'WEE'],
402 'month' => [1, 'MON'],
403 'quarter' => [3, 'MON'],
404 'half_year' => [6, 'MON'],
405 'year' => [1, 'ANN'],
406 ];
407 // Custom intervals with unknown units have no inferred duration.
408 if (isset($periods[$billingUnit])) {
409 list($quantity, $unit) = $periods[$billingUnit];
410 $specification['unitCode'] = $unit;
411 $specification['billingIncrement'] = $quantity;
412 $specification['referenceQuantity'] = [
413 '@type' => 'QuantitativeValue',
414 'value' => $quantity,
415 'unitCode' => $unit,
416 ];
417 $times = (int) Arr::get($otherInfo, 'times', 0);
418 if ($times > 0) {
419 $specification['billingDuration'] = $quantity * $times;
420 }
421 }
422 }
423
424 return $specification;
425 }
426
427 /**
428 * Stored cents to a plain decimal string, the way Helper::toDecimal()
429 * scales them but without its display formatting: "1999" → "19.99",
430 * and "1999" → "20" in a zero-decimal currency.
431 */
432 protected static function price($cents, int $decimals): string
433 {
434 return number_format(((float) $cents) / 100, $decimals, '.', '');
435 }
436
437 /**
438 * Echo the ld+json script tag for a product, or nothing.
439 *
440 * JSON_HEX_TAG turns < and > into \u003C / \u003E so a review body
441 * holding "</script>" cannot close the tag early; the encoded form is
442 * still valid JSON for the parser.
443 *
444 * @param int $postId
445 */
446 public static function render($postId): void
447 {
448 $schema = static::get($postId);
449
450 if (!$schema) {
451 return;
452 }
453
454 echo '<script type="application/ld+json">' . wp_json_encode($schema, JSON_HEX_TAG | JSON_HEX_AMP) . '</script>' . "\n";
455 }
456
457 /**
458 * How many reviews review[] holds: the store's page length, since that
459 * is what the first server-rendered page shows, capped at MAX_REVIEWS.
460 */
461 public static function reviewLimit(): int
462 {
463 $perPage = (int) Arr::get(ProductReviewService::getReviewSettings(), 'reviews_per_page', 10);
464
465 return max(1, min(static::MAX_REVIEWS, $perPage));
466 }
467
468 /**
469 * Review nodes in the order the list shows them by default: newest
470 * first, id as the tie-breaker. Same predicates as the public listing
471 * (ProductReviewResource::get with status=approved): top-level rows of
472 * this product in approved status. Rated only, on top of that, because
473 * a Review node without reviewRating fails validation.
474 *
475 * @param int $postId
476 * @return array
477 */
478 protected static function reviewNodes($postId): array
479 {
480 $rows = ProductReview::query()
481 ->select(['id', 'reviewer_name', 'title', 'review', 'rating', 'created_at'])
482 ->topLevel()
483 ->ofStatus(Status::REVIEW_APPROVED)
484 ->ofProduct($postId)
485 ->whereBetween('rating', [1, 5])
486 ->orderBy('created_at', 'DESC')
487 ->orderBy('id', 'DESC')
488 ->limit(static::reviewLimit())
489 ->get();
490
491 $nodes = [];
492
493 foreach ($rows as $row) {
494 $node = [
495 '@type' => 'Review',
496 'author' => [
497 '@type' => 'Person',
498 'name' => static::authorName($row->reviewer_name),
499 ],
500 ];
501
502 $published = static::datePublished($row->created_at);
503 if ($published !== '') {
504 $node['datePublished'] = $published;
505 }
506
507 $title = static::text($row->title);
508 if ($title !== '') {
509 $node['name'] = $title;
510 }
511
512 $body = static::text($row->content);
513 if ($body !== '') {
514 $node['reviewBody'] = $body;
515 }
516
517 $node['reviewRating'] = [
518 '@type' => 'Rating',
519 'ratingValue' => (string) (int) $row->rating,
520 'bestRating' => '5',
521 'worstRating' => '1',
522 ];
523
524 $nodes[] = $node;
525 }
526
527 return $nodes;
528 }
529
530 /**
531 * Stored text as the reader sees it. Request sanitizers store "<" as
532 * "&lt;" and the page's esc_html() leaves that entity alone, so the
533 * reader sees "<"; JSON-LD is not HTML, so the entity has to be
534 * decoded here or the crawler reads a literal "&lt;". Tags go first,
535 * so an encoded "&lt;script&gt;" becomes plain text, which
536 * JSON_HEX_TAG then keeps inert inside the script element.
537 */
538 protected static function text($value): string
539 {
540 $value = wp_strip_all_tags((string) $value);
541
542 return trim(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
543 }
544
545 /**
546 * The page's reviewer line hides an empty name; the node still needs an
547 * author, so an empty one reads as Anonymous.
548 */
549 protected static function authorName($name): string
550 {
551 $name = static::text($name);
552
553 return $name !== '' ? $name : __('Anonymous', 'fluent-cart');
554 }
555
556 /**
557 * created_at is stored in GMT; ISO 8601 with the explicit +00:00
558 * offset so the crawler reads it as UTC and not the site's zone.
559 */
560 protected static function datePublished($createdAt): string
561 {
562 $createdAt = (string) $createdAt;
563 if ($createdAt === '') {
564 return '';
565 }
566
567 $timestamp = strtotime($createdAt . ' UTC');
568
569 return $timestamp ? gmdate('c', $timestamp) : '';
570 }
571
572 /**
573 * The picture the product page shows: the first gallery image, and
574 * when the product has no gallery, the first variation's thumbnail —
575 * the same fallback ProductRenderer::renderGalleryThumb() makes, so
576 * the schema never names an image the page does not show, and never
577 * goes without one the page does. The variations are the ones already
578 * loaded for the offers; only their thumbnails are read, in one query,
579 * and only when there is no gallery.
580 *
581 * @param int $postId
582 * @param ProductVariation[] $variations
583 */
584 protected static function featuredImageUrl($postId, array $variations): string
585 {
586 $gallery = get_post_meta($postId, 'fluent-products-gallery-image', true);
587 $url = static::firstMediaUrl($gallery);
588
589 if ($url !== '' || !$variations) {
590 return $url;
591 }
592
593 $variationIds = array_map(function ($variation) {
594 return (int) $variation->id;
595 }, $variations);
596
597 $thumbnails = ProductMeta::query()
598 ->select(['object_id', 'meta_value'])
599 ->where('meta_key', 'product_thumbnail')
600 ->whereIn('object_id', $variationIds)
601 ->get()
602 ->keyBy('object_id');
603
604 foreach ($variationIds as $variationId) {
605 $thumbnail = $thumbnails->get($variationId);
606 $url = $thumbnail ? static::firstMediaUrl($thumbnail->meta_value) : '';
607
608 if ($url !== '') {
609 return $url;
610 }
611 }
612
613 return '';
614 }
615
616 /**
617 * The url of the first entry in a media list, or '' when there is none.
618 */
619 protected static function firstMediaUrl($media): string
620 {
621 if (!is_array($media) || !$media) {
622 return '';
623 }
624
625 $first = Arr::first($media);
626
627 return is_array($first) ? trim((string) Arr::get($first, 'url', '')) : '';
628 }
629
630 /**
631 * The excerpt when the merchant wrote one, else the opening of the
632 * content — stripped of tags either way.
633 */
634 protected static function description($product): string
635 {
636 $excerpt = static::text($product->post_excerpt);
637 if ($excerpt !== '') {
638 return $excerpt;
639 }
640
641 $content = static::text(strip_shortcodes((string) $product->post_content));
642
643 return $content !== '' ? wp_trim_words($content, 55, '') : '';
644 }
645 }
646