@thg-altitude/schemaorg 1.0.35 → 1.0.37

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.35",
3
+ "version": "1.0.37",
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,6 +29,7 @@ const {
29
29
  suggestedGender,
30
30
  beauty_targetAge,
31
31
  gender,
32
+ weight
32
33
  } = Astro.props;
33
34
 
34
35
  // Use the enhanced mapping function to extract data from the full product data
@@ -64,7 +65,9 @@ try {
64
65
  ? Astro.url.origin
65
66
  : `${Astro.request.headers.get("X-forwarded-Proto")}://${Astro.request.headers.get("X-forwarded-Host")?.split(", ")[0]}`;
66
67
  imageUrl = image
67
- ? `${import.meta.env.IMAGE_PROXY_URL}?url=${image}&format=webp&width=1500&height=1500&fit=cover`
68
+ ? (import.meta.env.IMAGE_PROXY_URL
69
+ ? `${import.meta.env.IMAGE_PROXY_URL}?url=${image}&format=webp&width=1500&height=1500&fit=cover`
70
+ : image)
68
71
  : null;
69
72
  pageUrl = urlPath ? `${url}${urlPath}` : url;
70
73
 
@@ -88,6 +91,40 @@ try {
88
91
  schemaError = true;
89
92
  }
90
93
 
94
+ // Helper function to parse weight string into value and unit
95
+ function parseWeight(weightString) {
96
+ if (!weightString) return null;
97
+
98
+ // Match patterns like "5kg", "5 kg", "500g", "500 g", "10lb", "10 lb"
99
+ const match = weightString.match(/(\d+(?:\.\d+)?)\s*(kg|g|lb|oz)/i);
100
+ if (match) {
101
+ return {
102
+ value: parseFloat(match[1]),
103
+ unitText: match[2].toLowerCase()
104
+ };
105
+ }
106
+ return null;
107
+ }
108
+
109
+ // Helper function to convert date from DD-MM-YYYY to ISO-8601 (YYYY-MM-DD)
110
+ function convertToISO8601(dateString) {
111
+ if (!dateString) return null;
112
+
113
+ // Check if already in ISO format (YYYY-MM-DD)
114
+ if (/^\d{4}-\d{2}-\d{2}/.test(dateString)) {
115
+ return dateString;
116
+ }
117
+
118
+ // Convert from DD-MM-YYYY to YYYY-MM-DD
119
+ const match = dateString.match(/^(\d{2})-(\d{2})-(\d{4})/);
120
+ if (match) {
121
+ const [, day, month, year] = match;
122
+ return `${year}-${month}-${day}`;
123
+ }
124
+
125
+ return dateString; // Return as-is if format not recognized
126
+ }
127
+
91
128
  function generateProductSchema() {
92
129
  const hasMultipleVariants = variants && variants.length > 1;
93
130
  let variantsArray = [];
@@ -160,11 +197,71 @@ function generateProductSchema() {
160
197
  sku: variant.sku.toString(),
161
198
  name: variantName,
162
199
  ...(description && { description }),
163
- ...(imageUrl && { image: imageUrl }),
164
- // Extract variant-specific properties from choices using correct schema.org properties
165
- ...(variant.choices && variant.choices.some(c => c.optionKey === 'Weight' || (c.title && (c.title.includes('kg') || c.title.includes('lb') || /\d+\s*g\b/.test(c.title)))) && {
166
- weight: variant.choices.find(c => c.optionKey === 'Weight' || (c.title && (c.title.includes('kg') || c.title.includes('lb') || /\d+\s*g\b/.test(c.title))))?.title
167
- }),
200
+ ...(() => {
201
+ // Try multiple possible locations for variant image and extract URL string
202
+ let variantImageUrl = variant.image || variant.imageUrl || variant.images?.[0] || variant.media?.[0]?.url || image;
203
+
204
+ // If image is an object, try to extract the URL string
205
+ if (variantImageUrl && typeof variantImageUrl === 'object') {
206
+ variantImageUrl = variantImageUrl.original || variantImageUrl.url || variantImageUrl.src || variantImageUrl.large || variantImageUrl.medium;
207
+ }
208
+
209
+ if (variantImageUrl && typeof variantImageUrl === 'string') {
210
+ const finalImageUrl = import.meta.env.IMAGE_PROXY_URL
211
+ ? `${import.meta.env.IMAGE_PROXY_URL}?url=${variantImageUrl}&format=webp&width=1500&height=1500&fit=cover`
212
+ : variantImageUrl;
213
+ return { image: finalImageUrl };
214
+ }
215
+ return {};
216
+ })(),
217
+ // Extract weight as QuantitativeValue
218
+ ...(() => {
219
+ // First check if weight is at product level (beauty products - mediumProduct.weight)
220
+ const productWeight = weight || Astro.props.mediumProduct?.weight;
221
+
222
+ if (productWeight && typeof productWeight === 'object' && productWeight.value && productWeight.unit) {
223
+ return {
224
+ weight: {
225
+ "@type": "QuantitativeValue",
226
+ "value": parseFloat(productWeight.value),
227
+ "unitText": productWeight.unit.toLowerCase()
228
+ }
229
+ };
230
+ }
231
+
232
+ // Check if weight is at variant level
233
+ if (variant.weight && typeof variant.weight === 'object' && variant.weight.value && variant.weight.unit) {
234
+ return {
235
+ weight: {
236
+ "@type": "QuantitativeValue",
237
+ "value": parseFloat(variant.weight.value),
238
+ "unitText": variant.weight.unit.toLowerCase()
239
+ }
240
+ };
241
+ }
242
+
243
+ // Otherwise, try to parse from choices
244
+ const weightChoice = variant.choices?.find(c =>
245
+ c.optionKey === 'Weight' ||
246
+ (c.optionKey === 'Size' && c.title && (c.title.includes('kg') || c.title.includes('g') || c.title.includes('lb'))) ||
247
+ (c.title && (c.title.includes('kg') || c.title.includes('lb') || /\d+\s*g\b/.test(c.title)))
248
+ );
249
+
250
+ if (weightChoice?.title) {
251
+ const parsedWeight = parseWeight(weightChoice.title);
252
+ if (parsedWeight) {
253
+ return {
254
+ weight: {
255
+ "@type": "QuantitativeValue",
256
+ "value": parsedWeight.value,
257
+ "unitText": parsedWeight.unitText
258
+ }
259
+ };
260
+ }
261
+ }
262
+
263
+ return {};
264
+ })(),
168
265
  ...(variant.choices && variant.choices.some(c => c.optionKey === 'Size' && c.title && !c.title.includes('kg') && !c.title.includes('g') && !c.title.includes('lb')) && {
169
266
  size: variant.choices.find(c => c.optionKey === 'Size' && c.title && !c.title.includes('kg') && !c.title.includes('g') && !c.title.includes('lb'))?.title
170
267
  }),
@@ -175,20 +272,10 @@ function generateProductSchema() {
175
272
  ...(variant.choices && variant.choices.some(c => c.optionKey === 'Material') && {
176
273
  material: variant.choices.find(c => c.optionKey === 'Material')?.title
177
274
  }),
178
- // Handle non-standard properties (amount, flavour) as additionalProperty
275
+ // Handle non-standard properties (flavour) as additionalProperty
179
276
  ...(() => {
180
277
  const additionalProperties = [];
181
278
 
182
- // Amount
183
- const amountChoice = variant.choices?.find(c => c.optionKey === 'Amount');
184
- if (amountChoice?.title) {
185
- additionalProperties.push({
186
- "@type": "PropertyValue",
187
- "name": "amount",
188
- "value": amountChoice.title
189
- });
190
- }
191
-
192
279
  // Flavour (as additionalProperty since it doesn't exist in schema.org)
193
280
  const flavourChoice = variant.choices?.find(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste');
194
281
  if (flavourChoice?.title) {
@@ -224,7 +311,7 @@ function generateProductSchema() {
224
311
  offers: {
225
312
  "@type": "Offer",
226
313
  url: `${pageUrl}?variation=${variant?.sku}`,
227
- price: variant.price.price.amount,
314
+ price: parseFloat(variant.price.price.amount),
228
315
  priceCurrency: currency,
229
316
  itemCondition: "https://schema.org/NewCondition",
230
317
  availability: variant?.inStock
@@ -240,7 +327,7 @@ function generateProductSchema() {
240
327
  {
241
328
  "@type": "UnitPriceSpecification",
242
329
  priceCurrency: currency,
243
- price: variant.price.price.amount,
330
+ price: parseFloat(variant.price.price.amount),
244
331
  valueAddedTaxIncluded: true,
245
332
  },
246
333
  ...(variant?.price?.rrp?.amount
@@ -248,7 +335,7 @@ function generateProductSchema() {
248
335
  {
249
336
  "@type": "UnitPriceSpecification",
250
337
  priceCurrency: currency,
251
- price: variant.price.rrp.amount,
338
+ price: parseFloat(variant.price.rrp.amount),
252
339
  valueAddedTaxIncluded: true,
253
340
  priceType: "https://schema.org/StrikethroughPrice",
254
341
  },
@@ -325,7 +412,7 @@ function generateProductSchema() {
325
412
  name: variantName,
326
413
  sku: variant.sku.toString(),
327
414
  url: pageUrl,
328
- price: variant.price.price.amount,
415
+ price: parseFloat(variant.price.price.amount),
329
416
  priceCurrency: currency,
330
417
  itemCondition: "https://schema.org/NewCondition",
331
418
  availability: variant?.inStock
@@ -342,7 +429,7 @@ function generateProductSchema() {
342
429
  {
343
430
  "@type": "UnitPriceSpecification",
344
431
  priceCurrency: currency,
345
- price: variant.price.price.amount,
432
+ price: parseFloat(variant.price.price.amount),
346
433
  valueAddedTaxIncluded: true,
347
434
  },
348
435
  ...(variant?.price?.rrp?.amount
@@ -350,7 +437,7 @@ function generateProductSchema() {
350
437
  {
351
438
  "@type": "UnitPriceSpecification",
352
439
  priceCurrency: currency,
353
- price: variant.price.rrp.amount,
440
+ price: parseFloat(variant.price.rrp.amount),
354
441
  valueAddedTaxIncluded: true,
355
442
  priceType: "https://schema.org/StrikethroughPrice",
356
443
  },
@@ -389,10 +476,7 @@ function generateProductSchema() {
389
476
  if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Material'))) {
390
477
  variesBy.push("https://schema.org/material");
391
478
  }
392
- // For non-standard properties like amount, flavour, and shade, use additionalProperty
393
- if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Amount'))) {
394
- variesBy.push("https://schema.org/additionalProperty");
395
- }
479
+ // For non-standard properties like flavour, use additionalProperty
396
480
  if (variants.some(v => v.choices && v.choices.some(c => c.optionKey === 'Flavour' || c.optionKey === 'Flavor' || c.optionKey === 'Taste'))) {
397
481
  if (!variesBy.includes("https://schema.org/additionalProperty")) {
398
482
  variesBy.push("https://schema.org/additionalProperty");
@@ -597,8 +681,8 @@ function generateSaleEventSchema() {
597
681
  name: brand,
598
682
  url: url,
599
683
  },
600
- startDate: saleEventStartDate,
601
- ...(saleEventEndDate && { endDate: saleEventEndDate }),
684
+ startDate: convertToISO8601(saleEventStartDate),
685
+ ...(saleEventEndDate && { endDate: convertToISO8601(saleEventEndDate) }),
602
686
  };
603
687
 
604
688
  if (streetAddress && addressLocality && postalCode && country) {
@@ -707,7 +791,7 @@ function generateReviewsSchema() {
707
791
 
708
792
  {
709
793
  schema && !schemaError && (
710
- <script type="application/ld+json" set:html={(() => {
794
+ <script type="application/ld+json" set:html={JSON.stringify((() => {
711
795
  // Clean the schema object by removing undefined, null, and empty values
712
796
  function cleanObject(obj) {
713
797
  if (obj === null || obj === undefined || obj === '') {
@@ -734,7 +818,7 @@ function generateReviewsSchema() {
734
818
  }
735
819
 
736
820
  const cleanedSchema = cleanObject(schema);
737
- return cleanedSchema ? JSON.stringify(cleanedSchema, null, 2) : '{}';
738
- })()} />
821
+ return cleanedSchema || {};
822
+ })(), null, 2)} />
739
823
  )
740
824
  }
@@ -17,26 +17,40 @@ breadcrumbs?.length > 0 && breadcrumbs.forEach((crumb,index)=>{breadcrumbsList.p
17
17
  } )})
18
18
 
19
19
  products?.length >0 && products?.forEach((product)=>{
20
- productList.push( {
21
- "@type": "Product",
22
- "@id":product?.url,
23
- "name": product?.title,
24
- "image": product?.images?.[0]?.original ,
25
- "url": product?.url,
26
- "sku":product?.sku,
27
- "offers": {
28
- "@type": "Offer",
29
- "priceCurrency": product.defaultVariant?.price?.price.currency ,
30
- "price": product.defaultVariant?.price?.price.amount
31
- },
32
- "aggregateRating":{
33
- "@type":"AggregateRating",
34
- "ratingValue":product?.reviews?.averageScore,
35
- "reviewCount":product?.reviews?.total
36
- },
37
-
38
- },)
39
-
20
+ if (!product?.title || !product?.sku) return; // Skip products without required fields
21
+
22
+ const productItem = {
23
+ "@type": "Product",
24
+ "@id": product?.url,
25
+ "name": product?.title,
26
+ "url": product?.url,
27
+ "sku": product?.sku
28
+ };
29
+
30
+ // Add image if available
31
+ if (product?.images?.[0]?.original) {
32
+ productItem.image = product.images[0].original;
33
+ }
34
+
35
+ // Add offers if price data is available
36
+ if (product.defaultVariant?.price?.price?.currency && product.defaultVariant?.price?.price?.amount) {
37
+ productItem.offers = {
38
+ "@type": "Offer",
39
+ "priceCurrency": product.defaultVariant.price.price.currency,
40
+ "price": product.defaultVariant.price.price.amount
41
+ };
42
+ }
43
+
44
+ // Add aggregateRating only if review data exists
45
+ if (product?.reviews?.averageScore && product?.reviews?.total) {
46
+ productItem.aggregateRating = {
47
+ "@type": "AggregateRating",
48
+ "ratingValue": product.reviews.averageScore,
49
+ "reviewCount": product.reviews.total
50
+ };
51
+ }
52
+
53
+ productList.push(productItem);
40
54
  })
41
55
 
42
56
  let schema = {
@@ -56,4 +70,17 @@ let schema = {
56
70
  ]
57
71
  };
58
72
  ---
59
- <script type="application/ld+json" set:html={JSON.stringify(schema)}></script>
73
+ <script type="application/ld+json" set:html={JSON.stringify(schema, (key, value) => {
74
+ // Filter out undefined, null, and empty string values
75
+ if (value === undefined || value === null || value === '') {
76
+ return undefined;
77
+ }
78
+ // Filter out empty arrays and objects
79
+ if (Array.isArray(value) && value.length === 0) {
80
+ return undefined;
81
+ }
82
+ if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) {
83
+ return undefined;
84
+ }
85
+ return value;
86
+ })}></script>
@@ -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,15 +22,50 @@ 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
29
  // Check if we have multiple variants to determine if we need ProductGroup
30
30
  const hasMultipleVariants = Astro.props.variants && Astro.props.variants.length > 1;
31
31
 
32
+ let reviewsArray: {}[] = []
33
+ Astro.props.reviews?.forEach((review)=>{
34
+ reviewsArray.push({
35
+ "@type": "Review",
36
+ itemReviewed: {
37
+ "@id": Astro.props.sku?.toString() || "",
38
+ name: Astro.props.name || "",
39
+ },
40
+ reviewRating: {
41
+ "@type": "Rating",
42
+ bestRating: "5",
43
+ ratingValue: review?.elements?.[0]?.score,
44
+ },
45
+ author: {
46
+ "@type": "Person",
47
+ name: stripHtml(review?.authorName),
48
+ },
49
+ datePublished: review?.posted,
50
+ reviewBody: stripHtml(review?.elements?.[1]?.value),
51
+ });
52
+ });
53
+
54
+ let aggregateRating;
55
+ if (Astro.props.reviewsCount > 0) {
56
+ aggregateRating = {
57
+ "@type": "AggregateRating",
58
+ "bestRating": 5,
59
+ "worstRating": 1
60
+ };
61
+
62
+ if (Astro.props.reviewsAverage) aggregateRating.ratingValue = Astro.props.reviewsAverage;
63
+ if (Astro.props.reviewsCount) aggregateRating.reviewCount = Astro.props.reviewsCount;
64
+ }
65
+
32
66
  let variantsArray = []
33
67
  let offersArray = []
68
+ let schema;
34
69
 
35
70
  if (hasMultipleVariants) {
36
71
  // Create individual Product objects for each variant
@@ -40,19 +75,16 @@ if (hasMultipleVariants) {
40
75
  // Build variant name with attributes in logical order: Name + Weight + Flavor + Size + Color + Material
41
76
  let variantName = Astro.props.name || "";
42
77
  const attributes = [];
43
-
78
+
44
79
  // Check for amount/quantity first
45
80
  if (variant.amount) attributes.push(variant.amount);
46
81
 
47
82
  // Check for weight in different possible property names
48
83
  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);
84
+ else if (variant.size?.includes('kg') || variant.size?.includes('g')) attributes.push(variant.size);
51
85
 
52
86
  // 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);
87
+ if (variant.flavor || variant.flavour || variant.taste) attributes.push(variant.flavor || variant.flavour || variant.taste);
56
88
  else if (variant.name && variant.name !== Astro.props.name) {
57
89
  // If variant has its own name that's different from product name, use it as flavor
58
90
  const variantSpecificName = variant.name.replace(Astro.props.name, '').trim();
@@ -72,17 +104,51 @@ if (hasMultipleVariants) {
72
104
  "@type": "Product",
73
105
  "sku": variant.sku.toString()
74
106
  };
75
-
107
+
76
108
  if (variantName) variantObj.name = variantName;
77
109
  if (Astro.props.description) variantObj.description = Astro.props.description;
78
- if ((variant.image || Astro.props.image) && import.meta.env.IMAGE_PROXY_URL) {
79
- variantObj.image = `${import.meta.env.IMAGE_PROXY_URL}?url=${variant.image || Astro.props.image}&format=webp&width=1500&height=1500&fit=cover`;
110
+ // Try multiple possible locations for variant image and extract URL string
111
+ let variantImageUrl = variant.image || variant.imageUrl || variant.images?.[0] || variant.media?.[0]?.url || Astro.props.image;
112
+
113
+ // If image is an object, try to extract the URL string
114
+ if (variantImageUrl && typeof variantImageUrl === 'object') {
115
+ variantImageUrl = variantImageUrl.original || variantImageUrl.url || variantImageUrl.src || variantImageUrl.large || variantImageUrl.medium;
116
+ }
117
+
118
+ if (variantImageUrl && typeof variantImageUrl === 'string') {
119
+ variantObj.image = import.meta.env.IMAGE_PROXY_URL
120
+ ? `${import.meta.env.IMAGE_PROXY_URL}?url=${variantImageUrl}&format=webp&width=1500&height=1500&fit=cover`
121
+ : variantImageUrl;
80
122
  }
81
123
  // Use correct schema.org properties
82
124
  if (variant.color) variantObj.color = variant.color;
83
125
  if (variant.size) variantObj.size = variant.size;
84
126
  if (variant.material) variantObj.material = variant.material;
85
- if (variant.weight) variantObj.weight = variant.weight;
127
+
128
+ // Handle weight as QuantitativeValue
129
+ if (variant.weight) {
130
+ if (typeof variant.weight === 'object' && variant.weight.value && variant.weight.unit) {
131
+ // Already in correct format
132
+ variantObj.weight = {
133
+ "@type": "QuantitativeValue",
134
+ "value": parseFloat(variant.weight.value),
135
+ "unitText": variant.weight.unit.toLowerCase()
136
+ };
137
+ } else if (typeof variant.weight === 'string') {
138
+ // Parse string like "5kg" or "5 kg"
139
+ const match = variant.weight.match(/(\d+(?:\.\d+)?)\s*(kg|g|lb|oz)/i);
140
+ if (match) {
141
+ variantObj.weight = {
142
+ "@type": "QuantitativeValue",
143
+ "value": parseFloat(match[1]),
144
+ "unitText": match[2].toLowerCase()
145
+ };
146
+ } else {
147
+ // Fallback to string if can't parse
148
+ variantObj.weight = variant.weight;
149
+ }
150
+ }
151
+ }
86
152
 
87
153
  // Extract color from variant.choices (Look Fantastic structure) - add as direct property
88
154
  if (!variantObj.color && variant.choices && Array.isArray(variant.choices)) {
@@ -102,19 +168,8 @@ if (hasMultipleVariants) {
102
168
  // Handle non-standard properties as additionalProperty
103
169
  const additionalProperties = [];
104
170
 
105
- if (variant.amount) {
106
- additionalProperties.push({
107
- "@type": "PropertyValue",
108
- "name": "amount",
109
- "value": variant.amount
110
- });
111
- }
112
-
113
171
  // Handle flavour as additionalProperty since it doesn't exist in schema.org
114
- let flavorValue = null;
115
- if (variant.flavor) flavorValue = variant.flavor;
116
- else if (variant.flavour) flavorValue = variant.flavour;
117
- else if (variant.taste) flavorValue = variant.taste;
172
+ const flavorValue = variant.flavor || variant.flavour || variant.taste || null;
118
173
  if (flavorValue) {
119
174
  additionalProperties.push({
120
175
  "@type": "PropertyValue",
@@ -135,7 +190,7 @@ if (hasMultipleVariants) {
135
190
  "availability": variant.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock"
136
191
  };
137
192
 
138
- if (variant?.price?.price?.amount) offerObj.price = variant.price.price.amount;
193
+ if (variant?.price?.price?.amount) offerObj.price = parseFloat(variant.price.price.amount);
139
194
  if (Astro.props.currency) offerObj.priceCurrency = Astro.props.currency;
140
195
 
141
196
  variantObj.offers = offerObj;
@@ -143,6 +198,49 @@ if (hasMultipleVariants) {
143
198
  }
144
199
  });
145
200
  }
201
+
202
+ // Determine what properties vary between variants
203
+ const propertyChecks = [
204
+ { prop: 'weight', url: 'https://schema.org/weight' },
205
+ { prop: 'size', url: 'https://schema.org/size' },
206
+ { prop: 'color', url: 'https://schema.org/color' },
207
+ { prop: 'material', url: 'https://schema.org/material' },
208
+ ];
209
+
210
+ // Check what attributes exist across variants - ensure variants is an array
211
+ // For non-standard properties like amount and flavour, we use additionalProperty
212
+ const variesBy = propertyChecks
213
+ .filter(({ prop }) => Astro.props.variants?.some(v => v?.[prop]))
214
+ .map(({ url }) => url);
215
+
216
+ // 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))) {
218
+ if (!variesBy.includes("https://schema.org/additionalProperty")) {
219
+ variesBy.push("https://schema.org/additionalProperty");
220
+ }
221
+ }
222
+
223
+ // 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) &&
225
+ v.choices.some(c => c.optionKey === 'Shade' || c.optionKey === 'shade' ||
226
+ c.optionKey === 'Colour' || c.optionKey === 'colour' ||
227
+ c.optionKey === 'Color' || c.optionKey === 'color'))) {
228
+ if (!variesBy.includes("https://schema.org/color")) {
229
+ variesBy.push("https://schema.org/color");
230
+ }
231
+ }
232
+
233
+ schema = {
234
+ "@type": "ProductGroup",
235
+ "@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
240
+ };
241
+
242
+ if (variesBy.length > 0) schema.variesBy = variesBy;
243
+
146
244
  } else {
147
245
  // Single variant - create offers array as before
148
246
  if (Array.isArray(Astro.props.variants)) {
@@ -156,169 +254,14 @@ if (hasMultipleVariants) {
156
254
  "availability": variant.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock"
157
255
  };
158
256
 
159
- if (variant?.price?.price?.amount) offerObj.price = variant.price.price.amount;
257
+ if (variant?.price?.price?.amount) offerObj.price = parseFloat(variant.price.price.amount);
160
258
  if (Astro.props.currency) offerObj.priceCurrency = Astro.props.currency;
161
259
 
162
260
  offersArray.push(offerObj);
163
261
  }
164
262
  });
165
263
  }
166
- }
167
-
168
- let reviewsArray = []
169
- if (Astro.props.reviews && Array.isArray(Astro.props.reviews)) {
170
- Astro.props.reviews.forEach((review)=>{
171
- if (review && typeof review === 'object') {
172
- const reviewObj = {
173
- "@type": "Review",
174
- "itemReviewed": {
175
- "@id": Astro.props.sku ? Astro.props.sku.toString() : "",
176
- "name": Astro.props.name || ""
177
- },
178
- "reviewRating": {
179
- "@type": "Rating",
180
- "bestRating": "5"
181
- },
182
- "author": {
183
- "@type": "Person"
184
- }
185
- };
186
-
187
- if (review?.elements?.[0]?.score) reviewObj.reviewRating.ratingValue = review.elements[0].score;
188
- if (review?.authorName) reviewObj.author.name = stripHtml(review.authorName);
189
- if (review?.posted) reviewObj.datePublished = review.posted;
190
- if (review?.elements?.[1]?.value) reviewObj.reviewBody = stripHtml(review.elements[1].value);
191
-
192
- reviewsArray.push(reviewObj);
193
- }
194
- });
195
- }
196
-
197
- let aggregateRating = undefined;
198
- if (Astro.props.reviewsCount > 0) {
199
- aggregateRating = {
200
- "@type": "AggregateRating",
201
- "bestRating": 5,
202
- "worstRating": 1
203
- };
204
-
205
- if (Astro.props.reviewsAverage) aggregateRating.ratingValue = Astro.props.reviewsAverage;
206
- if (Astro.props.reviewsCount) aggregateRating.reviewCount = Astro.props.reviewsCount;
207
- }
208
-
209
- let schema;
210
264
 
211
- if (hasMultipleVariants) {
212
- // Determine what properties vary between variants
213
- const variesBy = [];
214
-
215
- // Check what attributes exist across variants - ensure variants is an array
216
- if (Array.isArray(Astro.props.variants)) {
217
- if (Astro.props.variants.some(v => v && v.weight)) variesBy.push("https://schema.org/weight");
218
- if (Astro.props.variants.some(v => v && v.size)) variesBy.push("https://schema.org/size");
219
- if (Astro.props.variants.some(v => v && v.color)) variesBy.push("https://schema.org/color");
220
- if (Astro.props.variants.some(v => v && v.material)) variesBy.push("https://schema.org/material");
221
-
222
- // For non-standard properties like amount, flavour, and shade, we use additionalProperty
223
- if (Astro.props.variants.some(v => v && v.amount)) variesBy.push("https://schema.org/additionalProperty");
224
-
225
- // Check for flavor with multiple possible property names - treat as additionalProperty
226
- if (Astro.props.variants.some(v => v && (v.flavor || v.flavour || v.taste))) {
227
- if (!variesBy.includes("https://schema.org/additionalProperty")) {
228
- variesBy.push("https://schema.org/additionalProperty");
229
- }
230
- }
231
-
232
- // Check for shade/colour in variant.choices (Look Fantastic structure) - now uses color property
233
- if (Astro.props.variants.some(v => v && v.choices && Array.isArray(v.choices) &&
234
- v.choices.some(c => c.optionKey === 'Shade' || c.optionKey === 'shade' ||
235
- c.optionKey === 'Colour' || c.optionKey === 'colour' ||
236
- c.optionKey === 'Color' || c.optionKey === 'color'))) {
237
- if (!variesBy.includes("https://schema.org/color")) {
238
- variesBy.push("https://schema.org/color");
239
- }
240
- }
241
- }
242
-
243
- schema = {
244
- "@type": "ProductGroup",
245
- "@context": "https://schema.org",
246
- "@id": Astro.props.sku ? Astro.props.sku.toString() : "",
247
- "url": `${url}${Astro.props.url || ""}`,
248
- "productGroupID": Astro.props.sku ? Astro.props.sku.toString() : "",
249
- "hasVariant": variantsArray
250
- };
251
-
252
- if (Astro.props.name) schema.name = Astro.props.name;
253
- if (Astro.props.description) schema.description = Astro.props.description;
254
- if (Astro.props.image && import.meta.env.IMAGE_PROXY_URL) {
255
- schema.image = `${import.meta.env.IMAGE_PROXY_URL}?url=${Astro.props.image}&format=webp&width=1500&height=1500&fit=cover`;
256
- }
257
- if (Astro.props.brand) {
258
- schema.brand = {
259
- "@type": "Brand",
260
- "name": Astro.props.brand
261
- };
262
- }
263
- if (variesBy.length > 0) schema.variesBy = variesBy;
264
-
265
- // Handle custom properties as additionalProperty (schema.org compliant)
266
- const additionalProperties = [];
267
- if (suggestedGender) {
268
- additionalProperties.push({
269
- "@type": "PropertyValue",
270
- "name": "suggestedGender",
271
- "value": suggestedGender
272
- });
273
- }
274
- if (suggestedAge) {
275
- additionalProperties.push({
276
- "@type": "PropertyValue",
277
- "name": "suggestedAge",
278
- "value": suggestedAge
279
- });
280
- }
281
- if (Astro.props.beauty_targetAge) {
282
- additionalProperties.push({
283
- "@type": "PropertyValue",
284
- "name": "targetAge",
285
- "value": Astro.props.beauty_targetAge
286
- });
287
- }
288
- if (Astro.props.gender) {
289
- additionalProperties.push({
290
- "@type": "PropertyValue",
291
- "name": "GenderType",
292
- "value": Astro.props.gender
293
- });
294
- }
295
-
296
- // Add flavour as additionalProperty for ProductGroup level
297
- if (Array.isArray(Astro.props.variants)) {
298
- const flavourValues = Astro.props.variants.map(v => {
299
- if (v && typeof v === 'object') {
300
- return v.flavor || v.flavour || v.taste;
301
- }
302
- return null;
303
- }).filter(Boolean);
304
-
305
- if (flavourValues.length > 0) {
306
- // If multiple unique flavours, list them all; if single flavour, just that one
307
- const uniqueFlavours = [...new Set(flavourValues)];
308
- additionalProperties.push({
309
- "@type": "PropertyValue",
310
- "name": "flavour",
311
- "value": uniqueFlavours.length === 1 ? uniqueFlavours[0] : uniqueFlavours.join(', ')
312
- });
313
- }
314
- }
315
-
316
- if (additionalProperties.length > 0) {
317
- schema.additionalProperty = additionalProperties;
318
- }
319
- if (aggregateRating) schema.aggregateRating = aggregateRating;
320
- if (reviewsArray.length > 0) schema.review = reviewsArray;
321
- } else {
322
265
  // Single product schema as before
323
266
  schema = {
324
267
  "@type": "Product",
@@ -327,77 +270,80 @@ if (hasMultipleVariants) {
327
270
  "sku": Astro.props.sku ? Astro.props.sku.toString() : "",
328
271
  "offers": offersArray
329
272
  };
273
+ }
274
+
275
+ // Extract duplicate code
276
+ if (Astro.props.name) schema.name = Astro.props.name;
277
+ if (Astro.props.description) schema.description = Astro.props.description;
278
+ if (Astro.props.image) {
279
+ schema.image = import.meta.env.IMAGE_PROXY_URL
280
+ ? `${import.meta.env.IMAGE_PROXY_URL}?url=${Astro.props.image}&format=webp&width=1500&height=1500&fit=cover`
281
+ : Astro.props.image;
282
+ }
283
+ if (Astro.props.brand) {
284
+ schema.brand = {
285
+ "@type": "Brand",
286
+ "name": Astro.props.brand
287
+ };
288
+ }
289
+
290
+ // Handle custom properties as additionalProperty (schema.org compliant)
291
+ const additionalProperties: {"@type":string, name:string, value:string}[] = [];
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": "beauty_targetAge",
310
+ "value": Astro.props.beauty_targetAge
311
+ });
312
+ }
313
+ if (Astro.props.gender) {
314
+ additionalProperties.push({
315
+ "@type": "PropertyValue",
316
+ "name": "gender",
317
+ "value": Astro.props.gender
318
+ });
319
+ }
320
+
321
+ // Add flavour as additionalProperty for single product
322
+ if (Array.isArray(Astro.props.variants)) {
323
+ const flavourValues: string[] = 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);
330
329
 
331
- if (Astro.props.name) schema.name = Astro.props.name;
332
- if (Astro.props.description) schema.description = Astro.props.description;
333
- if (Astro.props.image && import.meta.env.IMAGE_PROXY_URL) {
334
- schema.image = `${import.meta.env.IMAGE_PROXY_URL}?url=${Astro.props.image}&format=webp&width=1500&height=1500&fit=cover`;
335
- }
336
- if (Astro.props.brand) {
337
- schema.brand = {
338
- "@type": "Brand",
339
- "name": Astro.props.brand
340
- };
341
- }
342
-
343
- // Handle custom properties as additionalProperty (schema.org compliant)
344
- const additionalProperties = [];
345
- if (suggestedGender) {
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)];
346
333
  additionalProperties.push({
347
334
  "@type": "PropertyValue",
348
- "name": "suggestedGender",
349
- "value": suggestedGender
335
+ "name": "flavour",
336
+ "value": uniqueFlavours.length === 1 ? uniqueFlavours[0] : uniqueFlavours.join(', ')
350
337
  });
351
338
  }
352
- if (suggestedAge) {
353
- additionalProperties.push({
354
- "@type": "PropertyValue",
355
- "name": "suggestedAge",
356
- "value": suggestedAge
357
- });
358
- }
359
- if (Astro.props.beauty_targetAge) {
360
- additionalProperties.push({
361
- "@type": "PropertyValue",
362
- "name": "beauty_targetAge",
363
- "value": Astro.props.beauty_targetAge
364
- });
365
- }
366
- if (Astro.props.gender) {
367
- additionalProperties.push({
368
- "@type": "PropertyValue",
369
- "name": "gender",
370
- "value": Astro.props.gender
371
- });
372
- }
373
-
374
- // Add flavour as additionalProperty for single product
375
- if (Array.isArray(Astro.props.variants)) {
376
- const flavourValues = Astro.props.variants.map(v => {
377
- if (v && typeof v === 'object') {
378
- return v.flavor || v.flavour || v.taste;
379
- }
380
- return null;
381
- }).filter(Boolean);
382
-
383
- if (flavourValues.length > 0) {
384
- // If multiple unique flavours, list them all; if single flavour, just that one
385
- const uniqueFlavours = [...new Set(flavourValues)];
386
- additionalProperties.push({
387
- "@type": "PropertyValue",
388
- "name": "flavour",
389
- "value": uniqueFlavours.length === 1 ? uniqueFlavours[0] : uniqueFlavours.join(', ')
390
- });
391
- }
392
- }
393
-
394
- if (additionalProperties.length > 0) {
395
- schema.additionalProperty = additionalProperties;
396
- }
397
- if (aggregateRating) schema.aggregateRating = aggregateRating;
398
- if (reviewsArray.length > 0) schema.review = reviewsArray;
399
339
  }
400
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
+
401
347
  ---
402
348
 
403
349
  <script type="application/ld+json" set:html={JSON.stringify(schema, (key, value) => {