PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / trunk
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler vtrunk
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 1.3.20 All 50 releases
fluent-cart / app / Services / Schema / ProductSchema.php

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

520 lines 18.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 public const FILTER_HOOK = 'fluent_cart/review/json_ld';
45
46 /**
47 * The Product node for a product, or [] when there is nothing worth
48 * emitting — no product, or nothing to sell and no approved reviews. A
49 * Product node without offers, rating or review is not eligible for
50 * anything, and emitting one would only invite a second, competing
51 * node from an SEO plugin.
52 *
53 * @param int $postId
54 * @return array
55 */
56 public static function get($postId): array
57 {
58 $postId = (int) $postId;
59 $product = $postId ? get_post($postId) : null;
60
61 // Only what a visitor can see. A draft or pending product is a
62 // preview for its editor; a private one is for logged-in readers; a
63 // password-protected one shows its content to nobody until the
64 // password is given — and the crawler gives none. Structured data
65 // for any of them would publish prices and reviews the page keeps
66 // back. 'private' is admitted because the buy button sells private
67 // products to the readers who can open them (ProductVariation's
68 // purchasability check), and a crawler never reaches the page.
69 if (!$product
70 || !in_array($product->post_status, ['publish', 'private'], true)
71 || post_password_required($product)
72 ) {
73 return [];
74 }
75
76 $url = (string) get_permalink($product);
77 $variations = static::activeVariations($postId);
78 $offers = static::offersNode($variations, $url, static::offerBounds($postId));
79
80 $summary = static::reviewsVisible($postId)
81 ? ProductReviewService::getProductRatingSummary($postId)
82 : ['total' => 0, 'average' => 0, 'breakdown' => []];
83 $hasReviews = (int) Arr::get($summary, 'total', 0) > 0;
84
85 if (!$offers && !$hasReviews) {
86 return [];
87 }
88
89 $schema = [
90 '@context' => 'https://schema.org',
91 '@type' => 'Product',
92 'name' => static::text($product->post_title),
93 'url' => $url,
94 ];
95
96 $image = static::featuredImageUrl($postId, $variations);
97 if ($image !== '') {
98 $schema['image'] = [$image];
99 }
100
101 $description = static::description($product);
102 if ($description !== '') {
103 $schema['description'] = $description;
104 }
105
106 // A product-level SKU only when one variation is the product. With
107 // several, each SKU sits on its own offer, and lifting the first
108 // to the product would mislabel it.
109 if ($offers && $offers['@type'] === 'Offer') {
110 $sku = trim((string) $variations[0]->sku);
111 if ($sku !== '') {
112 $schema['sku'] = $sku;
113 }
114 }
115
116 if ($offers) {
117 $schema['offers'] = $offers;
118 }
119
120 if ($hasReviews) {
121 $schema['aggregateRating'] = [
122 '@type' => 'AggregateRating',
123 'ratingValue' => (string) Arr::get($summary, 'average', 0),
124 'reviewCount' => (int) Arr::get($summary, 'total', 0),
125 'bestRating' => '5',
126 'worstRating' => '1',
127 ];
128
129 $reviews = static::reviewNodes($postId);
130 if ($reviews) {
131 $schema['review'] = $reviews;
132 }
133 }
134
135 return apply_filters(static::FILTER_HOOK, $schema, [
136 'post_id' => $postId,
137 'summary' => $summary,
138 ]);
139 }
140
141 /**
142 * Whether the page shows this product's reviews: the module on — the
143 * gate TemplateActions puts before the reviews section — and then the
144 * renderer's own policy: the store's Enable Product Reviews switch,
145 * the product's toggle, and the single-page setting with its filter.
146 * The same decision the list makes, so the schema never names a
147 * review the page does not render.
148 */
149 protected static function reviewsVisible($postId): bool
150 {
151 return ModuleSettings::isActive('reviews')
152 && ProductReviewRenderer::isVisibleFor($postId);
153 }
154
155 /**
156 * The variations the product page offers, in its order. Active rows
157 * only: a draft variation is not on the page, so it is not for sale.
158 * The product and its detail come along in two queries because the
159 * stock checks consult them.
160 *
161 * @param int $postId
162 * @return ProductVariation[]
163 */
164 protected static function activeVariations($postId): array
165 {
166 $rows = ProductVariation::query()
167 ->where('post_id', $postId)
168 ->where('item_status', 'active')
169 ->with(['product', 'product.detail'])
170 ->orderBy('serial_index', 'ASC')
171 ->orderBy('id', 'ASC')
172 ->limit(static::MAX_OFFERS)
173 ->get();
174
175 return $rows ? $rows->all() : [];
176 }
177
178 /**
179 * What the product sells as a whole, over every active variation and
180 * not only the MAX_OFFERS the nested offers expand: how many, and the
181 * cheapest and dearest. One aggregate query, so offerCount, lowPrice
182 * and highPrice are right however many variations the product has.
183 *
184 * @param int $postId
185 * @return array{count:int, low:float, high:float}
186 */
187 protected static function offerBounds($postId): array
188 {
189 $row = ProductVariation::query()
190 ->where('post_id', $postId)
191 ->where('item_status', 'active')
192 ->selectRaw('COUNT(*) as offer_count, MIN(item_price) as low_price, MAX(item_price) as high_price')
193 ->first();
194
195 return [
196 'count' => $row ? (int) $row->offer_count : 0,
197 'low' => $row ? (float) $row->low_price : 0.0,
198 'high' => $row ? (float) $row->high_price : 0.0,
199 ];
200 }
201
202 /**
203 * offers: one Offer for a single variation, an AggregateOffer for
204 * several. The aggregate's count and price bounds come from every
205 * active variation; its nested offers are the first MAX_OFFERS in
206 * page order, so a product past that cap still states the complete
207 * offering while the crawler is not handed hundreds of nodes. Prices
208 * are the stored cents as a decimal string in the store currency —
209 * never the formatted display price, which carries signs, separators
210 * and translated digits the crawler cannot parse.
211 *
212 * @param ProductVariation[] $variations
213 * @param string $url
214 * @param array $bounds from offerBounds()
215 * @return array
216 */
217 protected static function offersNode(array $variations, string $url, array $bounds): array
218 {
219 if (!$variations || (int) Arr::get($bounds, 'count', 0) < 1) {
220 return [];
221 }
222
223 // CurrencySettings directly, not Helper::shopConfig(): that helper
224 // also reads the store's shipping packages, which no offer needs.
225 $currencySettings = CurrencySettings::get();
226 $currency = strtoupper((string) Arr::get($currencySettings, 'currency', 'USD'));
227 $decimals = Arr::get($currencySettings, 'is_zero_decimal') ? 0 : 2;
228
229 // Availability the way the buy button decides it: with the stock
230 // module off everything on the page is for sale, whatever the
231 // stock columns hold; with it on, the product and the variation
232 // both have to be in stock. Same rule as ProductRenderer's
233 // direct-checkout button, so the schema never says OutOfStock for
234 // a variation the page sells, or InStock for one it refuses.
235 $stockManaged = ModuleSettings::isActive('stock_management');
236 $product = $variations[0]->product;
237 $detail = $product ? $product->detail : null;
238
239 // The product-level flag, read from the detail already loaded with
240 // the variations — the same rule as Product::isStock() short of its
241 // bundle branch. That branch is not called here: it lazy-loads the
242 // whole variants relation and queries the default variation's
243 // children, and both are covered below, once, for every variation.
244 $productInStock = !$stockManaged
245 || !$detail
246 || !$detail->manage_stock
247 || $detail->stock_availability === Helper::IN_STOCK;
248
249 // A bundle's stock is its children's. One query for every child of
250 // every variation on the page, instead of one per variation from
251 // isStock() on its own — a bundle with many variations is the case
252 // MAX_OFFERS exists for. Non-bundles read nothing.
253 $bundleChildren = $stockManaged && $product && $product->isBundleProduct()
254 ? ProductVariation::loadBundleChildren($variations)
255 : [];
256
257 $offers = [];
258
259 foreach ($variations as $variation) {
260 $cents = (float) $variation->item_price;
261 $inStock = !$stockManaged || ($productInStock && $variation->isStock($bundleChildren));
262
263 $offer = [
264 '@type' => 'Offer',
265 'url' => $url,
266 'price' => static::price($cents, $decimals),
267 'priceCurrency' => $currency,
268 'availability' => $inStock
269 ? 'https://schema.org/InStock'
270 : 'https://schema.org/OutOfStock',
271 ];
272
273 $title = static::text($variation->variation_title);
274 if ($bounds['count'] > 1 && $title !== '') {
275 $offer['name'] = $title;
276 }
277
278 $sku = trim((string) $variation->sku);
279 if ($sku !== '') {
280 $offer['sku'] = $sku;
281 }
282
283 $offers[] = $offer;
284 }
285
286 if ((int) $bounds['count'] === 1) {
287 return $offers[0];
288 }
289
290 return [
291 '@type' => 'AggregateOffer',
292 'url' => $url,
293 'priceCurrency' => $currency,
294 'lowPrice' => static::price($bounds['low'], $decimals),
295 'highPrice' => static::price($bounds['high'], $decimals),
296 'offerCount' => (int) $bounds['count'],
297 'offers' => $offers,
298 ];
299 }
300
301 /**
302 * Stored cents to a plain decimal string, the way Helper::toDecimal()
303 * scales them but without its display formatting: "1999" → "19.99",
304 * and "1999" → "20" in a zero-decimal currency.
305 */
306 protected static function price($cents, int $decimals): string
307 {
308 return number_format(((float) $cents) / 100, $decimals, '.', '');
309 }
310
311 /**
312 * Echo the ld+json script tag for a product, or nothing.
313 *
314 * JSON_HEX_TAG turns < and > into \u003C / \u003E so a review body
315 * holding "</script>" cannot close the tag early; the encoded form is
316 * still valid JSON for the parser.
317 *
318 * @param int $postId
319 */
320 public static function render($postId): void
321 {
322 $schema = static::get($postId);
323
324 if (!$schema) {
325 return;
326 }
327
328 echo '<script type="application/ld+json">' . wp_json_encode($schema, JSON_HEX_TAG | JSON_HEX_AMP) . '</script>' . "\n";
329 }
330
331 /**
332 * How many reviews review[] holds: the store's page length, since that
333 * is what the first server-rendered page shows, capped at MAX_REVIEWS.
334 */
335 public static function reviewLimit(): int
336 {
337 $perPage = (int) Arr::get(ProductReviewService::getReviewSettings(), 'reviews_per_page', 10);
338
339 return max(1, min(static::MAX_REVIEWS, $perPage));
340 }
341
342 /**
343 * Review nodes in the order the list shows them by default: newest
344 * first, id as the tie-breaker. Same predicates as the public listing
345 * (ProductReviewResource::get with status=approved): top-level rows of
346 * this product in approved status. Rated only, on top of that, because
347 * a Review node without reviewRating fails validation.
348 *
349 * @param int $postId
350 * @return array
351 */
352 protected static function reviewNodes($postId): array
353 {
354 $rows = ProductReview::query()
355 ->select(['id', 'reviewer_name', 'title', 'review', 'rating', 'created_at'])
356 ->topLevel()
357 ->ofStatus(Status::REVIEW_APPROVED)
358 ->ofProduct($postId)
359 ->whereBetween('rating', [1, 5])
360 ->orderBy('created_at', 'DESC')
361 ->orderBy('id', 'DESC')
362 ->limit(static::reviewLimit())
363 ->get();
364
365 $nodes = [];
366
367 foreach ($rows as $row) {
368 $node = [
369 '@type' => 'Review',
370 'author' => [
371 '@type' => 'Person',
372 'name' => static::authorName($row->reviewer_name),
373 ],
374 ];
375
376 $published = static::datePublished($row->created_at);
377 if ($published !== '') {
378 $node['datePublished'] = $published;
379 }
380
381 $title = static::text($row->title);
382 if ($title !== '') {
383 $node['name'] = $title;
384 }
385
386 $body = static::text($row->content);
387 if ($body !== '') {
388 $node['reviewBody'] = $body;
389 }
390
391 $node['reviewRating'] = [
392 '@type' => 'Rating',
393 'ratingValue' => (string) (int) $row->rating,
394 'bestRating' => '5',
395 'worstRating' => '1',
396 ];
397
398 $nodes[] = $node;
399 }
400
401 return $nodes;
402 }
403
404 /**
405 * Stored text as the reader sees it. Request sanitizers store "<" as
406 * "&lt;" and the page's esc_html() leaves that entity alone, so the
407 * reader sees "<"; JSON-LD is not HTML, so the entity has to be
408 * decoded here or the crawler reads a literal "&lt;". Tags go first,
409 * so an encoded "&lt;script&gt;" becomes plain text, which
410 * JSON_HEX_TAG then keeps inert inside the script element.
411 */
412 protected static function text($value): string
413 {
414 $value = wp_strip_all_tags((string) $value);
415
416 return trim(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
417 }
418
419 /**
420 * The page's reviewer line hides an empty name; the node still needs an
421 * author, so an empty one reads as Anonymous.
422 */
423 protected static function authorName($name): string
424 {
425 $name = static::text($name);
426
427 return $name !== '' ? $name : __('Anonymous', 'fluent-cart');
428 }
429
430 /**
431 * created_at is stored in GMT; ISO 8601 with the explicit +00:00
432 * offset so the crawler reads it as UTC and not the site's zone.
433 */
434 protected static function datePublished($createdAt): string
435 {
436 $createdAt = (string) $createdAt;
437 if ($createdAt === '') {
438 return '';
439 }
440
441 $timestamp = strtotime($createdAt . ' UTC');
442
443 return $timestamp ? gmdate('c', $timestamp) : '';
444 }
445
446 /**
447 * The picture the product page shows: the first gallery image, and
448 * when the product has no gallery, the first variation's thumbnail —
449 * the same fallback ProductRenderer::renderGalleryThumb() makes, so
450 * the schema never names an image the page does not show, and never
451 * goes without one the page does. The variations are the ones already
452 * loaded for the offers; only their thumbnails are read, in one query,
453 * and only when there is no gallery.
454 *
455 * @param int $postId
456 * @param ProductVariation[] $variations
457 */
458 protected static function featuredImageUrl($postId, array $variations): string
459 {
460 $gallery = get_post_meta($postId, 'fluent-products-gallery-image', true);
461 $url = static::firstMediaUrl($gallery);
462
463 if ($url !== '' || !$variations) {
464 return $url;
465 }
466
467 $variationIds = array_map(function ($variation) {
468 return (int) $variation->id;
469 }, $variations);
470
471 $thumbnails = ProductMeta::query()
472 ->select(['object_id', 'meta_value'])
473 ->where('meta_key', 'product_thumbnail')
474 ->whereIn('object_id', $variationIds)
475 ->get()
476 ->keyBy('object_id');
477
478 foreach ($variationIds as $variationId) {
479 $thumbnail = $thumbnails->get($variationId);
480 $url = $thumbnail ? static::firstMediaUrl($thumbnail->meta_value) : '';
481
482 if ($url !== '') {
483 return $url;
484 }
485 }
486
487 return '';
488 }
489
490 /**
491 * The url of the first entry in a media list, or '' when there is none.
492 */
493 protected static function firstMediaUrl($media): string
494 {
495 if (!is_array($media) || !$media) {
496 return '';
497 }
498
499 $first = Arr::first($media);
500
501 return is_array($first) ? trim((string) Arr::get($first, 'url', '')) : '';
502 }
503
504 /**
505 * The excerpt when the merchant wrote one, else the opening of the
506 * content — stripped of tags either way.
507 */
508 protected static function description($product): string
509 {
510 $excerpt = static::text($product->post_excerpt);
511 if ($excerpt !== '') {
512 return $excerpt;
513 }
514
515 $content = static::text(strip_shortcodes((string) $product->post_content));
516
517 return $content !== '' ? wp_trim_words($content, 55, '') : '';
518 }
519 }
520