@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thg-altitude/schemaorg",
3
- "version": "1.0.37",
3
+ "version": "1.0.39",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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
- const hasMultipleVariants = variants && variants.length > 1;
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 (variants && variants.length > 0 && currency) {
173
+ if (filteredVariants && filteredVariants.length > 0 && currency) {
133
174
  if (hasMultipleVariants) {
134
175
  // Create individual Product objects for each variant
135
- variantsArray = variants
176
+ variantsArray = filteredVariants
136
177
  .filter((variant) => variant?.sku && variant?.price?.price?.amount)
137
178
  .map((variant) => {
138
- // Build variant name with attributes from choices in logical order
139
- let variantName = name;
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 + Size + Color + Material
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 = `${name} ${attributes.join(' ')}`;
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 = variants
395
+ offersArray = filteredVariants
351
396
  .filter((variant) => variant?.sku && variant?.price?.price?.amount)
352
397
  .map((variant) => {
353
- // Build variant name with attributes from choices in logical order
354
- let variantName = name;
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 + Size + Color + Material
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 = `${name} ${attributes.join(' ')}`;
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 = variants && variants.length > 1;
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
- // Determine what properties vary between variants
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 based on choices using correct schema.org properties
467
- 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)))))) {
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 (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')))) {
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
- if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour))) {
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 (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Material'))) {
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 (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste'))) {
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 (variants.some(v => v.choices && v.choices.some(c =>
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
- "@id": pageUrl,
498
- url: pageUrl,
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: variantsArray,
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 = variants?.map(v =>
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 = Astro.props.variants && Astro.props.variants.length > 1;
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(Astro.props.variants)) {
73
- Astro.props.variants.forEach((variant) => {
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 with attributes in logical order: Name + Weight + Flavor + Size + Color + Material
76
- let variantName = Astro.props.name || "";
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 && Astro.props.name) {
100
- variantName = `${Astro.props.name} ${attributes.join(' ')}`;
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 variants - ensure variants is an array
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 }) => Astro.props.variants?.some(v => v?.[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 (Astro.props.variants?.some(v => v && (v.flavor || v.flavour || v.taste))) {
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 (Astro.props.variants?.some(v => v && v.choices && Array.isArray(v.choices) &&
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
- "@id": Astro.props.sku ? Astro.props.sku.toString() : "",
237
- "url": `${url}${Astro.props.url || ""}`,
238
- "productGroupID": Astro.props.sku ? Astro.props.sku.toString() : "",
239
- "hasVariant": variantsArray
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(Astro.props.variants)) {
247
- Astro.props.variants.forEach((variant) => {
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) schema.name = 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