@thg-altitude/schemaorg 1.0.36 → 1.0.38
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/index.js +2 -0
- package/package.json +1 -1
- package/src/components/CollectionPage.astro +11 -4
- package/src/components/EnhancedProduct.astro +134 -44
- package/src/components/FAQ.astro +43 -0
- package/src/components/Product.astro +275 -246
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@ 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
9
|
import Recipe from './src/components/Recipe.astro'
|
|
10
|
+
import FAQ from './src/components/FAQ.astro'
|
|
10
11
|
|
|
11
12
|
export {
|
|
12
13
|
Product,
|
|
@@ -17,5 +18,6 @@ export {
|
|
|
17
18
|
ProductCollection,
|
|
18
19
|
WebSite,
|
|
19
20
|
EnhancedProductList,
|
|
21
|
+
FAQ,
|
|
20
22
|
Recipe
|
|
21
23
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
---
|
|
2
|
+
interface Props {
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const { name, description } = Astro.props as Props;
|
|
8
|
+
|
|
2
9
|
let schema = {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
10
|
+
"@context": "https://schema.org",
|
|
11
|
+
"@type": "CollectionPage",
|
|
12
|
+
"name": name,
|
|
13
|
+
"description": description,
|
|
7
14
|
};
|
|
8
15
|
---
|
|
9
16
|
|
|
@@ -29,7 +29,10 @@ const {
|
|
|
29
29
|
suggestedGender,
|
|
30
30
|
beauty_targetAge,
|
|
31
31
|
gender,
|
|
32
|
-
weight
|
|
32
|
+
weight,
|
|
33
|
+
colorVariantPages, // Array of {url, sku, color} for sibling color pages
|
|
34
|
+
currentColor, // The color of the current page (used to filter variants)
|
|
35
|
+
baseProductGroupID, // Optional: explicit base product group ID without color suffix
|
|
33
36
|
} = Astro.props;
|
|
34
37
|
|
|
35
38
|
// Use the enhanced mapping function to extract data from the full product data
|
|
@@ -126,20 +129,64 @@ function convertToISO8601(dateString) {
|
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
function generateProductSchema() {
|
|
129
|
-
|
|
132
|
+
// Helper: extract the color value from a variant's choices
|
|
133
|
+
function getVariantColor(variant) {
|
|
134
|
+
if (!variant?.choices) return null;
|
|
135
|
+
const colorChoice = variant.choices.find(c =>
|
|
136
|
+
c.optionKey === 'Color' || c.optionKey === 'Colour' || c.optionKey === 'color' ||
|
|
137
|
+
c.optionKey === 'colour' || c.optionKey === 'Shade' || c.optionKey === 'shade' ||
|
|
138
|
+
c.colour
|
|
139
|
+
);
|
|
140
|
+
if (colorChoice) {
|
|
141
|
+
return colorChoice.title || colorChoice.key || colorChoice.colour || null;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Filter variants to only include those matching the current page's color
|
|
147
|
+
// The currentColor prop must be explicitly passed by the calling code
|
|
148
|
+
// e.g. currentColor="Blush" to filter to only Blush variants
|
|
149
|
+
// If not provided, all variants are included (backwards compatible)
|
|
150
|
+
let filteredVariants = variants;
|
|
151
|
+
let excludedVariants = []; // Variants filtered out (other colors) - used for URL-only references
|
|
152
|
+
if (currentColor && variants && variants.length > 0) {
|
|
153
|
+
const filtered = variants.filter(v => {
|
|
154
|
+
const variantColor = getVariantColor(v);
|
|
155
|
+
if (!variantColor) return true; // Include variants without color info
|
|
156
|
+
return variantColor.toLowerCase() === currentColor.toLowerCase();
|
|
157
|
+
});
|
|
158
|
+
// Collect excluded variants (other colors) for URL-only hasVariant entries
|
|
159
|
+
excludedVariants = variants.filter(v => {
|
|
160
|
+
const variantColor = getVariantColor(v);
|
|
161
|
+
if (!variantColor) return false; // Don't include variants without color info
|
|
162
|
+
return variantColor.toLowerCase() !== currentColor.toLowerCase();
|
|
163
|
+
});
|
|
164
|
+
// Fallback: if filtering removed everything, use all variants
|
|
165
|
+
if (filtered.length > 0) {
|
|
166
|
+
filteredVariants = filtered;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const hasMultipleVariants = filteredVariants && filteredVariants.length > 1;
|
|
130
171
|
let variantsArray = [];
|
|
131
172
|
|
|
132
|
-
if (
|
|
173
|
+
if (filteredVariants && filteredVariants.length > 0 && currency) {
|
|
133
174
|
if (hasMultipleVariants) {
|
|
134
175
|
// Create individual Product objects for each variant
|
|
135
|
-
variantsArray =
|
|
176
|
+
variantsArray = filteredVariants
|
|
136
177
|
.filter((variant) => variant?.sku && variant?.price?.price?.amount)
|
|
137
178
|
.map((variant) => {
|
|
138
|
-
// Build variant name
|
|
139
|
-
|
|
179
|
+
// Build variant name from base name (without color suffix) + variant's color + size
|
|
180
|
+
// e.g. "MP Women's Tempo Twist Front Sports Bra" + "Blush" + "XL"
|
|
181
|
+
// = "MP Women's Tempo Twist Front Sports Bra Blush XL"
|
|
182
|
+
const hasColorData = filteredVariants.some(v => getVariantColor(v));
|
|
183
|
+
const baseName = hasColorData && name
|
|
184
|
+
? name.replace(/\s*-\s*[^-]+$/, '')
|
|
185
|
+
: name;
|
|
186
|
+
let variantName = baseName;
|
|
140
187
|
const attributes = [];
|
|
141
188
|
|
|
142
|
-
// Extract properties in preferred order: Amount + Weight + Flavor +
|
|
189
|
+
// Extract properties in preferred order: Amount + Weight + Flavor + Color + Size + Material
|
|
143
190
|
if (variant.choices && variant.choices.length > 0) {
|
|
144
191
|
// Amount (only if explicitly marked as Amount, not weight-related)
|
|
145
192
|
const amountChoice = variant.choices.find(c =>
|
|
@@ -165,6 +212,10 @@ function generateProductSchema() {
|
|
|
165
212
|
);
|
|
166
213
|
if (flavorChoice && flavorChoice.title) attributes.push(flavorChoice.title);
|
|
167
214
|
|
|
215
|
+
// Color - add from variant's actual color data
|
|
216
|
+
const variantColor = getVariantColor(variant);
|
|
217
|
+
if (variantColor) attributes.push(variantColor);
|
|
218
|
+
|
|
168
219
|
// Size (excluding weight-related sizes and already processed weight)
|
|
169
220
|
const sizeChoice = variant.choices.find(c =>
|
|
170
221
|
c.optionKey === 'Size' &&
|
|
@@ -176,20 +227,13 @@ function generateProductSchema() {
|
|
|
176
227
|
);
|
|
177
228
|
if (sizeChoice && sizeChoice.title) attributes.push(sizeChoice.title);
|
|
178
229
|
|
|
179
|
-
// Color
|
|
180
|
-
const colorChoice = variant.choices.find(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour);
|
|
181
|
-
if (colorChoice) {
|
|
182
|
-
if (colorChoice.title) attributes.push(colorChoice.title);
|
|
183
|
-
else if (colorChoice.colour) attributes.push(colorChoice.colour);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
230
|
// Material
|
|
187
231
|
const materialChoice = variant.choices.find(c => c.optionKey === 'Material');
|
|
188
232
|
if (materialChoice && materialChoice.title) attributes.push(materialChoice.title);
|
|
189
233
|
}
|
|
190
234
|
|
|
191
235
|
if (attributes.length > 0) {
|
|
192
|
-
variantName = `${
|
|
236
|
+
variantName = `${baseName} ${attributes.join(' ')}`;
|
|
193
237
|
}
|
|
194
238
|
|
|
195
239
|
return {
|
|
@@ -347,14 +391,18 @@ function generateProductSchema() {
|
|
|
347
391
|
});
|
|
348
392
|
} else {
|
|
349
393
|
// Single variant - create offers array as before
|
|
350
|
-
offersArray =
|
|
394
|
+
offersArray = filteredVariants
|
|
351
395
|
.filter((variant) => variant?.sku && variant?.price?.price?.amount)
|
|
352
396
|
.map((variant) => {
|
|
353
|
-
// Build variant name
|
|
354
|
-
|
|
397
|
+
// Build variant name from base name + variant's color + size
|
|
398
|
+
const hasColorData = filteredVariants.some(v => getVariantColor(v));
|
|
399
|
+
const baseName = hasColorData && name
|
|
400
|
+
? name.replace(/\s*-\s*[^-]+$/, '')
|
|
401
|
+
: name;
|
|
402
|
+
let variantName = baseName;
|
|
355
403
|
const attributes = [];
|
|
356
404
|
|
|
357
|
-
// Extract properties in preferred order: Amount + Weight + Flavor +
|
|
405
|
+
// Extract properties in preferred order: Amount + Weight + Flavor + Color + Size + Material
|
|
358
406
|
if (variant.choices && variant.choices.length > 0) {
|
|
359
407
|
// Amount (only if explicitly marked as Amount, not weight-related)
|
|
360
408
|
const amountChoice = variant.choices.find(c =>
|
|
@@ -380,6 +428,10 @@ function generateProductSchema() {
|
|
|
380
428
|
);
|
|
381
429
|
if (flavorChoice && flavorChoice.title) attributes.push(flavorChoice.title);
|
|
382
430
|
|
|
431
|
+
// Color - add from variant's actual color data
|
|
432
|
+
const variantColor = getVariantColor(variant);
|
|
433
|
+
if (variantColor) attributes.push(variantColor);
|
|
434
|
+
|
|
383
435
|
// Size (excluding weight-related sizes and already processed weight)
|
|
384
436
|
const sizeChoice = variant.choices.find(c =>
|
|
385
437
|
c.optionKey === 'Size' &&
|
|
@@ -391,20 +443,13 @@ function generateProductSchema() {
|
|
|
391
443
|
);
|
|
392
444
|
if (sizeChoice && sizeChoice.title) attributes.push(sizeChoice.title);
|
|
393
445
|
|
|
394
|
-
// Color
|
|
395
|
-
const colorChoice = variant.choices.find(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour);
|
|
396
|
-
if (colorChoice) {
|
|
397
|
-
if (colorChoice.title) attributes.push(colorChoice.title);
|
|
398
|
-
else if (colorChoice.colour) attributes.push(colorChoice.colour);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
446
|
// Material
|
|
402
447
|
const materialChoice = variant.choices.find(c => c.optionKey === 'Material');
|
|
403
448
|
if (materialChoice && materialChoice.title) attributes.push(materialChoice.title);
|
|
404
449
|
}
|
|
405
450
|
|
|
406
451
|
if (attributes.length > 0) {
|
|
407
|
-
variantName = `${
|
|
452
|
+
variantName = `${baseName} ${attributes.join(' ')}`;
|
|
408
453
|
}
|
|
409
454
|
|
|
410
455
|
return {
|
|
@@ -457,33 +502,70 @@ function generateProductSchema() {
|
|
|
457
502
|
const reviewsArray = generateReviewsSchema();
|
|
458
503
|
|
|
459
504
|
if (name && sku) {
|
|
460
|
-
const hasMultipleVariants =
|
|
505
|
+
const hasMultipleVariants = filteredVariants && filteredVariants.length > 1;
|
|
506
|
+
|
|
507
|
+
// Compute the base product group ID by stripping color suffix from SKU
|
|
508
|
+
// e.g. "16919878-fudge" -> "16919878", "16919878-rose-red" -> "16919878"
|
|
509
|
+
// If baseProductGroupID prop is provided, use that instead
|
|
510
|
+
const computedProductGroupID = baseProductGroupID || sku.toString().replace(/-[a-zA-Z].*$/, '');
|
|
461
511
|
|
|
462
512
|
if (hasMultipleVariants) {
|
|
463
|
-
//
|
|
513
|
+
// Build URL-only entries for other color variants (from excluded variants or colorVariantPages)
|
|
514
|
+
let otherColorUrlEntries = [];
|
|
515
|
+
if (colorVariantPages && colorVariantPages.length > 0) {
|
|
516
|
+
// Use explicitly provided color variant page URLs
|
|
517
|
+
otherColorUrlEntries = colorVariantPages
|
|
518
|
+
.filter(p => p?.url && (!currentColor || !p.color || p.color.toLowerCase() !== currentColor.toLowerCase()))
|
|
519
|
+
.map(p => ({
|
|
520
|
+
"url": p.url.startsWith('http') ? p.url : `${url}${p.url}`
|
|
521
|
+
}));
|
|
522
|
+
} else if (excludedVariants.length > 0) {
|
|
523
|
+
// Auto-generate URL-only entries from filtered-out variants (other colors)
|
|
524
|
+
// Use /p/{sku} format which redirects to the correct color page
|
|
525
|
+
// Deduplicate by color to get one URL per color (using first variant of each color)
|
|
526
|
+
const seenColors = new Set();
|
|
527
|
+
otherColorUrlEntries = excludedVariants
|
|
528
|
+
.filter(v => {
|
|
529
|
+
if (!v?.sku) return false;
|
|
530
|
+
const color = getVariantColor(v);
|
|
531
|
+
if (color && seenColors.has(color.toLowerCase())) return false;
|
|
532
|
+
if (color) seenColors.add(color.toLowerCase());
|
|
533
|
+
return true;
|
|
534
|
+
})
|
|
535
|
+
.map(v => ({
|
|
536
|
+
"url": `${url}/p/${v.sku}`
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Determine what properties vary between the filtered variants (this page's color only)
|
|
464
541
|
const variesBy = [];
|
|
465
542
|
|
|
466
|
-
// Check what attributes exist across variants
|
|
467
|
-
if (
|
|
543
|
+
// Check what attributes exist across filtered variants using correct schema.org properties
|
|
544
|
+
if (filteredVariants.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)))))) {
|
|
468
545
|
variesBy.push("https://schema.org/weight");
|
|
469
546
|
}
|
|
470
|
-
if (
|
|
547
|
+
if (filteredVariants.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')))) {
|
|
471
548
|
variesBy.push("https://schema.org/size");
|
|
472
549
|
}
|
|
473
|
-
|
|
550
|
+
// Add color to variesBy if we have other color variants (from colorVariantPages or excluded variants)
|
|
551
|
+
if (colorVariantPages && colorVariantPages.length > 0) {
|
|
552
|
+
variesBy.push("https://schema.org/color");
|
|
553
|
+
} else if (excludedVariants.length > 0) {
|
|
554
|
+
variesBy.push("https://schema.org/color");
|
|
555
|
+
} else if (filteredVariants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour))) {
|
|
474
556
|
variesBy.push("https://schema.org/color");
|
|
475
557
|
}
|
|
476
|
-
if (
|
|
558
|
+
if (filteredVariants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Material'))) {
|
|
477
559
|
variesBy.push("https://schema.org/material");
|
|
478
560
|
}
|
|
479
561
|
// For non-standard properties like flavour, use additionalProperty
|
|
480
|
-
if (
|
|
562
|
+
if (filteredVariants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste'))) {
|
|
481
563
|
if (!variesBy.includes("https://schema.org/additionalProperty")) {
|
|
482
564
|
variesBy.push("https://schema.org/additionalProperty");
|
|
483
565
|
}
|
|
484
566
|
}
|
|
485
567
|
// Check for Shade/Colour variations (Look Fantastic style) - now uses color property
|
|
486
|
-
if (
|
|
568
|
+
if (filteredVariants.some(v => v.choices && v.choices.some(c =>
|
|
487
569
|
c.optionKey === 'Shade' || c.optionKey === 'shade' ||
|
|
488
570
|
c.optionKey === 'Colour' || c.optionKey === 'colour' ||
|
|
489
571
|
c.optionKey === 'Color' || c.optionKey === 'color'))) {
|
|
@@ -492,12 +574,17 @@ function generateProductSchema() {
|
|
|
492
574
|
}
|
|
493
575
|
}
|
|
494
576
|
|
|
577
|
+
// Strip color suffix from ProductGroup name (e.g. "Sports Bra - Blush" -> "Sports Bra")
|
|
578
|
+
// Only strip when variants have color data, indicating the name suffix is a color
|
|
579
|
+
const hasColorVariants = filteredVariants.some(v => getVariantColor(v));
|
|
580
|
+
const productGroupName = hasColorVariants && name
|
|
581
|
+
? name.replace(/\s*-\s*[^-]+$/, '')
|
|
582
|
+
: name;
|
|
583
|
+
|
|
495
584
|
const productGroupSchema = {
|
|
496
585
|
"@type": "ProductGroup",
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
name: name,
|
|
500
|
-
productGroupID: sku.toString(),
|
|
586
|
+
name: productGroupName,
|
|
587
|
+
productGroupID: computedProductGroupID,
|
|
501
588
|
...(gtin13 && { gtin13: gtin13.toString() }),
|
|
502
589
|
...(description && { description }),
|
|
503
590
|
...(imageUrl && { image: imageUrl }),
|
|
@@ -508,7 +595,12 @@ function generateProductSchema() {
|
|
|
508
595
|
},
|
|
509
596
|
}),
|
|
510
597
|
...(variesBy.length > 0 && { variesBy }),
|
|
511
|
-
hasVariant:
|
|
598
|
+
hasVariant: [
|
|
599
|
+
...variantsArray,
|
|
600
|
+
// Add URL-only references to other color variants as hasVariant entries
|
|
601
|
+
// Uses colorVariantPages if provided, otherwise auto-generates from excluded variants
|
|
602
|
+
...otherColorUrlEntries
|
|
603
|
+
],
|
|
512
604
|
...(reviewsCount > 0 &&
|
|
513
605
|
reviewsAverage && {
|
|
514
606
|
aggregateRating: {
|
|
@@ -555,7 +647,7 @@ function generateProductSchema() {
|
|
|
555
647
|
}
|
|
556
648
|
|
|
557
649
|
// Add flavour as additionalProperty for ProductGroup level
|
|
558
|
-
const flavourValues =
|
|
650
|
+
const flavourValues = filteredVariants?.map(v =>
|
|
559
651
|
v.choices?.find(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste')?.title
|
|
560
652
|
).filter(Boolean);
|
|
561
653
|
|
|
@@ -578,8 +670,6 @@ function generateProductSchema() {
|
|
|
578
670
|
// Single product schema as before
|
|
579
671
|
const productSchema = {
|
|
580
672
|
"@type": "Product",
|
|
581
|
-
"@id": pageUrl,
|
|
582
|
-
url: pageUrl,
|
|
583
673
|
sku: sku.toString(),
|
|
584
674
|
name: name,
|
|
585
675
|
...(gtin13 && { gtin13: gtin13.toString() }),
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
faqs: { title: string; body: string }[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface faqQA {
|
|
7
|
+
"@type": "Question";
|
|
8
|
+
name: string;
|
|
9
|
+
acceptedAnswer: {
|
|
10
|
+
"@type": "Answer";
|
|
11
|
+
text: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { faqs } = Astro.props as Props;
|
|
16
|
+
|
|
17
|
+
const validFaqs = faqs.reduce((acc, faq) => {
|
|
18
|
+
if (faq?.title && faq?.body) {
|
|
19
|
+
acc.push({
|
|
20
|
+
"@type": "Question",
|
|
21
|
+
name: faq.title,
|
|
22
|
+
acceptedAnswer: {
|
|
23
|
+
"@type": "Answer",
|
|
24
|
+
text: faq.body,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return acc;
|
|
29
|
+
}, [] as faqQA[]);
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
validFaqs.length > 0 && (
|
|
34
|
+
<script
|
|
35
|
+
type="application/ld+json"
|
|
36
|
+
set:html={JSON.stringify({
|
|
37
|
+
"@context": "https://schema.org",
|
|
38
|
+
"@type": "FAQPage",
|
|
39
|
+
mainEntity: validFaqs,
|
|
40
|
+
})}
|
|
41
|
+
/>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -22,57 +22,149 @@ if (!suggestedAge && Astro.props.productContent?.propertyNameToValueMap?.beauty_
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
let url = import.meta.env.DEV
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
? Astro.url.origin
|
|
26
|
+
: `${Astro.request.headers.get(
|
|
27
|
+
'X-forwarded-Proto'
|
|
28
|
+
)}://${Astro.request.headers.get('X-forwarded-Host')?.split(', ')[0]}`
|
|
29
|
+
// New props for multi-page color variant support
|
|
30
|
+
const colorVariantPages = Astro.props.colorVariantPages; // Array of {url, sku, color} for sibling color pages
|
|
31
|
+
const currentColor = Astro.props.currentColor; // The color of the current page
|
|
32
|
+
const baseProductGroupID = Astro.props.baseProductGroupID; // Optional: explicit base product group ID
|
|
33
|
+
|
|
34
|
+
// Helper: extract the color value from a variant
|
|
35
|
+
function getVariantColor(variant) {
|
|
36
|
+
if (!variant) return null;
|
|
37
|
+
// Check direct color property
|
|
38
|
+
if (variant.color) return variant.color;
|
|
39
|
+
// Check choices array
|
|
40
|
+
if (variant.choices && Array.isArray(variant.choices)) {
|
|
41
|
+
const colorChoice = variant.choices.find(c =>
|
|
42
|
+
c.optionKey === 'Color' || c.optionKey === 'Colour' || c.optionKey === 'color' ||
|
|
43
|
+
c.optionKey === 'colour' || c.optionKey === 'Shade' || c.optionKey === 'shade'
|
|
44
|
+
);
|
|
45
|
+
if (colorChoice) {
|
|
46
|
+
return colorChoice.title || colorChoice.key || colorChoice.colour || null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Filter variants to only include those matching the current page's color
|
|
53
|
+
// The currentColor prop must be explicitly passed by the calling code
|
|
54
|
+
// e.g. currentColor="Blush" to filter to only Blush variants
|
|
55
|
+
// If not provided, all variants are included (backwards compatible)
|
|
56
|
+
let filteredVariants = Astro.props.variants;
|
|
57
|
+
let excludedVariants = []; // Variants filtered out (other colors) - used for URL-only references
|
|
58
|
+
if (currentColor && Array.isArray(Astro.props.variants) && Astro.props.variants.length > 0) {
|
|
59
|
+
const filtered = Astro.props.variants.filter(v => {
|
|
60
|
+
const variantColor = getVariantColor(v);
|
|
61
|
+
if (!variantColor) return true; // Include variants without color info
|
|
62
|
+
return variantColor.toLowerCase() === currentColor.toLowerCase();
|
|
63
|
+
});
|
|
64
|
+
// Collect excluded variants (other colors) for URL-only hasVariant entries
|
|
65
|
+
excludedVariants = Astro.props.variants.filter(v => {
|
|
66
|
+
const variantColor = getVariantColor(v);
|
|
67
|
+
if (!variantColor) return false; // Don't include variants without color info
|
|
68
|
+
return variantColor.toLowerCase() !== currentColor.toLowerCase();
|
|
69
|
+
});
|
|
70
|
+
// Fallback: if filtering removed everything, use all variants
|
|
71
|
+
if (filtered.length > 0) {
|
|
72
|
+
filteredVariants = filtered;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Compute the base product group ID by stripping color suffix from SKU
|
|
77
|
+
// e.g. "16919878-fudge" -> "16919878", "16919878-rose-red" -> "16919878"
|
|
78
|
+
const computedProductGroupID = baseProductGroupID || (Astro.props.sku ? Astro.props.sku.toString().replace(/-[a-zA-Z].*$/, '') : '');
|
|
79
|
+
|
|
29
80
|
// Check if we have multiple variants to determine if we need ProductGroup
|
|
30
|
-
const hasMultipleVariants =
|
|
81
|
+
const hasMultipleVariants = filteredVariants && filteredVariants.length > 1;
|
|
82
|
+
|
|
83
|
+
let reviewsArray: {}[] = []
|
|
84
|
+
Astro.props.reviews?.forEach((review)=>{
|
|
85
|
+
reviewsArray.push({
|
|
86
|
+
"@type": "Review",
|
|
87
|
+
itemReviewed: {
|
|
88
|
+
"@id": Astro.props.sku?.toString() || "",
|
|
89
|
+
name: Astro.props.name || "",
|
|
90
|
+
},
|
|
91
|
+
reviewRating: {
|
|
92
|
+
"@type": "Rating",
|
|
93
|
+
bestRating: "5",
|
|
94
|
+
ratingValue: review?.elements?.[0]?.score,
|
|
95
|
+
},
|
|
96
|
+
author: {
|
|
97
|
+
"@type": "Person",
|
|
98
|
+
name: stripHtml(review?.authorName),
|
|
99
|
+
},
|
|
100
|
+
datePublished: review?.posted,
|
|
101
|
+
reviewBody: stripHtml(review?.elements?.[1]?.value),
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
let aggregateRating;
|
|
106
|
+
if (Astro.props.reviewsCount > 0) {
|
|
107
|
+
aggregateRating = {
|
|
108
|
+
"@type": "AggregateRating",
|
|
109
|
+
"bestRating": 5,
|
|
110
|
+
"worstRating": 1
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (Astro.props.reviewsAverage) aggregateRating.ratingValue = Astro.props.reviewsAverage;
|
|
114
|
+
if (Astro.props.reviewsCount) aggregateRating.reviewCount = Astro.props.reviewsCount;
|
|
115
|
+
}
|
|
31
116
|
|
|
32
117
|
let variantsArray = []
|
|
33
118
|
let offersArray = []
|
|
119
|
+
let schema;
|
|
34
120
|
|
|
35
121
|
if (hasMultipleVariants) {
|
|
36
|
-
// Create individual Product objects for each variant
|
|
37
|
-
if (Array.isArray(
|
|
38
|
-
|
|
122
|
+
// Create individual Product objects for each variant (filtered to current color only)
|
|
123
|
+
if (Array.isArray(filteredVariants)) {
|
|
124
|
+
// Strip color from base name for variant name construction
|
|
125
|
+
const hasColorData = filteredVariants.some(v => getVariantColor(v));
|
|
126
|
+
const baseName = hasColorData && Astro.props.name
|
|
127
|
+
? Astro.props.name.replace(/\s*-\s*[^-]+$/, '')
|
|
128
|
+
: (Astro.props.name || "");
|
|
129
|
+
|
|
130
|
+
filteredVariants.forEach((variant) => {
|
|
39
131
|
if (variant && typeof variant === 'object' && variant.sku) {
|
|
40
|
-
// Build variant name
|
|
41
|
-
let variantName =
|
|
132
|
+
// Build variant name from base name + variant's color + size
|
|
133
|
+
let variantName = baseName;
|
|
42
134
|
const attributes = [];
|
|
43
|
-
|
|
135
|
+
|
|
44
136
|
// Check for amount/quantity first
|
|
45
137
|
if (variant.amount) attributes.push(variant.amount);
|
|
46
138
|
|
|
47
139
|
// Check for weight in different possible property names
|
|
48
140
|
if (variant.weight) attributes.push(variant.weight);
|
|
49
|
-
else if (variant.size
|
|
50
|
-
else if (variant.size && variant.size.includes('g')) attributes.push(variant.size);
|
|
141
|
+
else if (variant.size?.includes('kg') || variant.size?.includes('g')) attributes.push(variant.size);
|
|
51
142
|
|
|
52
143
|
// Check for flavor in different possible property names
|
|
53
|
-
if (variant.flavor) attributes.push(variant.flavor);
|
|
54
|
-
else if (variant.flavour) attributes.push(variant.flavour);
|
|
55
|
-
else if (variant.taste) attributes.push(variant.taste);
|
|
144
|
+
if (variant.flavor || variant.flavour || variant.taste) attributes.push(variant.flavor || variant.flavour || variant.taste);
|
|
56
145
|
else if (variant.name && variant.name !== Astro.props.name) {
|
|
57
146
|
// If variant has its own name that's different from product name, use it as flavor
|
|
58
147
|
const variantSpecificName = variant.name.replace(Astro.props.name, '').trim();
|
|
59
148
|
if (variantSpecificName) attributes.push(variantSpecificName);
|
|
60
149
|
}
|
|
61
150
|
|
|
151
|
+
// Add color from variant's actual data
|
|
152
|
+
const variantColor = getVariantColor(variant);
|
|
153
|
+
if (variantColor) attributes.push(variantColor);
|
|
154
|
+
|
|
62
155
|
// Add other attributes
|
|
63
156
|
if (variant.size && !variant.size.includes('kg') && !variant.size.includes('g')) attributes.push(variant.size);
|
|
64
|
-
if (variant.color) attributes.push(variant.color);
|
|
65
157
|
if (variant.material) attributes.push(variant.material);
|
|
66
158
|
|
|
67
|
-
if (attributes.length > 0 &&
|
|
68
|
-
variantName = `${
|
|
159
|
+
if (attributes.length > 0 && baseName) {
|
|
160
|
+
variantName = `${baseName} ${attributes.join(' ')}`;
|
|
69
161
|
}
|
|
70
162
|
|
|
71
163
|
const variantObj = {
|
|
72
164
|
"@type": "Product",
|
|
73
165
|
"sku": variant.sku.toString()
|
|
74
166
|
};
|
|
75
|
-
|
|
167
|
+
|
|
76
168
|
if (variantName) variantObj.name = variantName;
|
|
77
169
|
if (Astro.props.description) variantObj.description = Astro.props.description;
|
|
78
170
|
// Try multiple possible locations for variant image and extract URL string
|
|
@@ -137,10 +229,7 @@ if (hasMultipleVariants) {
|
|
|
137
229
|
const additionalProperties = [];
|
|
138
230
|
|
|
139
231
|
// Handle flavour as additionalProperty since it doesn't exist in schema.org
|
|
140
|
-
|
|
141
|
-
if (variant.flavor) flavorValue = variant.flavor;
|
|
142
|
-
else if (variant.flavour) flavorValue = variant.flavour;
|
|
143
|
-
else if (variant.taste) flavorValue = variant.taste;
|
|
232
|
+
const flavorValue = variant.flavor || variant.flavour || variant.taste || null;
|
|
144
233
|
if (flavorValue) {
|
|
145
234
|
additionalProperties.push({
|
|
146
235
|
"@type": "PropertyValue",
|
|
@@ -169,10 +258,95 @@ if (hasMultipleVariants) {
|
|
|
169
258
|
}
|
|
170
259
|
});
|
|
171
260
|
}
|
|
261
|
+
|
|
262
|
+
// Determine what properties vary between filtered variants
|
|
263
|
+
const propertyChecks = [
|
|
264
|
+
{ prop: 'weight', url: 'https://schema.org/weight' },
|
|
265
|
+
{ prop: 'size', url: 'https://schema.org/size' },
|
|
266
|
+
{ prop: 'material', url: 'https://schema.org/material' },
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
// Check what attributes exist across filtered variants
|
|
270
|
+
// For non-standard properties like amount and flavour, we use additionalProperty
|
|
271
|
+
const variesBy = propertyChecks
|
|
272
|
+
.filter(({ prop }) => filteredVariants?.some(v => v?.[prop]))
|
|
273
|
+
.map(({ url }) => url);
|
|
274
|
+
|
|
275
|
+
// Add color to variesBy if we have other color variants (from colorVariantPages or excluded variants)
|
|
276
|
+
if (colorVariantPages && colorVariantPages.length > 0) {
|
|
277
|
+
if (!variesBy.includes("https://schema.org/color")) {
|
|
278
|
+
variesBy.push("https://schema.org/color");
|
|
279
|
+
}
|
|
280
|
+
} else if (excludedVariants.length > 0) {
|
|
281
|
+
if (!variesBy.includes("https://schema.org/color")) {
|
|
282
|
+
variesBy.push("https://schema.org/color");
|
|
283
|
+
}
|
|
284
|
+
} else if (filteredVariants?.some(v => v?.color)) {
|
|
285
|
+
variesBy.push("https://schema.org/color");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Check for flavor with multiple possible property names - treat as additionalProperty
|
|
289
|
+
if (filteredVariants?.some(v => v && (v.flavor || v.flavour || v.taste))) {
|
|
290
|
+
if (!variesBy.includes("https://schema.org/additionalProperty")) {
|
|
291
|
+
variesBy.push("https://schema.org/additionalProperty");
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Check for shade/colour in variant.choices (Look Fantastic structure) - now uses color property
|
|
296
|
+
if (filteredVariants?.some(v => v && v.choices && Array.isArray(v.choices) &&
|
|
297
|
+
v.choices.some(c => c.optionKey === 'Shade' || c.optionKey === 'shade' ||
|
|
298
|
+
c.optionKey === 'Colour' || c.optionKey === 'colour' ||
|
|
299
|
+
c.optionKey === 'Color' || c.optionKey === 'color'))) {
|
|
300
|
+
if (!variesBy.includes("https://schema.org/color")) {
|
|
301
|
+
variesBy.push("https://schema.org/color");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Build URL-only entries for other color variants (from excluded variants or colorVariantPages)
|
|
306
|
+
let otherColorUrlEntries = [];
|
|
307
|
+
if (colorVariantPages && colorVariantPages.length > 0) {
|
|
308
|
+
// Use explicitly provided color variant page URLs
|
|
309
|
+
otherColorUrlEntries = colorVariantPages
|
|
310
|
+
.filter(p => p?.url && (!currentColor || !p.color || p.color.toLowerCase() !== currentColor.toLowerCase()))
|
|
311
|
+
.map(p => ({
|
|
312
|
+
"url": p.url.startsWith('http') ? p.url : `${url}${p.url}`
|
|
313
|
+
}));
|
|
314
|
+
} else if (excludedVariants.length > 0) {
|
|
315
|
+
// Auto-generate URL-only entries from filtered-out variants (other colors)
|
|
316
|
+
// Use /p/{sku} format which redirects to the correct color page
|
|
317
|
+
// Deduplicate by color to get one URL per color (using first variant of each color)
|
|
318
|
+
const seenColors = new Set();
|
|
319
|
+
otherColorUrlEntries = excludedVariants
|
|
320
|
+
.filter(v => {
|
|
321
|
+
if (!v?.sku) return false;
|
|
322
|
+
const color = getVariantColor(v);
|
|
323
|
+
if (color && seenColors.has(color.toLowerCase())) return false;
|
|
324
|
+
if (color) seenColors.add(color.toLowerCase());
|
|
325
|
+
return true;
|
|
326
|
+
})
|
|
327
|
+
.map(v => ({
|
|
328
|
+
"url": `${url}/p/${v.sku}`
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
schema = {
|
|
333
|
+
"@type": "ProductGroup",
|
|
334
|
+
"@context": "https://schema.org",
|
|
335
|
+
"productGroupID": computedProductGroupID,
|
|
336
|
+
"hasVariant": [
|
|
337
|
+
...variantsArray,
|
|
338
|
+
// Add URL-only references to other color variants as hasVariant entries
|
|
339
|
+
// Uses colorVariantPages if provided, otherwise auto-generates from excluded variants
|
|
340
|
+
...otherColorUrlEntries
|
|
341
|
+
]
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
if (variesBy.length > 0) schema.variesBy = variesBy;
|
|
345
|
+
|
|
172
346
|
} else {
|
|
173
347
|
// Single variant - create offers array as before
|
|
174
|
-
if (Array.isArray(
|
|
175
|
-
|
|
348
|
+
if (Array.isArray(filteredVariants)) {
|
|
349
|
+
filteredVariants.forEach((variant) => {
|
|
176
350
|
if (variant && typeof variant === 'object' && variant.sku) {
|
|
177
351
|
const offerObj = {
|
|
178
352
|
"@type": "Offer",
|
|
@@ -189,242 +363,97 @@ if (hasMultipleVariants) {
|
|
|
189
363
|
}
|
|
190
364
|
});
|
|
191
365
|
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
let reviewsArray = []
|
|
195
|
-
if (Astro.props.reviews && Array.isArray(Astro.props.reviews)) {
|
|
196
|
-
Astro.props.reviews.forEach((review)=>{
|
|
197
|
-
if (review && typeof review === 'object') {
|
|
198
|
-
const reviewObj = {
|
|
199
|
-
"@type": "Review",
|
|
200
|
-
"itemReviewed": {
|
|
201
|
-
"@id": Astro.props.sku ? Astro.props.sku.toString() : "",
|
|
202
|
-
"name": Astro.props.name || ""
|
|
203
|
-
},
|
|
204
|
-
"reviewRating": {
|
|
205
|
-
"@type": "Rating",
|
|
206
|
-
"bestRating": "5"
|
|
207
|
-
},
|
|
208
|
-
"author": {
|
|
209
|
-
"@type": "Person"
|
|
210
|
-
}
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
if (review?.elements?.[0]?.score) reviewObj.reviewRating.ratingValue = review.elements[0].score;
|
|
214
|
-
if (review?.authorName) reviewObj.author.name = stripHtml(review.authorName);
|
|
215
|
-
if (review?.posted) reviewObj.datePublished = review.posted;
|
|
216
|
-
if (review?.elements?.[1]?.value) reviewObj.reviewBody = stripHtml(review.elements[1].value);
|
|
217
|
-
|
|
218
|
-
reviewsArray.push(reviewObj);
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
366
|
|
|
223
|
-
let aggregateRating = undefined;
|
|
224
|
-
if (Astro.props.reviewsCount > 0) {
|
|
225
|
-
aggregateRating = {
|
|
226
|
-
"@type": "AggregateRating",
|
|
227
|
-
"bestRating": 5,
|
|
228
|
-
"worstRating": 1
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
if (Astro.props.reviewsAverage) aggregateRating.ratingValue = Astro.props.reviewsAverage;
|
|
232
|
-
if (Astro.props.reviewsCount) aggregateRating.reviewCount = Astro.props.reviewsCount;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
let schema;
|
|
236
|
-
|
|
237
|
-
if (hasMultipleVariants) {
|
|
238
|
-
// Determine what properties vary between variants
|
|
239
|
-
const variesBy = [];
|
|
240
|
-
|
|
241
|
-
// Check what attributes exist across variants - ensure variants is an array
|
|
242
|
-
if (Array.isArray(Astro.props.variants)) {
|
|
243
|
-
if (Astro.props.variants.some(v => v && v.weight)) variesBy.push("https://schema.org/weight");
|
|
244
|
-
if (Astro.props.variants.some(v => v && v.size)) variesBy.push("https://schema.org/size");
|
|
245
|
-
if (Astro.props.variants.some(v => v && v.color)) variesBy.push("https://schema.org/color");
|
|
246
|
-
if (Astro.props.variants.some(v => v && v.material)) variesBy.push("https://schema.org/material");
|
|
247
|
-
|
|
248
|
-
// Check for flavor with multiple possible property names - treat as additionalProperty
|
|
249
|
-
if (Astro.props.variants.some(v => v && (v.flavor || v.flavour || v.taste))) {
|
|
250
|
-
if (!variesBy.includes("https://schema.org/additionalProperty")) {
|
|
251
|
-
variesBy.push("https://schema.org/additionalProperty");
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Check for shade/colour in variant.choices (Look Fantastic structure) - now uses color property
|
|
256
|
-
if (Astro.props.variants.some(v => v && v.choices && Array.isArray(v.choices) &&
|
|
257
|
-
v.choices.some(c => c.optionKey === 'Shade' || c.optionKey === 'shade' ||
|
|
258
|
-
c.optionKey === 'Colour' || c.optionKey === 'colour' ||
|
|
259
|
-
c.optionKey === 'Color' || c.optionKey === 'color'))) {
|
|
260
|
-
if (!variesBy.includes("https://schema.org/color")) {
|
|
261
|
-
variesBy.push("https://schema.org/color");
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
schema = {
|
|
267
|
-
"@type": "ProductGroup",
|
|
268
|
-
"@context": "https://schema.org",
|
|
269
|
-
"@id": Astro.props.sku ? Astro.props.sku.toString() : "",
|
|
270
|
-
"url": `${url}${Astro.props.url || ""}`,
|
|
271
|
-
"productGroupID": Astro.props.sku ? Astro.props.sku.toString() : "",
|
|
272
|
-
"hasVariant": variantsArray
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
if (Astro.props.name) schema.name = Astro.props.name;
|
|
276
|
-
if (Astro.props.description) schema.description = Astro.props.description;
|
|
277
|
-
if (Astro.props.image) {
|
|
278
|
-
schema.image = import.meta.env.IMAGE_PROXY_URL
|
|
279
|
-
? `${import.meta.env.IMAGE_PROXY_URL}?url=${Astro.props.image}&format=webp&width=1500&height=1500&fit=cover`
|
|
280
|
-
: Astro.props.image;
|
|
281
|
-
}
|
|
282
|
-
if (Astro.props.brand) {
|
|
283
|
-
schema.brand = {
|
|
284
|
-
"@type": "Brand",
|
|
285
|
-
"name": Astro.props.brand
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
if (variesBy.length > 0) schema.variesBy = variesBy;
|
|
289
|
-
|
|
290
|
-
// Handle custom properties as additionalProperty (schema.org compliant)
|
|
291
|
-
const additionalProperties = [];
|
|
292
|
-
if (suggestedGender) {
|
|
293
|
-
additionalProperties.push({
|
|
294
|
-
"@type": "PropertyValue",
|
|
295
|
-
"name": "suggestedGender",
|
|
296
|
-
"value": suggestedGender
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
if (suggestedAge) {
|
|
300
|
-
additionalProperties.push({
|
|
301
|
-
"@type": "PropertyValue",
|
|
302
|
-
"name": "suggestedAge",
|
|
303
|
-
"value": suggestedAge
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
if (Astro.props.beauty_targetAge) {
|
|
307
|
-
additionalProperties.push({
|
|
308
|
-
"@type": "PropertyValue",
|
|
309
|
-
"name": "targetAge",
|
|
310
|
-
"value": Astro.props.beauty_targetAge
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
if (Astro.props.gender) {
|
|
314
|
-
additionalProperties.push({
|
|
315
|
-
"@type": "PropertyValue",
|
|
316
|
-
"name": "GenderType",
|
|
317
|
-
"value": Astro.props.gender
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Add flavour as additionalProperty for ProductGroup level
|
|
322
|
-
if (Array.isArray(Astro.props.variants)) {
|
|
323
|
-
const flavourValues = Astro.props.variants.map(v => {
|
|
324
|
-
if (v && typeof v === 'object') {
|
|
325
|
-
return v.flavor || v.flavour || v.taste;
|
|
326
|
-
}
|
|
327
|
-
return null;
|
|
328
|
-
}).filter(Boolean);
|
|
329
|
-
|
|
330
|
-
if (flavourValues.length > 0) {
|
|
331
|
-
// If multiple unique flavours, list them all; if single flavour, just that one
|
|
332
|
-
const uniqueFlavours = [...new Set(flavourValues)];
|
|
333
|
-
additionalProperties.push({
|
|
334
|
-
"@type": "PropertyValue",
|
|
335
|
-
"name": "flavour",
|
|
336
|
-
"value": uniqueFlavours.length === 1 ? uniqueFlavours[0] : uniqueFlavours.join(', ')
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
if (additionalProperties.length > 0) {
|
|
342
|
-
schema.additionalProperty = additionalProperties;
|
|
343
|
-
}
|
|
344
|
-
if (aggregateRating) schema.aggregateRating = aggregateRating;
|
|
345
|
-
if (reviewsArray.length > 0) schema.review = reviewsArray;
|
|
346
|
-
} else {
|
|
347
367
|
// Single product schema as before
|
|
348
368
|
schema = {
|
|
349
369
|
"@type": "Product",
|
|
350
370
|
"@context": "https://schema.org",
|
|
351
|
-
"@id": Astro.props.sku ? Astro.props.sku.toString() : "",
|
|
352
371
|
"sku": Astro.props.sku ? Astro.props.sku.toString() : "",
|
|
353
372
|
"offers": offersArray
|
|
354
373
|
};
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
"name": Astro.props.brand
|
|
367
|
-
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Extract duplicate code
|
|
377
|
+
if (Astro.props.name) {
|
|
378
|
+
// Strip color suffix from ProductGroup name (e.g. "Sports Bra - Blush" -> "Sports Bra")
|
|
379
|
+
// Only strip when it's a ProductGroup and variants have color data
|
|
380
|
+
const hasColorVariants = hasMultipleVariants && filteredVariants?.some(v => getVariantColor(v));
|
|
381
|
+
if (hasColorVariants) {
|
|
382
|
+
schema.name = Astro.props.name.replace(/\s*-\s*[^-]+$/, '');
|
|
383
|
+
} else {
|
|
384
|
+
schema.name = Astro.props.name;
|
|
368
385
|
}
|
|
386
|
+
}
|
|
387
|
+
if (Astro.props.description) schema.description = Astro.props.description;
|
|
388
|
+
if (Astro.props.image) {
|
|
389
|
+
schema.image = import.meta.env.IMAGE_PROXY_URL
|
|
390
|
+
? `${import.meta.env.IMAGE_PROXY_URL}?url=${Astro.props.image}&format=webp&width=1500&height=1500&fit=cover`
|
|
391
|
+
: Astro.props.image;
|
|
392
|
+
}
|
|
393
|
+
if (Astro.props.brand) {
|
|
394
|
+
schema.brand = {
|
|
395
|
+
"@type": "Brand",
|
|
396
|
+
"name": Astro.props.brand
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Handle custom properties as additionalProperty (schema.org compliant)
|
|
401
|
+
const additionalProperties: {"@type":string, name:string, value:string}[] = [];
|
|
402
|
+
if (suggestedGender) {
|
|
403
|
+
additionalProperties.push({
|
|
404
|
+
"@type": "PropertyValue",
|
|
405
|
+
"name": "suggestedGender",
|
|
406
|
+
"value": suggestedGender
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
if (suggestedAge) {
|
|
410
|
+
additionalProperties.push({
|
|
411
|
+
"@type": "PropertyValue",
|
|
412
|
+
"name": "suggestedAge",
|
|
413
|
+
"value": suggestedAge
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (Astro.props.beauty_targetAge) {
|
|
417
|
+
additionalProperties.push({
|
|
418
|
+
"@type": "PropertyValue",
|
|
419
|
+
"name": "beauty_targetAge",
|
|
420
|
+
"value": Astro.props.beauty_targetAge
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
if (Astro.props.gender) {
|
|
424
|
+
additionalProperties.push({
|
|
425
|
+
"@type": "PropertyValue",
|
|
426
|
+
"name": "gender",
|
|
427
|
+
"value": Astro.props.gender
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Add flavour as additionalProperty for single product
|
|
432
|
+
if (Array.isArray(Astro.props.variants)) {
|
|
433
|
+
const flavourValues: string[] = Astro.props.variants.map(v => {
|
|
434
|
+
if (v && typeof v === 'object') {
|
|
435
|
+
return v.flavor || v.flavour || v.taste;
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}).filter(Boolean);
|
|
369
439
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
440
|
+
if (flavourValues.length > 0) {
|
|
441
|
+
// If multiple unique flavours, list them all; if single flavour, just that one
|
|
442
|
+
const uniqueFlavours = [...new Set(flavourValues)];
|
|
373
443
|
additionalProperties.push({
|
|
374
444
|
"@type": "PropertyValue",
|
|
375
|
-
"name": "
|
|
376
|
-
"value":
|
|
445
|
+
"name": "flavour",
|
|
446
|
+
"value": uniqueFlavours.length === 1 ? uniqueFlavours[0] : uniqueFlavours.join(', ')
|
|
377
447
|
});
|
|
378
448
|
}
|
|
379
|
-
if (suggestedAge) {
|
|
380
|
-
additionalProperties.push({
|
|
381
|
-
"@type": "PropertyValue",
|
|
382
|
-
"name": "suggestedAge",
|
|
383
|
-
"value": suggestedAge
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
if (Astro.props.beauty_targetAge) {
|
|
387
|
-
additionalProperties.push({
|
|
388
|
-
"@type": "PropertyValue",
|
|
389
|
-
"name": "beauty_targetAge",
|
|
390
|
-
"value": Astro.props.beauty_targetAge
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
if (Astro.props.gender) {
|
|
394
|
-
additionalProperties.push({
|
|
395
|
-
"@type": "PropertyValue",
|
|
396
|
-
"name": "gender",
|
|
397
|
-
"value": Astro.props.gender
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Add flavour as additionalProperty for single product
|
|
402
|
-
if (Array.isArray(Astro.props.variants)) {
|
|
403
|
-
const flavourValues = Astro.props.variants.map(v => {
|
|
404
|
-
if (v && typeof v === 'object') {
|
|
405
|
-
return v.flavor || v.flavour || v.taste;
|
|
406
|
-
}
|
|
407
|
-
return null;
|
|
408
|
-
}).filter(Boolean);
|
|
409
|
-
|
|
410
|
-
if (flavourValues.length > 0) {
|
|
411
|
-
// If multiple unique flavours, list them all; if single flavour, just that one
|
|
412
|
-
const uniqueFlavours = [...new Set(flavourValues)];
|
|
413
|
-
additionalProperties.push({
|
|
414
|
-
"@type": "PropertyValue",
|
|
415
|
-
"name": "flavour",
|
|
416
|
-
"value": uniqueFlavours.length === 1 ? uniqueFlavours[0] : uniqueFlavours.join(', ')
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
if (additionalProperties.length > 0) {
|
|
422
|
-
schema.additionalProperty = additionalProperties;
|
|
423
|
-
}
|
|
424
|
-
if (aggregateRating) schema.aggregateRating = aggregateRating;
|
|
425
|
-
if (reviewsArray.length > 0) schema.review = reviewsArray;
|
|
426
449
|
}
|
|
427
450
|
|
|
451
|
+
if (additionalProperties.length > 0) {
|
|
452
|
+
schema.additionalProperty = additionalProperties;
|
|
453
|
+
}
|
|
454
|
+
if (aggregateRating) schema.aggregateRating = aggregateRating;
|
|
455
|
+
if (reviewsArray.length > 0) schema.review = reviewsArray;
|
|
456
|
+
|
|
428
457
|
---
|
|
429
458
|
|
|
430
459
|
<script type="application/ld+json" set:html={JSON.stringify(schema, (key, value) => {
|