@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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@thg-altitude/schemaorg",
3
- "version": "1.0.36",
3
+ "version": "1.0.38",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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
- "@context": "https://schema.org",
4
- "@type": "CollectionPage",
5
- "name": Astro.props.name,
6
- "description": Astro.props.description,
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
- 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() }),
@@ -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
- ? Astro.url.origin
26
- : `${Astro.request.headers.get(
27
- 'X-forwarded-Proto'
28
- )}://${Astro.request.headers.get('X-forwarded-Host')?.split(', ')[0]}`
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 = Astro.props.variants && Astro.props.variants.length > 1;
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(Astro.props.variants)) {
38
- 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) => {
39
131
  if (variant && typeof variant === 'object' && variant.sku) {
40
- // Build variant name with attributes in logical order: Name + Weight + Flavor + Size + Color + Material
41
- let variantName = Astro.props.name || "";
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 && variant.size.includes('kg')) attributes.push(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 && Astro.props.name) {
68
- variantName = `${Astro.props.name} ${attributes.join(' ')}`;
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
- let flavorValue = null;
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(Astro.props.variants)) {
175
- Astro.props.variants.forEach((variant) => {
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
- if (Astro.props.name) schema.name = Astro.props.name;
357
- if (Astro.props.description) schema.description = Astro.props.description;
358
- if (Astro.props.image) {
359
- schema.image = import.meta.env.IMAGE_PROXY_URL
360
- ? `${import.meta.env.IMAGE_PROXY_URL}?url=${Astro.props.image}&format=webp&width=1500&height=1500&fit=cover`
361
- : Astro.props.image;
362
- }
363
- if (Astro.props.brand) {
364
- schema.brand = {
365
- "@type": "Brand",
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
- // Handle custom properties as additionalProperty (schema.org compliant)
371
- const additionalProperties = [];
372
- if (suggestedGender) {
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": "suggestedGender",
376
- "value": suggestedGender
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) => {