@thg-altitude/schemaorg 1.0.37 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thg-altitude/schemaorg",
3
- "version": "1.0.37",
3
+ "version": "1.0.38",
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 {
@@ -347,14 +391,18 @@ function generateProductSchema() {
347
391
  });
348
392
  } else {
349
393
  // Single variant - create offers array as before
350
- offersArray = variants
394
+ offersArray = filteredVariants
351
395
  .filter((variant) => variant?.sku && variant?.price?.price?.amount)
352
396
  .map((variant) => {
353
- // Build variant name with attributes from choices in logical order
354
- let variantName = name;
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 + Size + Color + Material
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 = `${name} ${attributes.join(' ')}`;
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 = variants && variants.length > 1;
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
- // Determine what properties vary between variants
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 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)))))) {
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 (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')))) {
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
- if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Color' || c.optionKey === 'Colour' || c.colour))) {
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 (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Material'))) {
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 (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste'))) {
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 (variants.some(v => v.choices && v.choices.some(c =>
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
- "@id": pageUrl,
498
- url: pageUrl,
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: variantsArray,
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 = variants?.map(v =>
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() }),
@@ -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 = {
@@ -199,29 +259,41 @@ if (hasMultipleVariants) {
199
259
  });
200
260
  }
201
261
 
202
- // Determine what properties vary between variants
262
+ // Determine what properties vary between filtered variants
203
263
  const propertyChecks = [
204
264
  { prop: 'weight', url: 'https://schema.org/weight' },
205
265
  { prop: 'size', url: 'https://schema.org/size' },
206
- { prop: 'color', url: 'https://schema.org/color' },
207
266
  { prop: 'material', url: 'https://schema.org/material' },
208
267
  ];
209
268
 
210
- // Check what attributes exist across variants - ensure variants is an array
269
+ // Check what attributes exist across filtered variants
211
270
  // For non-standard properties like amount and flavour, we use additionalProperty
212
271
  const variesBy = propertyChecks
213
- .filter(({ prop }) => Astro.props.variants?.some(v => v?.[prop]))
272
+ .filter(({ prop }) => filteredVariants?.some(v => v?.[prop]))
214
273
  .map(({ url }) => url);
215
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
+
216
288
  // 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))) {
289
+ if (filteredVariants?.some(v => v && (v.flavor || v.flavour || v.taste))) {
218
290
  if (!variesBy.includes("https://schema.org/additionalProperty")) {
219
291
  variesBy.push("https://schema.org/additionalProperty");
220
292
  }
221
293
  }
222
294
 
223
295
  // 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) &&
296
+ if (filteredVariants?.some(v => v && v.choices && Array.isArray(v.choices) &&
225
297
  v.choices.some(c => c.optionKey === 'Shade' || c.optionKey === 'shade' ||
226
298
  c.optionKey === 'Colour' || c.optionKey === 'colour' ||
227
299
  c.optionKey === 'Color' || c.optionKey === 'color'))) {
@@ -230,21 +302,51 @@ if (hasMultipleVariants) {
230
302
  }
231
303
  }
232
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
+
233
332
  schema = {
234
333
  "@type": "ProductGroup",
235
334
  "@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
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
+ ]
240
342
  };
241
343
 
242
344
  if (variesBy.length > 0) schema.variesBy = variesBy;
243
345
 
244
346
  } else {
245
347
  // Single variant - create offers array as before
246
- if (Array.isArray(Astro.props.variants)) {
247
- Astro.props.variants.forEach((variant) => {
348
+ if (Array.isArray(filteredVariants)) {
349
+ filteredVariants.forEach((variant) => {
248
350
  if (variant && typeof variant === 'object' && variant.sku) {
249
351
  const offerObj = {
250
352
  "@type": "Offer",
@@ -266,14 +368,22 @@ if (hasMultipleVariants) {
266
368
  schema = {
267
369
  "@type": "Product",
268
370
  "@context": "https://schema.org",
269
- "@id": Astro.props.sku ? Astro.props.sku.toString() : "",
270
371
  "sku": Astro.props.sku ? Astro.props.sku.toString() : "",
271
372
  "offers": offersArray
272
373
  };
273
374
  }
274
375
 
275
376
  // Extract duplicate code
276
- if (Astro.props.name) schema.name = Astro.props.name;
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;
385
+ }
386
+ }
277
387
  if (Astro.props.description) schema.description = Astro.props.description;
278
388
  if (Astro.props.image) {
279
389
  schema.image = import.meta.env.IMAGE_PROXY_URL