| @@ -40,8 +40,9 @@ | ||
| 40 | 40 | |
| 41 | 41 | /** Ceiling on the variations read for offers. */ |
| 42 | 42 | public const MAX_OFFERS = 100; |
| 43 | 43 | |
| 44 | + /** Legacy hook name retained for existing integrations. */ | |
| 44 | 45 | public const FILTER_HOOK = 'fluent_cart/review/json_ld'; |
| 45 | 46 | |
| 46 | 47 | /** |
| 47 | 48 | * The Product node for a product, or [] when there is nothing worth |
| @@ -55,8 +56,21 @@ | ||
| 55 | 56 | */ |
| 56 | 57 | public static function get($postId): array |
| 57 | 58 | { |
| 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 | + | |
| 59 | 73 | $product = $postId ? get_post($postId) : null; |
| 60 | 74 | |
| 61 | 75 | // Only what a visitor can see. A draft or pending product is a |
| 62 | 76 | // preview for its editor; a private one is for logged-in readers; a |
| @@ -92,8 +106,13 @@ | ||
| 92 | 106 | 'name' => static::text($product->post_title), |
| 93 | 107 | 'url' => $url, |
| 94 | 108 | ]; |
| 95 | 109 | |
| 110 | + $brands = static::brandNodes($postId); | |
| 111 | + if ($brands) { | |
| 112 | + $schema['brand'] = count($brands) === 1 ? $brands[0] : $brands; | |
| 113 | + } | |
| 114 | + | |
| 96 | 115 | $image = static::featuredImageUrl($postId, $variations); |
| 97 | 116 | if ($image !== '') { |
| 98 | 117 | $schema['image'] = [$image]; |
| 99 | 118 | } |
| @@ -131,14 +150,46 @@ | ||
| 131 | 150 | $schema['review'] = $reviews; |
| 132 | 151 | } |
| 133 | 152 | } |
| 134 | 153 | |
| 135 | - return apply_filters(static::FILTER_HOOK, $schema, [ | |
| 154 | + $context = [ | |
| 136 | 155 | 'post_id' => $postId, |
| 137 | 156 | 'summary' => $summary, |
| 138 | - ]); | |
| 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); | |
| 139 | 171 | } |
| 140 | 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 | + | |
| 141 | 192 | /** |
| 142 | 193 | * Whether the page shows this product's reviews: the module on — the |
| 143 | 194 | * gate TemplateActions puts before the reviews section — and then the |
| 144 | 195 | * renderer's own policy: the store's Enable Product Reviews switch, |
| @@ -253,8 +304,14 @@ | ||
| 253 | 304 | $bundleChildren = $stockManaged && $product && $product->isBundleProduct() |
| 254 | 305 | ? ProductVariation::loadBundleChildren($variations) |
| 255 | 306 | : []; |
| 256 | 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 | + ]); | |
| 257 | 314 | $offers = []; |
| 258 | 315 | |
| 259 | 316 | foreach ($variations as $variation) { |
| 260 | 317 | $cents = (float) $variation->item_price; |
| @@ -269,8 +326,13 @@ | ||
| 269 | 326 | ? 'https://schema.org/InStock' |
| 270 | 327 | : 'https://schema.org/OutOfStock', |
| 271 | 328 | ]; |
| 272 | 329 | |
| 330 | + $priceSpecification = static::priceSpecification($variation, $offer, $taxSettings); | |
| 331 | + if ($priceSpecification) { | |
| 332 | + $offer['priceSpecification'] = $priceSpecification; | |
| 333 | + } | |
| 334 | + | |
| 273 | 335 | $title = static::text($variation->variation_title); |
| 274 | 336 | if ($bounds['count'] > 1 && $title !== '') { |
| 275 | 337 | $offer['name'] = $title; |
| 276 | 338 | } |
| @@ -295,8 +357,72 @@ | ||
| 295 | 357 | 'highPrice' => static::price($bounds['high'], $decimals), |
| 296 | 358 | 'offerCount' => (int) $bounds['count'], |
| 297 | 359 | 'offers' => $offers, |
| 298 | 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; | |
| 299 | 425 | } |
| 300 | 426 | |
| 301 | 427 | /** |
| 302 | 428 | * Stored cents to a plain decimal string, the way Helper::toDecimal() |