@thg-altitude/schemaorg 1.0.32 → 1.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.nvmrc +1 -1
- package/index.js +4 -2
- package/package.json +1 -1
- package/src/components/EnhancedProduct.astro +491 -67
- package/src/components/Product.astro +364 -52
- package/src/components/Recipe.astro +175 -0
- package/src/utils/productSchema.js +119 -0
package/.nvmrc
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
22.15.0
|
package/index.js
CHANGED
|
@@ -6,8 +6,9 @@ import Organization from './src/components/Organization.astro'
|
|
|
6
6
|
import ProductCollection from './src/components/ProductCollection.astro'
|
|
7
7
|
import WebSite from './src/components/WebSite.astro'
|
|
8
8
|
import EnhancedProductList from './src/components/EnhancedProductList.astro'
|
|
9
|
+
import Recipe from './src/components/Recipe.astro'
|
|
9
10
|
|
|
10
|
-
export {
|
|
11
|
+
export {
|
|
11
12
|
Product,
|
|
12
13
|
EnhancedProduct,
|
|
13
14
|
Breadcrumb,
|
|
@@ -15,5 +16,6 @@ export {
|
|
|
15
16
|
Organization,
|
|
16
17
|
ProductCollection,
|
|
17
18
|
WebSite,
|
|
18
|
-
EnhancedProductList
|
|
19
|
+
EnhancedProductList,
|
|
20
|
+
Recipe
|
|
19
21
|
}
|
package/package.json
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { mapProductSchemaDataEnhanced } from '../utils/productSchema.js';
|
|
3
|
+
|
|
2
4
|
const {
|
|
3
5
|
image,
|
|
4
6
|
sku,
|
|
@@ -23,8 +25,32 @@ const {
|
|
|
23
25
|
streetAddress,
|
|
24
26
|
addressLocality,
|
|
25
27
|
postalCode,
|
|
28
|
+
suggestedAge,
|
|
29
|
+
suggestedGender,
|
|
30
|
+
beauty_targetAge,
|
|
31
|
+
gender,
|
|
26
32
|
} = Astro.props;
|
|
27
33
|
|
|
34
|
+
// Use the enhanced mapping function to extract data from the full product data
|
|
35
|
+
const mappedData = mapProductSchemaDataEnhanced(Astro.props);
|
|
36
|
+
|
|
37
|
+
// Extract suggestedGender and suggestedAge using the comprehensive mapping function
|
|
38
|
+
let extractedSuggestedGender = suggestedGender || mappedData.suggestedGender;
|
|
39
|
+
let extractedSuggestedAge = suggestedAge || mappedData.suggestedAge;
|
|
40
|
+
|
|
41
|
+
// Fallback to the original simple extraction if the mapping didn't find anything
|
|
42
|
+
if (!extractedSuggestedGender && Astro.props.productContent?.propertyNameToValueMap?.gender) {
|
|
43
|
+
extractedSuggestedGender = Array.isArray(Astro.props.productContent.propertyNameToValueMap.gender)
|
|
44
|
+
? Astro.props.productContent.propertyNameToValueMap.gender[0]
|
|
45
|
+
: Astro.props.productContent.propertyNameToValueMap.gender;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!extractedSuggestedAge && Astro.props.productContent?.propertyNameToValueMap?.beauty_targetAge) {
|
|
49
|
+
extractedSuggestedAge = Array.isArray(Astro.props.productContent.propertyNameToValueMap.beauty_targetAge)
|
|
50
|
+
? Astro.props.productContent.propertyNameToValueMap.beauty_targetAge[0]
|
|
51
|
+
: Astro.props.productContent.propertyNameToValueMap.beauty_targetAge;
|
|
52
|
+
}
|
|
53
|
+
|
|
28
54
|
let url;
|
|
29
55
|
let schema = null;
|
|
30
56
|
let schemaError = false;
|
|
@@ -63,49 +89,258 @@ try {
|
|
|
63
89
|
}
|
|
64
90
|
|
|
65
91
|
function generateProductSchema() {
|
|
92
|
+
const hasMultipleVariants = variants && variants.length > 1;
|
|
93
|
+
let variantsArray = [];
|
|
94
|
+
|
|
66
95
|
if (variants && variants.length > 0 && currency) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
if (hasMultipleVariants) {
|
|
97
|
+
// Create individual Product objects for each variant
|
|
98
|
+
variantsArray = variants
|
|
99
|
+
.filter((variant) => variant?.sku && variant?.price?.price?.amount)
|
|
100
|
+
.map((variant) => {
|
|
101
|
+
// Build variant name with attributes from choices in logical order
|
|
102
|
+
let variantName = name;
|
|
103
|
+
const attributes = [];
|
|
104
|
+
|
|
105
|
+
// Extract properties in preferred order: Amount + Weight + Flavor + Size + Color + Material
|
|
106
|
+
if (variant.choices && variant.choices.length > 0) {
|
|
107
|
+
// Amount (only if explicitly marked as Amount, not weight-related)
|
|
108
|
+
const amountChoice = variant.choices.find(c =>
|
|
109
|
+
c.optionKey === 'Amount' &&
|
|
110
|
+
c.title &&
|
|
111
|
+
!c.title.includes('kg') &&
|
|
112
|
+
!c.title.includes('g') &&
|
|
113
|
+
!c.title.includes('lb')
|
|
114
|
+
);
|
|
115
|
+
if (amountChoice && amountChoice.title) attributes.push(amountChoice.title);
|
|
116
|
+
|
|
117
|
+
// Weight (from Weight optionKey OR size containing weight units)
|
|
118
|
+
const weightChoice = variant.choices.find(c =>
|
|
119
|
+
c.optionKey === 'Weight' ||
|
|
120
|
+
(c.optionKey === 'Size' && c.title && (c.title.includes('kg') || c.title.includes('g') || c.title.includes('lb'))) ||
|
|
121
|
+
(c.title && (c.title.includes('kg') || c.title.includes('lb') || /\d+\s*g\b/.test(c.title)))
|
|
122
|
+
);
|
|
123
|
+
if (weightChoice && weightChoice.title) attributes.push(weightChoice.title);
|
|
124
|
+
|
|
125
|
+
// Flavor
|
|
126
|
+
const flavorChoice = variant.choices.find(c =>
|
|
127
|
+
c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste'
|
|
128
|
+
);
|
|
129
|
+
if (flavorChoice && flavorChoice.title) attributes.push(flavorChoice.title);
|
|
130
|
+
|
|
131
|
+
// Size (excluding weight-related sizes and already processed weight)
|
|
132
|
+
const sizeChoice = variant.choices.find(c =>
|
|
133
|
+
c.optionKey === 'Size' &&
|
|
134
|
+
c.title &&
|
|
135
|
+
!c.title.includes('kg') &&
|
|
136
|
+
!c.title.includes('g') &&
|
|
137
|
+
!c.title.includes('lb') &&
|
|
138
|
+
c !== weightChoice // Don't duplicate if already used as weight
|
|
139
|
+
);
|
|
140
|
+
if (sizeChoice && sizeChoice.title) attributes.push(sizeChoice.title);
|
|
141
|
+
|
|
142
|
+
// Color
|
|
143
|
+
const colorChoice = variant.choices.find(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour);
|
|
144
|
+
if (colorChoice) {
|
|
145
|
+
if (colorChoice.title) attributes.push(colorChoice.title);
|
|
146
|
+
else if (colorChoice.colour) attributes.push(colorChoice.colour);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Material
|
|
150
|
+
const materialChoice = variant.choices.find(c => c.optionKey === 'Material');
|
|
151
|
+
if (materialChoice && materialChoice.title) attributes.push(materialChoice.title);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (attributes.length > 0) {
|
|
155
|
+
variantName = `${name} ${attributes.join(' ')}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"@type": "Product",
|
|
160
|
+
sku: variant.sku.toString(),
|
|
161
|
+
name: variantName,
|
|
162
|
+
...(description && { description }),
|
|
163
|
+
...(imageUrl && { image: imageUrl }),
|
|
164
|
+
// Extract variant-specific properties from choices using correct schema.org properties
|
|
165
|
+
...(variant.choices && variant.choices.some(c => c.optionKey === 'Weight' || (c.title && (c.title.includes('kg') || c.title.includes('lb') || /\d+\s*g\b/.test(c.title)))) && {
|
|
166
|
+
weight: variant.choices.find(c => c.optionKey === 'Weight' || (c.title && (c.title.includes('kg') || c.title.includes('lb') || /\d+\s*g\b/.test(c.title))))?.title
|
|
167
|
+
}),
|
|
168
|
+
...(variant.choices && variant.choices.some(c => c.optionKey === 'Size' && c.title && !c.title.includes('kg') && !c.title.includes('g') && !c.title.includes('lb')) && {
|
|
169
|
+
size: variant.choices.find(c => c.optionKey === 'Size' && c.title && !c.title.includes('kg') && !c.title.includes('g') && !c.title.includes('lb'))?.title
|
|
170
|
+
}),
|
|
171
|
+
...(variant.choices && variant.choices.some(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour) && {
|
|
172
|
+
color: variant.choices.find(c => c.optionKey === 'Color' || c.optionKey === 'Colour')?.title ||
|
|
173
|
+
variant.choices.find(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour)?.colour
|
|
174
|
+
}),
|
|
175
|
+
...(variant.choices && variant.choices.some(c => c.optionKey === 'Material') && {
|
|
176
|
+
material: variant.choices.find(c => c.optionKey === 'Material')?.title
|
|
177
|
+
}),
|
|
178
|
+
// Handle non-standard properties (amount, flavour) as additionalProperty
|
|
179
|
+
...(() => {
|
|
180
|
+
const additionalProperties = [];
|
|
181
|
+
|
|
182
|
+
// Amount
|
|
183
|
+
const amountChoice = variant.choices?.find(c => c.optionKey === 'Amount');
|
|
184
|
+
if (amountChoice?.title) {
|
|
185
|
+
additionalProperties.push({
|
|
186
|
+
"@type": "PropertyValue",
|
|
187
|
+
"name": "amount",
|
|
188
|
+
"value": amountChoice.title
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Flavour (as additionalProperty since it doesn't exist in schema.org)
|
|
193
|
+
const flavourChoice = variant.choices?.find(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste');
|
|
194
|
+
if (flavourChoice?.title) {
|
|
195
|
+
additionalProperties.push({
|
|
196
|
+
"@type": "PropertyValue",
|
|
197
|
+
"name": "flavour",
|
|
198
|
+
"value": flavourChoice.title
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return additionalProperties.length > 0 ? { additionalProperty: additionalProperties } : {};
|
|
203
|
+
})(),
|
|
204
|
+
...(variant?.barcode && { gtin13: variant.barcode.toString() }),
|
|
205
|
+
offers: {
|
|
206
|
+
"@type": "Offer",
|
|
207
|
+
url: `${pageUrl}?variation=${variant?.sku}`,
|
|
208
|
+
price: variant.price.price.amount,
|
|
209
|
+
priceCurrency: currency,
|
|
210
|
+
itemCondition: "https://schema.org/NewCondition",
|
|
211
|
+
availability: variant?.inStock
|
|
212
|
+
? "https://schema.org/InStock"
|
|
213
|
+
: "https://schema.org/OutOfStock",
|
|
214
|
+
...(brand && {
|
|
215
|
+
seller: {
|
|
216
|
+
"@type": "Organization",
|
|
217
|
+
name: brand,
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
priceSpecification: [
|
|
98
221
|
{
|
|
99
222
|
"@type": "UnitPriceSpecification",
|
|
100
223
|
priceCurrency: currency,
|
|
101
|
-
price: variant.price.
|
|
224
|
+
price: variant.price.price.amount,
|
|
102
225
|
valueAddedTaxIncluded: true,
|
|
103
|
-
priceType: "https://schema.org/ListPrice",
|
|
104
226
|
},
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
227
|
+
...(variant?.price?.rrp?.amount
|
|
228
|
+
? [
|
|
229
|
+
{
|
|
230
|
+
"@type": "UnitPriceSpecification",
|
|
231
|
+
priceCurrency: currency,
|
|
232
|
+
price: variant.price.rrp.amount,
|
|
233
|
+
valueAddedTaxIncluded: true,
|
|
234
|
+
priceType: "https://schema.org/StrikethroughPrice",
|
|
235
|
+
},
|
|
236
|
+
]
|
|
237
|
+
: []),
|
|
238
|
+
],
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
} else {
|
|
243
|
+
// Single variant - create offers array as before
|
|
244
|
+
offersArray = variants
|
|
245
|
+
.filter((variant) => variant?.sku && variant?.price?.price?.amount)
|
|
246
|
+
.map((variant) => {
|
|
247
|
+
// Build variant name with attributes from choices in logical order
|
|
248
|
+
let variantName = name;
|
|
249
|
+
const attributes = [];
|
|
250
|
+
|
|
251
|
+
// Extract properties in preferred order: Amount + Weight + Flavor + Size + Color + Material
|
|
252
|
+
if (variant.choices && variant.choices.length > 0) {
|
|
253
|
+
// Amount (only if explicitly marked as Amount, not weight-related)
|
|
254
|
+
const amountChoice = variant.choices.find(c =>
|
|
255
|
+
c.optionKey === 'Amount' &&
|
|
256
|
+
c.title &&
|
|
257
|
+
!c.title.includes('kg') &&
|
|
258
|
+
!c.title.includes('g') &&
|
|
259
|
+
!c.title.includes('lb')
|
|
260
|
+
);
|
|
261
|
+
if (amountChoice && amountChoice.title) attributes.push(amountChoice.title);
|
|
262
|
+
|
|
263
|
+
// Weight (from Weight optionKey OR size containing weight units)
|
|
264
|
+
const weightChoice = variant.choices.find(c =>
|
|
265
|
+
c.optionKey === 'Weight' ||
|
|
266
|
+
(c.optionKey === 'Size' && c.title && (c.title.includes('kg') || c.title.includes('g') || c.title.includes('lb'))) ||
|
|
267
|
+
(c.title && (c.title.includes('kg') || c.title.includes('lb') || /\d+\s*g\b/.test(c.title)))
|
|
268
|
+
);
|
|
269
|
+
if (weightChoice && weightChoice.title) attributes.push(weightChoice.title);
|
|
270
|
+
|
|
271
|
+
// Flavor
|
|
272
|
+
const flavorChoice = variant.choices.find(c =>
|
|
273
|
+
c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste'
|
|
274
|
+
);
|
|
275
|
+
if (flavorChoice && flavorChoice.title) attributes.push(flavorChoice.title);
|
|
276
|
+
|
|
277
|
+
// Size (excluding weight-related sizes and already processed weight)
|
|
278
|
+
const sizeChoice = variant.choices.find(c =>
|
|
279
|
+
c.optionKey === 'Size' &&
|
|
280
|
+
c.title &&
|
|
281
|
+
!c.title.includes('kg') &&
|
|
282
|
+
!c.title.includes('g') &&
|
|
283
|
+
!c.title.includes('lb') &&
|
|
284
|
+
c !== weightChoice // Don't duplicate if already used as weight
|
|
285
|
+
);
|
|
286
|
+
if (sizeChoice && sizeChoice.title) attributes.push(sizeChoice.title);
|
|
287
|
+
|
|
288
|
+
// Color
|
|
289
|
+
const colorChoice = variant.choices.find(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour);
|
|
290
|
+
if (colorChoice) {
|
|
291
|
+
if (colorChoice.title) attributes.push(colorChoice.title);
|
|
292
|
+
else if (colorChoice.colour) attributes.push(colorChoice.colour);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Material
|
|
296
|
+
const materialChoice = variant.choices.find(c => c.optionKey === 'Material');
|
|
297
|
+
if (materialChoice && materialChoice.title) attributes.push(materialChoice.title);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (attributes.length > 0) {
|
|
301
|
+
variantName = `${name} ${attributes.join(' ')}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
"@type": "Offer",
|
|
306
|
+
name: variantName,
|
|
307
|
+
sku: variant.sku.toString(),
|
|
308
|
+
url: pageUrl,
|
|
309
|
+
price: variant.price.price.amount,
|
|
310
|
+
priceCurrency: currency,
|
|
311
|
+
itemCondition: "https://schema.org/NewCondition",
|
|
312
|
+
availability: variant?.inStock
|
|
313
|
+
? "https://schema.org/InStock"
|
|
314
|
+
: "https://schema.org/OutOfStock",
|
|
315
|
+
...(variant?.barcode && { gtin13: variant.barcode.toString() }),
|
|
316
|
+
...(brand && {
|
|
317
|
+
seller: {
|
|
318
|
+
"@type": "Organization",
|
|
319
|
+
name: brand,
|
|
320
|
+
},
|
|
321
|
+
}),
|
|
322
|
+
priceSpecification: [
|
|
323
|
+
{
|
|
324
|
+
"@type": "UnitPriceSpecification",
|
|
325
|
+
priceCurrency: currency,
|
|
326
|
+
price: variant.price.price.amount,
|
|
327
|
+
valueAddedTaxIncluded: true,
|
|
328
|
+
},
|
|
329
|
+
...(variant?.price?.rrp?.amount
|
|
330
|
+
? [
|
|
331
|
+
{
|
|
332
|
+
"@type": "UnitPriceSpecification",
|
|
333
|
+
priceCurrency: currency,
|
|
334
|
+
price: variant.price.rrp.amount,
|
|
335
|
+
valueAddedTaxIncluded: true,
|
|
336
|
+
priceType: "https://schema.org/StrikethroughPrice",
|
|
337
|
+
},
|
|
338
|
+
]
|
|
339
|
+
: []),
|
|
340
|
+
],
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
}
|
|
109
344
|
}
|
|
110
345
|
|
|
111
346
|
const category =
|
|
@@ -116,38 +351,199 @@ function generateProductSchema() {
|
|
|
116
351
|
const reviewsArray = generateReviewsSchema();
|
|
117
352
|
|
|
118
353
|
if (name && sku) {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
354
|
+
const hasMultipleVariants = variants && variants.length > 1;
|
|
355
|
+
|
|
356
|
+
if (hasMultipleVariants) {
|
|
357
|
+
// Determine what properties vary between variants
|
|
358
|
+
const variesBy = [];
|
|
359
|
+
|
|
360
|
+
// Check what attributes exist across variants based on choices using correct schema.org properties
|
|
361
|
+
if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Weight' || (c.title && (c.title.includes('kg') || c.title.includes('lb') || /\d+\s*g\b/.test(c.title)))))) {
|
|
362
|
+
variesBy.push("https://schema.org/weight");
|
|
363
|
+
}
|
|
364
|
+
if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Size' && c.title && !c.title.includes('kg') && !c.title.includes('g') && !c.title.includes('lb')))) {
|
|
365
|
+
variesBy.push("https://schema.org/size");
|
|
366
|
+
}
|
|
367
|
+
if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour))) {
|
|
368
|
+
variesBy.push("https://schema.org/color");
|
|
369
|
+
}
|
|
370
|
+
if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Material'))) {
|
|
371
|
+
variesBy.push("https://schema.org/material");
|
|
372
|
+
}
|
|
373
|
+
// For non-standard properties like amount and flavour, use additionalProperty
|
|
374
|
+
if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Amount'))) {
|
|
375
|
+
variesBy.push("https://schema.org/additionalProperty");
|
|
376
|
+
}
|
|
377
|
+
if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste'))) {
|
|
378
|
+
variesBy.push("https://schema.org/additionalProperty");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const productGroupSchema = {
|
|
382
|
+
"@type": "ProductGroup",
|
|
383
|
+
"@id": pageUrl,
|
|
384
|
+
url: pageUrl,
|
|
385
|
+
name: name,
|
|
386
|
+
productGroupID: sku.toString(),
|
|
387
|
+
...(gtin13 && { gtin13: gtin13.toString() }),
|
|
388
|
+
...(description && { description }),
|
|
389
|
+
...(imageUrl && { image: imageUrl }),
|
|
390
|
+
...(brand && {
|
|
391
|
+
brand: {
|
|
392
|
+
"@type": "Brand",
|
|
393
|
+
name: brand,
|
|
142
394
|
},
|
|
143
395
|
}),
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
396
|
+
...(variesBy.length > 0 && { variesBy }),
|
|
397
|
+
hasVariant: variantsArray,
|
|
398
|
+
...(reviewsCount > 0 &&
|
|
399
|
+
reviewsAverage && {
|
|
400
|
+
aggregateRating: {
|
|
401
|
+
"@type": "AggregateRating",
|
|
402
|
+
ratingValue: reviewsAverage,
|
|
403
|
+
reviewCount: reviewsCount,
|
|
404
|
+
bestRating: 5,
|
|
405
|
+
worstRating: 1,
|
|
406
|
+
},
|
|
407
|
+
}),
|
|
408
|
+
...(reviewsArray && reviewsArray.length > 0 && { review: reviewsArray }),
|
|
409
|
+
...(category && { category }),
|
|
410
|
+
...(keywords && { keywords }),
|
|
411
|
+
// Handle custom properties as additionalProperty
|
|
412
|
+
...(() => {
|
|
413
|
+
const additionalProperties = [];
|
|
414
|
+
if (extractedSuggestedGender) {
|
|
415
|
+
additionalProperties.push({
|
|
416
|
+
"@type": "PropertyValue",
|
|
417
|
+
"name": "suggestedGender",
|
|
418
|
+
"value": extractedSuggestedGender
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
if (extractedSuggestedAge) {
|
|
422
|
+
additionalProperties.push({
|
|
423
|
+
"@type": "PropertyValue",
|
|
424
|
+
"name": "suggestedAge",
|
|
425
|
+
"value": extractedSuggestedAge
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
if (beauty_targetAge) {
|
|
429
|
+
additionalProperties.push({
|
|
430
|
+
"@type": "PropertyValue",
|
|
431
|
+
"name": "targetAge",
|
|
432
|
+
"value": beauty_targetAge
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
if (gender) {
|
|
436
|
+
additionalProperties.push({
|
|
437
|
+
"@type": "PropertyValue",
|
|
438
|
+
"name": "GenderType",
|
|
439
|
+
"value": gender
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Add flavour as additionalProperty for ProductGroup level
|
|
444
|
+
const flavourValues = variants?.map(v =>
|
|
445
|
+
v.choices?.find(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste')?.title
|
|
446
|
+
).filter(Boolean);
|
|
447
|
+
|
|
448
|
+
if (flavourValues && flavourValues.length > 0) {
|
|
449
|
+
// If multiple unique flavours, list them all; if single flavour, just that one
|
|
450
|
+
const uniqueFlavours = [...new Set(flavourValues)];
|
|
451
|
+
additionalProperties.push({
|
|
452
|
+
"@type": "PropertyValue",
|
|
453
|
+
"name": "flavour",
|
|
454
|
+
"value": uniqueFlavours.length === 1 ? uniqueFlavours[0] : uniqueFlavours.join(', ')
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return additionalProperties.length > 0 ? { additionalProperty: additionalProperties } : {};
|
|
459
|
+
})(),
|
|
460
|
+
};
|
|
149
461
|
|
|
150
|
-
|
|
462
|
+
graphArray.push(productGroupSchema);
|
|
463
|
+
} else {
|
|
464
|
+
// Single product schema as before
|
|
465
|
+
const productSchema = {
|
|
466
|
+
"@type": "Product",
|
|
467
|
+
"@id": pageUrl,
|
|
468
|
+
url: pageUrl,
|
|
469
|
+
sku: sku.toString(),
|
|
470
|
+
name: name,
|
|
471
|
+
...(gtin13 && { gtin13: gtin13.toString() }),
|
|
472
|
+
...(description && { description }),
|
|
473
|
+
...(imageUrl && { image: imageUrl }),
|
|
474
|
+
...(brand && {
|
|
475
|
+
brand: {
|
|
476
|
+
"@type": "Brand",
|
|
477
|
+
name: brand,
|
|
478
|
+
},
|
|
479
|
+
}),
|
|
480
|
+
...(reviewsCount > 0 &&
|
|
481
|
+
reviewsAverage && {
|
|
482
|
+
aggregateRating: {
|
|
483
|
+
"@type": "AggregateRating",
|
|
484
|
+
ratingValue: reviewsAverage,
|
|
485
|
+
reviewCount: reviewsCount,
|
|
486
|
+
bestRating: 5,
|
|
487
|
+
worstRating: 1,
|
|
488
|
+
},
|
|
489
|
+
}),
|
|
490
|
+
...(reviewsArray && reviewsArray.length > 0 && { review: reviewsArray }),
|
|
491
|
+
...(category && { category }),
|
|
492
|
+
...(keywords && { keywords }),
|
|
493
|
+
// Handle custom properties as additionalProperty
|
|
494
|
+
...(() => {
|
|
495
|
+
const additionalProperties = [];
|
|
496
|
+
if (extractedSuggestedGender) {
|
|
497
|
+
additionalProperties.push({
|
|
498
|
+
"@type": "PropertyValue",
|
|
499
|
+
"name": "suggestedGender",
|
|
500
|
+
"value": extractedSuggestedGender
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
if (extractedSuggestedAge) {
|
|
504
|
+
additionalProperties.push({
|
|
505
|
+
"@type": "PropertyValue",
|
|
506
|
+
"name": "suggestedAge",
|
|
507
|
+
"value": extractedSuggestedAge
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
if (beauty_targetAge) {
|
|
511
|
+
additionalProperties.push({
|
|
512
|
+
"@type": "PropertyValue",
|
|
513
|
+
"name": "beauty_targetAge",
|
|
514
|
+
"value": beauty_targetAge
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
if (gender) {
|
|
518
|
+
additionalProperties.push({
|
|
519
|
+
"@type": "PropertyValue",
|
|
520
|
+
"name": "gender",
|
|
521
|
+
"value": gender
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Add flavour as additionalProperty for single product
|
|
526
|
+
const flavourValues = variants?.map(v =>
|
|
527
|
+
v.choices?.find(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste')?.title
|
|
528
|
+
).filter(Boolean);
|
|
529
|
+
|
|
530
|
+
if (flavourValues && flavourValues.length > 0) {
|
|
531
|
+
// If multiple unique flavours, list them all; if single flavour, just that one
|
|
532
|
+
const uniqueFlavours = [...new Set(flavourValues)];
|
|
533
|
+
additionalProperties.push({
|
|
534
|
+
"@type": "PropertyValue",
|
|
535
|
+
"name": "flavour",
|
|
536
|
+
"value": uniqueFlavours.length === 1 ? uniqueFlavours[0] : uniqueFlavours.join(', ')
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return additionalProperties.length > 0 ? { additionalProperty: additionalProperties } : {};
|
|
541
|
+
})(),
|
|
542
|
+
...(offersArray.length > 0 && { offers: offersArray }),
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
graphArray.push(productSchema);
|
|
546
|
+
}
|
|
151
547
|
}
|
|
152
548
|
}
|
|
153
549
|
|
|
@@ -281,6 +677,34 @@ function generateReviewsSchema() {
|
|
|
281
677
|
|
|
282
678
|
{
|
|
283
679
|
schema && !schemaError && (
|
|
284
|
-
<script type="application/ld+json" set:html={
|
|
680
|
+
<script type="application/ld+json" set:html={(() => {
|
|
681
|
+
// Clean the schema object by removing undefined, null, and empty values
|
|
682
|
+
function cleanObject(obj) {
|
|
683
|
+
if (obj === null || obj === undefined || obj === '') {
|
|
684
|
+
return undefined;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (Array.isArray(obj)) {
|
|
688
|
+
const cleaned = obj.map(cleanObject).filter(item => item !== undefined);
|
|
689
|
+
return cleaned.length > 0 ? cleaned : undefined;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (typeof obj === 'object' && obj !== null) {
|
|
693
|
+
const cleaned = {};
|
|
694
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
695
|
+
const cleanedValue = cleanObject(value);
|
|
696
|
+
if (cleanedValue !== undefined) {
|
|
697
|
+
cleaned[key] = cleanedValue;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return obj;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const cleanedSchema = cleanObject(schema);
|
|
707
|
+
return cleanedSchema ? JSON.stringify(cleanedSchema, null, 2) : '{}';
|
|
708
|
+
})()} />
|
|
285
709
|
)
|
|
286
710
|
}
|