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
← All changes | app/Services/Schema/ProductSchema.php +128 -2 1.7.0 → 1.7.1 View file →
@@ -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()