| 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 |
* "<" 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 "<". Tags go first, |
| 409 |
* so an encoded "<script>" 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 |
|