@thg-altitude/schemaorg 1.0.37 → 1.0.39
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/package.json +1 -1
- package/src/components/EnhancedProduct.astro +135 -44
- package/src/components/Product.astro +134 -23
package/package.json
CHANGED
|
@@ -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 {
|
|
@@ -310,6 +354,7 @@ function generateProductSchema() {
|
|
|
310
354
|
...(variant?.barcode && { gtin13: variant.barcode.toString() }),
|
|
311
355
|
offers: {
|
|
312
356
|
"@type": "Offer",
|
|
357
|
+
sku: variant.sku.toString(),
|
|
313
358
|
url: `${pageUrl}?variation=${variant?.sku}`,
|
|
314
359
|
price: parseFloat(variant.price.price.amount),
|
|
315
360
|
priceCurrency: currency,
|
|
@@ -347,14 +392,18 @@ function generateProductSchema() {
|
|
|
347
392
|
});
|
|
348
393
|
} else {
|
|
349
394
|
// Single variant - create offers array as before
|
|
350
|
-
offersArray =
|
|
395
|
+
offersArray = filteredVariants
|
|
351
396
|
.filter((variant) => variant?.sku && variant?.price?.price?.amount)
|
|
352
397
|
.map((variant) => {
|
|
353
|
-
// Build variant name
|
|
354
|
-
|
|
398
|
+
// Build variant name from base name + variant's color + size
|
|
399
|
+
const hasColorData = filteredVariants.some(v => getVariantColor(v));
|
|
400
|
+
const baseName = hasColorData && name
|
|
401
|
+
? name.replace(/\s*-\s*[^-]+$/, '')
|
|
402
|
+
: name;
|
|
403
|
+
let variantName = baseName;
|
|
355
404
|
const attributes = [];
|
|
356
405
|
|
|
357
|
-
// Extract properties in preferred order: Amount + Weight + Flavor +
|
|
406
|
+
// Extract properties in preferred order: Amount + Weight + Flavor + Color + Size + Material
|
|
358
407
|
if (variant.choices && variant.choices.length > 0) {
|
|
359
408
|
// Amount (only if explicitly marked as Amount, not weight-related)
|
|
360
409
|
const amountChoice = variant.choices.find(c =>
|
|
@@ -380,6 +429,10 @@ function generateProductSchema() {
|
|
|
380
429
|
);
|
|
381
430
|
if (flavorChoice && flavorChoice.title) attributes.push(flavorChoice.title);
|
|
382
431
|
|
|
432
|
+
// Color - add from variant's actual color data
|
|
433
|
+
const variantColor = getVariantColor(variant);
|
|
434
|
+
if (variantColor) attributes.push(variantColor);
|
|
435
|
+
|
|
383
436
|
// Size (excluding weight-related sizes and already processed weight)
|
|
384
437
|
const sizeChoice = variant.choices.find(c =>
|
|
385
438
|
c.optionKey === 'Size' &&
|
|
@@ -391,20 +444,13 @@ function generateProductSchema() {
|
|
|
391
444
|
);
|
|
392
445
|
if (sizeChoice && sizeChoice.title) attributes.push(sizeChoice.title);
|
|
393
446
|
|
|
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
447
|
// Material
|
|
402
448
|
const materialChoice = variant.choices.find(c => c.optionKey === 'Material');
|
|
403
449
|
if (materialChoice && materialChoice.title) attributes.push(materialChoice.title);
|
|
404
450
|
}
|
|
405
451
|
|
|
406
452
|
if (attributes.length > 0) {
|
|
407
|
-
variantName = `${
|
|
453
|
+
variantName = `${baseName} ${attributes.join(' ')}`;
|
|
408
454
|
}
|
|
409
455
|
|
|
410
456
|
return {
|
|
@@ -457,33 +503,70 @@ function generateProductSchema() {
|
|
|
457
503
|
const reviewsArray = generateReviewsSchema();
|
|
458
504
|
|
|
459
505
|
if (name && sku) {
|
|
460
|
-
const hasMultipleVariants =
|
|
506
|
+
const hasMultipleVariants = filteredVariants && filteredVariants.length > 1;
|
|
507
|
+
|
|
508
|
+
// Compute the base product group ID by stripping color suffix from SKU
|
|
509
|
+
// e.g. "16919878-fudge" -> "16919878", "16919878-rose-red" -> "16919878"
|
|
510
|
+
// If baseProductGroupID prop is provided, use that instead
|
|
511
|
+
const computedProductGroupID = baseProductGroupID || sku.toString().replace(/-[a-zA-Z].*$/, '');
|
|
461
512
|
|
|
462
513
|
if (hasMultipleVariants) {
|
|
463
|
-
//
|
|
514
|
+
// Build URL-only entries for other color variants (from excluded variants or colorVariantPages)
|
|
515
|
+
let otherColorUrlEntries = [];
|
|
516
|
+
if (colorVariantPages && colorVariantPages.length > 0) {
|
|
517
|
+
// Use explicitly provided color variant page URLs
|
|
518
|
+
otherColorUrlEntries = colorVariantPages
|
|
519
|
+
.filter(p => p?.url && (!currentColor || !p.color || p.color.toLowerCase() !== currentColor.toLowerCase()))
|
|
520
|
+
.map(p => ({
|
|
521
|
+
"url": p.url.startsWith('http') ? p.url : `${url}${p.url}`
|
|
522
|
+
}));
|
|
523
|
+
} else if (excludedVariants.length > 0) {
|
|
524
|
+
// Auto-generate URL-only entries from filtered-out variants (other colors)
|
|
525
|
+
// Use /p/{sku} format which redirects to the correct color page
|
|
526
|
+
// Deduplicate by color to get one URL per color (using first variant of each color)
|
|
527
|
+
const seenColors = new Set();
|
|
528
|
+
otherColorUrlEntries = excludedVariants
|
|
529
|
+
.filter(v => {
|
|
530
|
+
if (!v?.sku) return false;
|
|
531
|
+
const color = getVariantColor(v);
|
|
532
|
+
if (color && seenColors.has(color.toLowerCase())) return false;
|
|
533
|
+
if (color) seenColors.add(color.toLowerCase());
|
|
534
|
+
return true;
|
|
535
|
+
})
|
|
536
|
+
.map(v => ({
|
|
537
|
+
"url": `${url}/p/${v.sku}`
|
|
538
|
+
}));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Determine what properties vary between the filtered variants (this page's color only)
|
|
464
542
|
const variesBy = [];
|
|
465
543
|
|
|
466
|
-
// Check what attributes exist across variants
|
|
467
|
-
if (
|
|
544
|
+
// Check what attributes exist across filtered variants using correct schema.org properties
|
|
545
|
+
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
546
|
variesBy.push("https://schema.org/weight");
|
|
469
547
|
}
|
|
470
|
-
if (
|
|
548
|
+
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
549
|
variesBy.push("https://schema.org/size");
|
|
472
550
|
}
|
|
473
|
-
|
|
551
|
+
// Add color to variesBy if we have other color variants (from colorVariantPages or excluded variants)
|
|
552
|
+
if (colorVariantPages && colorVariantPages.length > 0) {
|
|
553
|
+
variesBy.push("https://schema.org/color");
|
|
554
|
+
} else if (excludedVariants.length > 0) {
|
|
555
|
+
variesBy.push("https://schema.org/color");
|
|
556
|
+
} else if (filteredVariants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour))) {
|
|
474
557
|
variesBy.push("https://schema.org/color");
|
|
475
558
|
}
|
|
476
|
-
if (
|
|
559
|
+
if (filteredVariants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Material'))) {
|
|
477
560
|
variesBy.push("https://schema.org/material");
|
|
478
561
|
}
|
|
479
562
|
// For non-standard properties like flavour, use additionalProperty
|
|
480
|
-
if (
|
|
563
|
+
if (filteredVariants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste'))) {
|
|
481
564
|
if (!variesBy.includes("https://schema.org/additionalProperty")) {
|
|
482
565
|
variesBy.push("https://schema.org/additionalProperty");
|
|
483
566
|
}
|
|
484
567
|
}
|
|
485
568
|
// Check for Shade/Colour variations (Look Fantastic style) - now uses color property
|
|
486
|
-
if (
|
|
569
|
+
if (filteredVariants.some(v => v.choices && v.choices.some(c =>
|
|
487
570
|
c.optionKey === 'Shade' || c.optionKey === 'shade' ||
|
|
488
571
|
c.optionKey === 'Colour' || c.optionKey === 'colour' ||
|
|
489
572
|
c.optionKey === 'Color' || c.optionKey === 'color'))) {
|
|
@@ -492,12 +575,17 @@ function generateProductSchema() {
|
|
|
492
575
|
}
|
|
493
576
|
}
|
|
494
577
|
|
|
578
|
+
// Strip color suffix from ProductGroup name (e.g. "Sports Bra - Blush" -> "Sports Bra")
|
|
579
|
+
// Only strip when variants have color data, indicating the name suffix is a color
|
|
580
|
+
const hasColorVariants = filteredVariants.some(v => getVariantColor(v));
|
|
581
|
+
const productGroupName = hasColorVariants && name
|
|
582
|
+
? name.replace(/\s*-\s*[^-]+$/, '')
|
|
583
|
+
: name;
|
|
584
|
+
|
|
495
585
|
const productGroupSchema = {
|
|
496
586
|
"@type": "ProductGroup",
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
name: name,
|
|
500
|
-
productGroupID: sku.toString(),
|
|
587
|
+
name: productGroupName,
|
|
588
|
+
productGroupID: computedProductGroupID,
|
|
501
589
|
...(gtin13 && { gtin13: gtin13.toString() }),
|
|
502
590
|
...(description && { description }),
|
|
503
591
|
...(imageUrl && { image: imageUrl }),
|
|
@@ -508,7 +596,12 @@ function generateProductSchema() {
|
|
|
508
596
|
},
|
|
509
597
|
}),
|
|
510
598
|
...(variesBy.length > 0 && { variesBy }),
|
|
511
|
-
hasVariant:
|
|
599
|
+
hasVariant: [
|
|
600
|
+
...variantsArray,
|
|
601
|
+
// Add URL-only references to other color variants as hasVariant entries
|
|
602
|
+
// Uses colorVariantPages if provided, otherwise auto-generates from excluded variants
|
|
603
|
+
...otherColorUrlEntries
|
|
604
|
+
],
|
|
512
605
|
...(reviewsCount > 0 &&
|
|
513
606
|
reviewsAverage && {
|
|
514
607
|
aggregateRating: {
|
|
@@ -555,7 +648,7 @@ function generateProductSchema() {
|
|
|
555
648
|
}
|
|
556
649
|
|
|
557
650
|
// Add flavour as additionalProperty for ProductGroup level
|
|
558
|
-
const flavourValues =
|
|
651
|
+
const flavourValues = filteredVariants?.map(v =>
|
|
559
652
|
v.choices?.find(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste')?.title
|
|
560
653
|
).filter(Boolean);
|
|
561
654
|
|
|
@@ -578,8 +671,6 @@ function generateProductSchema() {
|
|
|
578
671
|
// Single product schema as before
|
|
579
672
|
const productSchema = {
|
|
580
673
|
"@type": "Product",
|
|
581
|
-
"@id": pageUrl,
|
|
582
|
-
url: pageUrl,
|
|
583
674
|
sku: sku.toString(),
|
|
584
675
|
name: name,
|
|
585
676
|
...(gtin13 && { gtin13: gtin13.toString() }),
|
|
@@ -26,8 +26,59 @@ let url = import.meta.env.DEV
|
|
|
26
26
|
: `${Astro.request.headers.get(
|
|
27
27
|
'X-forwarded-Proto'
|
|
28
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;
|
|
31
82
|
|
|
32
83
|
let reviewsArray: {}[] = []
|
|
33
84
|
Astro.props.reviews?.forEach((review)=>{
|
|
@@ -68,12 +119,18 @@ let offersArray = []
|
|
|
68
119
|
let schema;
|
|
69
120
|
|
|
70
121
|
if (hasMultipleVariants) {
|
|
71
|
-
// Create individual Product objects for each variant
|
|
72
|
-
if (Array.isArray(
|
|
73
|
-
|
|
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) => {
|
|
74
131
|
if (variant && typeof variant === 'object' && variant.sku) {
|
|
75
|
-
// Build variant name
|
|
76
|
-
let variantName =
|
|
132
|
+
// Build variant name from base name + variant's color + size
|
|
133
|
+
let variantName = baseName;
|
|
77
134
|
const attributes = [];
|
|
78
135
|
|
|
79
136
|
// Check for amount/quantity first
|
|
@@ -91,13 +148,16 @@ if (hasMultipleVariants) {
|
|
|
91
148
|
if (variantSpecificName) attributes.push(variantSpecificName);
|
|
92
149
|
}
|
|
93
150
|
|
|
151
|
+
// Add color from variant's actual data
|
|
152
|
+
const variantColor = getVariantColor(variant);
|
|
153
|
+
if (variantColor) attributes.push(variantColor);
|
|
154
|
+
|
|
94
155
|
// Add other attributes
|
|
95
156
|
if (variant.size && !variant.size.includes('kg') && !variant.size.includes('g')) attributes.push(variant.size);
|
|
96
|
-
if (variant.color) attributes.push(variant.color);
|
|
97
157
|
if (variant.material) attributes.push(variant.material);
|
|
98
158
|
|
|
99
|
-
if (attributes.length > 0 &&
|
|
100
|
-
variantName = `${
|
|
159
|
+
if (attributes.length > 0 && baseName) {
|
|
160
|
+
variantName = `${baseName} ${attributes.join(' ')}`;
|
|
101
161
|
}
|
|
102
162
|
|
|
103
163
|
const variantObj = {
|
|
@@ -185,6 +245,7 @@ if (hasMultipleVariants) {
|
|
|
185
245
|
|
|
186
246
|
const offerObj = {
|
|
187
247
|
"@type": "Offer",
|
|
248
|
+
"sku": variant.sku.toString(),
|
|
188
249
|
"url": `${url}${Astro.props.url || ""}?variation=${variant.sku}`,
|
|
189
250
|
"itemCondition": "https://schema.org/NewCondition",
|
|
190
251
|
"availability": variant.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock"
|
|
@@ -199,29 +260,41 @@ if (hasMultipleVariants) {
|
|
|
199
260
|
});
|
|
200
261
|
}
|
|
201
262
|
|
|
202
|
-
// Determine what properties vary between variants
|
|
263
|
+
// Determine what properties vary between filtered variants
|
|
203
264
|
const propertyChecks = [
|
|
204
265
|
{ prop: 'weight', url: 'https://schema.org/weight' },
|
|
205
266
|
{ prop: 'size', url: 'https://schema.org/size' },
|
|
206
|
-
{ prop: 'color', url: 'https://schema.org/color' },
|
|
207
267
|
{ prop: 'material', url: 'https://schema.org/material' },
|
|
208
268
|
];
|
|
209
269
|
|
|
210
|
-
// Check what attributes exist across
|
|
270
|
+
// Check what attributes exist across filtered variants
|
|
211
271
|
// For non-standard properties like amount and flavour, we use additionalProperty
|
|
212
272
|
const variesBy = propertyChecks
|
|
213
|
-
.filter(({ prop }) =>
|
|
273
|
+
.filter(({ prop }) => filteredVariants?.some(v => v?.[prop]))
|
|
214
274
|
.map(({ url }) => url);
|
|
215
275
|
|
|
276
|
+
// Add color to variesBy if we have other color variants (from colorVariantPages or excluded variants)
|
|
277
|
+
if (colorVariantPages && colorVariantPages.length > 0) {
|
|
278
|
+
if (!variesBy.includes("https://schema.org/color")) {
|
|
279
|
+
variesBy.push("https://schema.org/color");
|
|
280
|
+
}
|
|
281
|
+
} else if (excludedVariants.length > 0) {
|
|
282
|
+
if (!variesBy.includes("https://schema.org/color")) {
|
|
283
|
+
variesBy.push("https://schema.org/color");
|
|
284
|
+
}
|
|
285
|
+
} else if (filteredVariants?.some(v => v?.color)) {
|
|
286
|
+
variesBy.push("https://schema.org/color");
|
|
287
|
+
}
|
|
288
|
+
|
|
216
289
|
// Check for flavor with multiple possible property names - treat as additionalProperty
|
|
217
|
-
if (
|
|
290
|
+
if (filteredVariants?.some(v => v && (v.flavor || v.flavour || v.taste))) {
|
|
218
291
|
if (!variesBy.includes("https://schema.org/additionalProperty")) {
|
|
219
292
|
variesBy.push("https://schema.org/additionalProperty");
|
|
220
293
|
}
|
|
221
294
|
}
|
|
222
295
|
|
|
223
296
|
// Check for shade/colour in variant.choices (Look Fantastic structure) - now uses color property
|
|
224
|
-
if (
|
|
297
|
+
if (filteredVariants?.some(v => v && v.choices && Array.isArray(v.choices) &&
|
|
225
298
|
v.choices.some(c => c.optionKey === 'Shade' || c.optionKey === 'shade' ||
|
|
226
299
|
c.optionKey === 'Colour' || c.optionKey === 'colour' ||
|
|
227
300
|
c.optionKey === 'Color' || c.optionKey === 'color'))) {
|
|
@@ -230,21 +303,51 @@ if (hasMultipleVariants) {
|
|
|
230
303
|
}
|
|
231
304
|
}
|
|
232
305
|
|
|
306
|
+
// Build URL-only entries for other color variants (from excluded variants or colorVariantPages)
|
|
307
|
+
let otherColorUrlEntries = [];
|
|
308
|
+
if (colorVariantPages && colorVariantPages.length > 0) {
|
|
309
|
+
// Use explicitly provided color variant page URLs
|
|
310
|
+
otherColorUrlEntries = colorVariantPages
|
|
311
|
+
.filter(p => p?.url && (!currentColor || !p.color || p.color.toLowerCase() !== currentColor.toLowerCase()))
|
|
312
|
+
.map(p => ({
|
|
313
|
+
"url": p.url.startsWith('http') ? p.url : `${url}${p.url}`
|
|
314
|
+
}));
|
|
315
|
+
} else if (excludedVariants.length > 0) {
|
|
316
|
+
// Auto-generate URL-only entries from filtered-out variants (other colors)
|
|
317
|
+
// Use /p/{sku} format which redirects to the correct color page
|
|
318
|
+
// Deduplicate by color to get one URL per color (using first variant of each color)
|
|
319
|
+
const seenColors = new Set();
|
|
320
|
+
otherColorUrlEntries = excludedVariants
|
|
321
|
+
.filter(v => {
|
|
322
|
+
if (!v?.sku) return false;
|
|
323
|
+
const color = getVariantColor(v);
|
|
324
|
+
if (color && seenColors.has(color.toLowerCase())) return false;
|
|
325
|
+
if (color) seenColors.add(color.toLowerCase());
|
|
326
|
+
return true;
|
|
327
|
+
})
|
|
328
|
+
.map(v => ({
|
|
329
|
+
"url": `${url}/p/${v.sku}`
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
|
|
233
333
|
schema = {
|
|
234
334
|
"@type": "ProductGroup",
|
|
235
335
|
"@context": "https://schema.org",
|
|
236
|
-
"
|
|
237
|
-
"
|
|
238
|
-
|
|
239
|
-
|
|
336
|
+
"productGroupID": computedProductGroupID,
|
|
337
|
+
"hasVariant": [
|
|
338
|
+
...variantsArray,
|
|
339
|
+
// Add URL-only references to other color variants as hasVariant entries
|
|
340
|
+
// Uses colorVariantPages if provided, otherwise auto-generates from excluded variants
|
|
341
|
+
...otherColorUrlEntries
|
|
342
|
+
]
|
|
240
343
|
};
|
|
241
344
|
|
|
242
345
|
if (variesBy.length > 0) schema.variesBy = variesBy;
|
|
243
346
|
|
|
244
347
|
} else {
|
|
245
348
|
// Single variant - create offers array as before
|
|
246
|
-
if (Array.isArray(
|
|
247
|
-
|
|
349
|
+
if (Array.isArray(filteredVariants)) {
|
|
350
|
+
filteredVariants.forEach((variant) => {
|
|
248
351
|
if (variant && typeof variant === 'object' && variant.sku) {
|
|
249
352
|
const offerObj = {
|
|
250
353
|
"@type": "Offer",
|
|
@@ -266,14 +369,22 @@ if (hasMultipleVariants) {
|
|
|
266
369
|
schema = {
|
|
267
370
|
"@type": "Product",
|
|
268
371
|
"@context": "https://schema.org",
|
|
269
|
-
"@id": Astro.props.sku ? Astro.props.sku.toString() : "",
|
|
270
372
|
"sku": Astro.props.sku ? Astro.props.sku.toString() : "",
|
|
271
373
|
"offers": offersArray
|
|
272
374
|
};
|
|
273
375
|
}
|
|
274
376
|
|
|
275
377
|
// Extract duplicate code
|
|
276
|
-
if (Astro.props.name)
|
|
378
|
+
if (Astro.props.name) {
|
|
379
|
+
// Strip color suffix from ProductGroup name (e.g. "Sports Bra - Blush" -> "Sports Bra")
|
|
380
|
+
// Only strip when it's a ProductGroup and variants have color data
|
|
381
|
+
const hasColorVariants = hasMultipleVariants && filteredVariants?.some(v => getVariantColor(v));
|
|
382
|
+
if (hasColorVariants) {
|
|
383
|
+
schema.name = Astro.props.name.replace(/\s*-\s*[^-]+$/, '');
|
|
384
|
+
} else {
|
|
385
|
+
schema.name = Astro.props.name;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
277
388
|
if (Astro.props.description) schema.description = Astro.props.description;
|
|
278
389
|
if (Astro.props.image) {
|
|
279
390
|
schema.image = import.meta.env.IMAGE_PROXY_URL
|