@nuskin/cs-product-lib 1.0.0-ian-001.1

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/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @nuskin/cs-product-lib
2
+
3
+ Library for pulling and parsing product content (and applying business logic) retrieved from Contentstack.
4
+
5
+ ## Installing
6
+
7
+ Using npm:
8
+
9
+ ```bash
10
+ npm add @nuskin/cs-product-lib
11
+ ```
12
+
13
+ Usng yarn:
14
+
15
+ ```bash
16
+ yarn add @nuskin/cs-product-lib
17
+ ```
18
+
19
+ ## Example usage
20
+
21
+ ```js
22
+ const { tbd } = require('@nuskin/cs-product-lib')
23
+ tbd.doSomething()
24
+
25
+ ```
26
+
27
+ ## Resources
28
+
29
+ - Changelog could go here
30
+
31
+ ## License
32
+
33
+ [MIT](https://opensource.org/licenses/MIT)
34
+
35
+ #### 7. Turning on your CI/CD pipeline
36
+
37
+ Once you are ready for your project to start running the CI/CD pipeline, you should rename the `gitlab-ci.yml`
38
+ to `.gitlab-ci.yml`.
39
+ ```bash
40
+ git mv gitlab-ci.yml .gitlab-ci.yml
41
+ git commit -am"Chore: renaming gitlab-ci.yml to .gitlab-ci.yml so my pipeline runs"
42
+ git push
43
+ ```
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@nuskin/cs-product-lib",
3
+ "version": "1.0.0-ian-001.1",
4
+ "description": "Library for pulling and parsing product content (and applying business logic) retrieved from Contentstack",
5
+ "main": "src/index.js",
6
+ "scripts": {
7
+ "audit": "yarn audit --level high",
8
+ "jsdoc": "node_modules/.bin/jsdoc -a all -c jsdoc.json -r -d jsdocs",
9
+ "lint": "eslint src __tests__",
10
+ "test": "jest --coverage"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git@code.tls.nuskin.io:ns-am/product/js-libs/cs-product-lib.git"
18
+ },
19
+ "keywords": [],
20
+ "author": "Nuskin BFF Team",
21
+ "license": "MIT",
22
+ "homepage": "https://code.tls.nuskin.io/ns-am/product/js-libs/cs-product-lib/blob/master/README.md",
23
+ "devDependencies": {
24
+ "@nuskin/docdash": "1.0.1",
25
+ "eslint": "7.21.0",
26
+ "eslint-config-google": "0.14.0",
27
+ "eslint-config-prettier": "8.1.0",
28
+ "eslint-plugin-json": "2.1.2",
29
+ "eslint-plugin-prettier": "3.3.1",
30
+ "jest": "29.4.1",
31
+ "jest-sonar-reporter": "2.0.0",
32
+ "jsdoc": "3.6.6",
33
+ "prettier": "2.2.1"
34
+ },
35
+ "files": [
36
+ "src/"
37
+ ],
38
+ "dependencies": {
39
+ "@contentstack/json-rte-serializer": "2.0.1",
40
+ "@nuskin/ns-common-lib": "1.4.5",
41
+ "axios": "1.3.0",
42
+ "axios-retry": "3.4.0",
43
+ "entities": "4.4.0",
44
+ "lodash": "4.17.21",
45
+ "turndown": "7.1.1"
46
+ }
47
+ }
@@ -0,0 +1,187 @@
1
+ "use strict"
2
+
3
+ const axios = require('axios');
4
+ const axiosRetry = require('axios-retry');
5
+ const {arrayString} = require('./helpers')
6
+
7
+ const csProduct = (config) => {
8
+ let apiKey = ''
9
+ let environment = ''
10
+ let deliveryToken = ''
11
+
12
+ // Query Constants (includes, etc.)
13
+ const DEV_ENVIRONMENT = 'public-dev';
14
+ const CONTENT_STACK_DELIVERY_URL = 'https://cdn.contentstack.io/v3/';
15
+ const VARIANTS = 'variants';
16
+ const KEY_INGREDIENTS = 'ingredients.key_ingredients';
17
+ const PRODUCT_HIGHLIGHTS = 'product_details.highlights';
18
+ const VARIANT_HIGHLIGHTS = 'variants.product_details.highlights';
19
+ const VARIANT_KEY_INGREDIENTS = 'variants.ingredients_override.key_ingredients';
20
+ const PRODUCT_FAQS = 'faqs';
21
+ const VARIANT_FAQS = 'variants.faqs';
22
+ const PRODUCT_RESOURCES = 'resources';
23
+ const VARIANT_RESOURCES = 'variants.resources';
24
+ const PRODUCT_CATEGORY = 'categories';
25
+ const PRODUCT_SUBCATEGORIES = 'categories.subcategories';
26
+ const DEFAULT_LOCALE = 'en';
27
+ const PRODUCT_TYPE = 'product';
28
+
29
+ // eslint-disable-next-line max-len
30
+ const productIncludes = `&include[]=${VARIANTS}&include[]=${PRODUCT_FAQS}&include[]=${KEY_INGREDIENTS}&include[]=${PRODUCT_HIGHLIGHTS}&include[]=${PRODUCT_RESOURCES}&include[]=${VARIANT_KEY_INGREDIENTS}&include[]=${VARIANT_HIGHLIGHTS}&include[]=${VARIANT_FAQS}&include[]=${VARIANT_RESOURCES}&include[]=${PRODUCT_CATEGORY}&include[]=${PRODUCT_SUBCATEGORIES}`;
31
+
32
+ if (!config) {
33
+ throw new Error(`This will not work without a valid configuration {apiKey, environment, deliveryToken}.`)
34
+ } else {
35
+ apiKey = config.apiKey
36
+ environment = config.environment
37
+ deliveryToken = config.deliveryToken
38
+ }
39
+
40
+ const deliveryAxios = axios.create({
41
+ baseURL: CONTENT_STACK_DELIVERY_URL,
42
+ timeout: 3000,
43
+ headers: {
44
+ api_key: apiKey,
45
+ access_token: deliveryToken
46
+ }
47
+ });
48
+ axiosRetry(deliveryAxios, {
49
+ retries: 3,
50
+ shouldResetTimeout: true,
51
+ // eslint-disable-next-line no-unused-vars
52
+ retryCondition: (_error) => true
53
+ });
54
+
55
+ const asObject = () => {
56
+ return {apiKey, environment, deliveryToken}
57
+ }
58
+
59
+ const getProductsBySlugs = async ({
60
+ slugs = [], locale = DEFAULT_LOCALE
61
+ }) => {
62
+ if (!Array.isArray(slugs) || slugs.length < 1) throw new Error(`Invalid Slugs: ${ slugs}`);
63
+ // eslint-disable-next-line max-len
64
+ let url = `content_types/${PRODUCT_TYPE}/entries?environment=${environment}&locale=${locale}&include_count=true&query={"seo_information.slug": {"$in": ${arrayString(slugs)}}}`;
65
+ url += productIncludes
66
+ if (environment === DEV_ENVIRONMENT){
67
+ url += '&include_fallback=true'
68
+ }
69
+ try {
70
+ const res = await deliveryAxios.get(url);
71
+ return res && res.data && res.data.entries ? res.data.entries : res;
72
+ } catch (error) {
73
+ // log.error(error.response);
74
+ return error;
75
+ }
76
+ }
77
+
78
+ const getProductsByQuery = async ({
79
+ query, locale = DEFAULT_LOCALE
80
+ }) => {
81
+ if (!query) throw new Error(`Invalid Query: ${ query }`);
82
+ // eslint-disable-next-line max-len
83
+ let url = `content_types/${PRODUCT_TYPE}/entries?environment=${environment}&locale=${locale}&include_count=true&query=${query}`
84
+ url += productIncludes
85
+ if (environment === DEV_ENVIRONMENT){
86
+ url += '&include_fallback=true'
87
+ }
88
+ try {
89
+ const res = await deliveryAxios.get(url);
90
+ return res && res.data && res.data.entries ? res.data.entries : res;
91
+ } catch (error) {
92
+ // log.error(error.response);
93
+ return error;
94
+ }
95
+ }
96
+
97
+ // includes is an array of all the referenced types in the contentType you are requesting that should be resolved
98
+ // and returned (Example: ['common_strings'])
99
+ const getEntriesByQuery = async ({
100
+ contentType = PRODUCT_TYPE, query = '', locale = DEFAULT_LOCALE, includes, start = 0, limit=50, onlyEntries=true
101
+ }) => {
102
+ // eslint-disable-next-line max-len
103
+ let url = `content_types/${contentType}/entries?environment=${environment}&locale=${locale}&include_count=true&limit=${limit}&query=${query}`
104
+ if (contentType === PRODUCT_TYPE) {
105
+ url += productIncludes
106
+ }
107
+ if (includes) {
108
+ for (const include of includes) {
109
+ url += `&include[]=${include}`
110
+ }
111
+ }
112
+ if (environment === DEV_ENVIRONMENT){
113
+ url += '&include_fallback=true'
114
+ }
115
+ if (start > 0) {
116
+ url += `&skip=${start}`
117
+ }
118
+ try {
119
+ const res = await deliveryAxios(url);
120
+ if (onlyEntries) {
121
+ return res && res.data && res.data.entries ? res.data.entries : res;
122
+ }
123
+ return res && res.data && res.data.entries ? res.data : res;
124
+ } catch (error) {
125
+ // log.error(error.response);
126
+ return error;
127
+ }
128
+ }
129
+
130
+ const queryProducts = async(query, locale) => {
131
+ const products = await getEntriesByQuery({query, locale})
132
+ return products
133
+ }
134
+
135
+ const getProductsBySku = async (skus, locale) => {
136
+ if (!Array.isArray(skus)) {
137
+ skus = [skus]
138
+ }
139
+ const query = `{"variants": {"$in_query":{"sap_data.skus": {"$in": ${arrayString([skus])}}}}}`
140
+ return await queryProducts(query, locale);
141
+ }
142
+
143
+ const getProductsByGlobalId = async (ids, locale) => {
144
+ if (!Array.isArray(ids)) {
145
+ ids = [ids]
146
+ }
147
+ const query = `{"variants": {"$in_query":{"sap_data.global_id": {"$in": ${arrayString([ids])}}}}}`
148
+ return await queryProducts(query, locale)
149
+ }
150
+
151
+ const getProductsBySlug = async (slugs, locale) => {
152
+ if (!Array.isArray(slugs)) {
153
+ slugs = [slugs]
154
+ }
155
+ const query = `{"seo_information.slug": {"$in": ${arrayString(slugs)}}}`
156
+ return await queryProducts(query, locale)
157
+ }
158
+
159
+ const getProductsByUID = async (ids, locale) => {
160
+ if (!Array.isArray(ids)) {
161
+ ids = [ids]
162
+ }
163
+ const query = `{"uid": {"$in": ${arrayString(ids)}}}`
164
+ return await queryProducts(query, locale)
165
+ }
166
+
167
+ const getTranslations = async (locale) => {
168
+ const translations = await getEntriesByQuery({contentType: 'strings_product_detail_page', locale, includes: ['common_strings'] })
169
+ return translations
170
+ }
171
+
172
+ return {
173
+ asObject,
174
+ getEntriesByQuery,
175
+ getProductsBySku,
176
+ getProductsByGlobalId,
177
+ getProductsBySlug,
178
+ getProductsBySlugs,
179
+ getProductsByQuery,
180
+ getProductsByUID,
181
+ getTranslations
182
+ }
183
+ }
184
+
185
+ module.exports = {
186
+ csProduct
187
+ }
package/src/helpers.js ADDED
@@ -0,0 +1,558 @@
1
+ 'use strict';
2
+
3
+ const { stripHtml } = require('@nuskin/ns-common-lib');
4
+ const TurndownService = require('turndown')
5
+ const {jsonToHtml} = require('@contentstack/json-rte-serializer')
6
+ const entities = require('entities');
7
+ const template = require('lodash/template')
8
+ const templateSettings = require('lodash/templateSettings')
9
+
10
+ function isIterable(obj) {
11
+ // checks for null and undefined
12
+ if (obj == null) {
13
+ return false;
14
+ }
15
+ return typeof obj[Symbol.iterator] === 'function';
16
+ }
17
+
18
+ function decodeHTML(decodeMe) {
19
+ if (Array.isArray(decodeMe)) {
20
+ for (const item of decodeMe) {
21
+ entities.decodeHTML(item);
22
+ }
23
+ } else if (typeof(decodeMe) === 'string') {
24
+ return entities.decodeHTML(decodeMe);
25
+ }
26
+ return decodeMe;
27
+ }
28
+
29
+ function rteHasContent(rteField) {
30
+ const markdown = createMarkdownFromRTE(rteField);
31
+ return !!markdown;
32
+ }
33
+
34
+ function mapSubscriptions(subscriptions) {
35
+ templateSettings.interpolate = /{([\s\S]+?)}/g;
36
+
37
+ const [{content = [], footer} = {}] = subscriptions?.modal || []
38
+ const parseContent = content.map(({_metadata : {uid}, ...props}) => ({
39
+ uid, ...props
40
+ }))
41
+
42
+ const buildLink = ({href = '#', title = ''} = {}) => `<a href='${href}' target='blank'>${title}</a>`
43
+
44
+ const footerTemplate = template(footer?.content_text)
45
+ const parseFooter = footerTemplate({
46
+ terms_conditions: buildLink(footer?.terms_conditions),
47
+ return_policy: buildLink(footer?.return_policy),
48
+ faq: buildLink(footer?.faq)
49
+ })
50
+
51
+ return {
52
+ message : subscriptions?.message,
53
+ triggerText: subscriptions?.trigger_text,
54
+ modal : {
55
+ content: parseContent,
56
+ footer: htmlToMarkdown(parseFooter)
57
+ }
58
+ }
59
+ }
60
+
61
+ function getBestSkuData(variant) {
62
+ if (variant.sap_data && variant.sap_data.sku_data && variant.sap_data.sku_data.length > 0) {
63
+ if (variant.sap_data.sku_data.length > 1) {
64
+ let bestSKUData = variant.sap_data.sku_data.find(s=>s.status === 10);
65
+ bestSKUData = variant.sap_data.sku_data.find(s=>(s.status_selections || {}).status === 'sellable') || bestSKUData;
66
+ if (!bestSKUData) {
67
+ bestSKUData = variant.sap_data.sku_data.find(s=>s.status === 9);
68
+ bestSKUData = variant.sap_data.sku_data.find(s=>(s.status_selections || {}).status === 'inactive') || bestSKUData;
69
+ if (!bestSKUData) {
70
+ bestSKUData = variant.sap_data.sku_data.find(s=>s.status !== 2);
71
+ bestSKUData = variant.sap_data.sku_data.find(s=>(s.status_selections || {}).status !== 'discontinued') || bestSKUData;
72
+ }
73
+ if (!bestSKUData) {
74
+ bestSKUData = variant.sap_data.sku_data[0];
75
+ }
76
+ }
77
+ return bestSKUData;
78
+ }
79
+ return variant.sap_data.sku_data[0] || {};
80
+ }
81
+ else if (variant.sap_data && Array.isArray(variant.sap_data.sku)) {
82
+ if (variant.sap_data.sku.length > 1) {
83
+ let bestSKUData = variant.sap_data.sku.find(s=>s.retail_status === 10);
84
+ if (!bestSKUData) {
85
+ bestSKUData = variant.sap_data.sku.find(s=>s.retail_status === 9);
86
+ if (!bestSKUData) {
87
+ bestSKUData = variant.sap_data.sku.find(s=>s.retail_status !== 2);
88
+ }
89
+ }
90
+ bestSKUData.status = bestSKUData.retail_status;
91
+ return bestSKUData;
92
+ }
93
+ if (variant.sap_data.sku[0]) {
94
+ variant.sap_data.sku[0].status = variant.sap_data.sku[0].retail_status;
95
+ }
96
+ return variant.sap_data.sku[0] || {};
97
+ } else if (variant.sap_data && variant.sap_data.sku) {
98
+ variant.sap_data.sku.status = variant.sap_data.sku.retail_status;
99
+ return variant.sap_data.sku;
100
+ }
101
+ return {};
102
+ }
103
+
104
+ function mapCsStatus(variant) {
105
+ const bestSKU = getBestSkuData(variant);
106
+ const status = {isBackordered: false, backorderedAvailableDate: null};
107
+
108
+ if (bestSKU.status_selections) {
109
+ status.status = status.retail = status.wholesale = bestSKU.status_selections.status;
110
+ return status;
111
+ }
112
+
113
+ switch (bestSKU.status) {
114
+ case 10:
115
+ status.status = status.retail = status.wholesale = 'sellable';
116
+ break;
117
+ case 9:
118
+ status.status = status.retail = status.wholesale = 'inactive';
119
+ break;
120
+ case 5:
121
+ status.status = status.retail = status.wholesale = 'stopped';
122
+ break;
123
+ case 4:
124
+ status.status = status.retail = status.wholesale = 'bundleOnly';
125
+ break;
126
+ case 2:
127
+ status.status = status.retail = status.wholesale = 'discontinued';
128
+ break;
129
+ default:
130
+ status.status = status.retail = status.wholesale = 'inactive';
131
+ break;
132
+ }
133
+ return status;
134
+ }
135
+
136
+ function mapImage(image) {
137
+ if (Array.isArray(image)) {
138
+ image = image[0];
139
+ }
140
+ if ((image || {}).type) {
141
+ if (image.type === 'VIDEO') {
142
+ const derivatives = image.derivatives || {};
143
+ if ((image.previewUrls || []).length > 0) {
144
+ return {url: image.previewUrls[0], alt: image.description || image.name || '', thumbnail: derivatives.thumbnail};
145
+ }
146
+ return {url: derivatives.webImage, alt: image.description || image.name || '', thumbnail: derivatives.thumbnail};
147
+ }
148
+ }
149
+ if ((image || {}).derivatives) {
150
+ return {url: (image.derivatives || {}).webImage, alt: image.description || image.name || '', thumbnail: (image.derivatives || {}).thumbnail};
151
+ } else if ((image || {}).files) {
152
+ return {url: (image.files.webImage || {}).url, alt: image.description || image.name || '', thumbnail: (image.files.thumbnail || {}).url};
153
+ }
154
+ return (image || {}).url ? { url: image.url, alt: image.description || image.title || '', thumbnail: image.url } : null;
155
+ }
156
+
157
+ function mapLink(link) {
158
+ return (link || {}).href ? { url: link.href, text: link.title || '' } : null;
159
+ }
160
+
161
+ function mapProductDetails(productOrVariant) {
162
+ const productDetails = (productOrVariant || {}).product_details || {};
163
+ if ((productOrVariant.long_description_override || []).length > 0) {
164
+ productDetails.long_description = productOrVariant.long_description_override;
165
+ }
166
+ if ((productOrVariant.included_items_override || []).length > 0) {
167
+ productDetails.included_items = productOrVariant.included_items_override;
168
+ }
169
+ let description = productDetails.long_description;
170
+ if (!Array.isArray(description)) {
171
+ description = createMarkdown(description && description.type ? description : null, description);
172
+ description = description.type || description === '' || description === 'undefined' || description === null ? [] : [description];
173
+ }
174
+ const includedItems = productDetails.included_items;
175
+ const highlights = (productDetails.highlights || []).map(highlight => ({
176
+ iconUrl: (highlight.icon || {}).url,
177
+ label: highlight.text
178
+ }));
179
+ const originCountry = productDetails.origin_country;
180
+ const importer = productDetails.importer;
181
+ const warnings = productDetails.warnings;
182
+ return {
183
+ description: decodeHTML(description),
184
+ includedItems: decodeHTML(includedItems), highlights, originCountry, importer, warnings
185
+ }
186
+ }
187
+
188
+ function featureImage(feature) {
189
+ let image = Array.isArray(feature.image) ? feature.image[0] : feature.image;
190
+ if(Array.isArray(feature.image_bynder) && feature.image_bynder.length > 0) {
191
+ image = feature.image_bynder;
192
+ }
193
+ return image;
194
+ }
195
+
196
+ function mapFeatures(productOrVariant) {
197
+ return ((productOrVariant || {}).features || []).map((feature = {}) => ({
198
+ // eslint-disable-next-line no-nested-ternary
199
+ image: mapImage(featureImage(feature)),
200
+ subtitle: feature.header,
201
+ features: feature.features,
202
+ backgroundColor: feature.background_color
203
+ }))
204
+ }
205
+
206
+ function benefitsToMarkdown(legacyBenefits, jsonBenefits) {
207
+ let benefits = '';
208
+ if (legacyBenefits.length > 0) {
209
+ const leg = legacyBenefits.map(benefit => ({
210
+ title: benefit.header,
211
+ benefits: benefit.benefits || []
212
+ }));
213
+ for (let i = 0; i < leg.length; i++) {
214
+ if (leg[i].title) {
215
+ benefits += `**${leg[i].title.trim().replace(/\*/g, '\\*')}**\n`;
216
+ }
217
+ for (const benefit of leg[i].benefits) {
218
+ benefits += `\n* ${benefit.trim().replace(/\*/g, '\\*')}`;
219
+ }
220
+ if (i < leg.length-1) {
221
+ benefits += '\n\n';
222
+ }
223
+ }
224
+ }
225
+ if (jsonBenefits && rteHasContent(jsonBenefits)) {
226
+ benefits = createMarkdown(jsonBenefits);
227
+ }
228
+ return decodeHTML(benefits);
229
+ }
230
+
231
+ function mapBenefits(productOrVariant) {
232
+ let benefitsMedia = (productOrVariant || {}).benefits_media || {};
233
+ const legacyBenefits = Array.isArray((productOrVariant || {}).benefits) ? productOrVariant.benefits : [];
234
+ if ((productOrVariant || {}).benefits && (productOrVariant.benefits.image || productOrVariant.benefits.youtube_video_id)) {
235
+ benefitsMedia = {
236
+ image: Array.isArray(productOrVariant.benefits.image) ? productOrVariant.benefits.image[0] : productOrVariant.benefits.image,
237
+ youTubeVideoId: productOrVariant.benefits.youtube_video_id
238
+ }
239
+ }
240
+ // Account for new section in Product
241
+ if ((productOrVariant || {}).benefits_and_key_features && rteHasContent((productOrVariant || {}).benefits_and_key_features) && (productOrVariant.benefits_and_key_features.image || productOrVariant.benefits_and_key_features.youtube_video_id)) {
242
+ benefitsMedia = {
243
+ image: Array.isArray(productOrVariant.benefits_and_key_features.image) ? productOrVariant.benefits_and_key_features.image[0] : productOrVariant.benefits_and_key_features.image,
244
+ youTubeVideoId: productOrVariant.benefits_and_key_features.youtube_video_id
245
+ }
246
+ }
247
+ let jsonBenefits = (productOrVariant || {}).benefits &&
248
+ productOrVariant.benefits.benefits &&
249
+ productOrVariant.benefits.benefits.type ? productOrVariant.benefits.benefits : null;
250
+ // Account for new section in Product
251
+ jsonBenefits= (productOrVariant || {}).benefits_and_key_features &&
252
+ productOrVariant.benefits_and_key_features.benefits ? productOrVariant.benefits_and_key_features.benefits : jsonBenefits;
253
+
254
+ return {
255
+ image: mapImage(benefitsMedia.image),
256
+ youTubeVideoId: benefitsMedia.youTubeVideoId,
257
+ benefits: benefitsToMarkdown(legacyBenefits, jsonBenefits)
258
+ }
259
+ }
260
+
261
+ function mapResults(productOrVariant) {
262
+ const results = (productOrVariant || {}).results || {};
263
+ const summary = results.summary ? [results.summary] : [];
264
+ return {
265
+ summary: Array.isArray(results.summary) ? results.summary : summary,
266
+ results: (results.results || []).map(result => ({
267
+ percentage: result.number,
268
+ text: decodeHTML(result.description)
269
+ })),
270
+ report: {
271
+ url: results.report_url,
272
+ text: results.report_text
273
+ },
274
+ image: mapImage((Array.isArray(results.image_or_video) && results.image_or_video.length > 0) || results.image),
275
+ youTubeVideoId: results.youtube_video_id
276
+ }
277
+ }
278
+
279
+ function mapSustainability(product) {
280
+ const sustainability = (product || {}).sustainability || {};
281
+ const youTubeVideoId = sustainability.youtube_video;
282
+ const image = mapImage(Array.isArray(sustainability.image) ? sustainability.image[0] : sustainability.image);
283
+ const richText = sustainability.description;
284
+ const description = hasRichTextContent(richText) ? createMarkdown(richText) : null;
285
+ const highlights = (sustainability.highlights || []).map(highlight => ({
286
+ image: mapImage((Array.isArray(highlight.image) ? highlight.image[0] : highlight.image)),
287
+ description: hasRichTextContent(highlight.description) ? createMarkdown(highlight.description) : null
288
+ }));
289
+ return {
290
+ youTubeVideoId,
291
+ image,
292
+ description,
293
+ highlights
294
+ }
295
+ }
296
+
297
+ function mapUsage(productOrVariant, legacyUsage) {
298
+ const usage = (productOrVariant || {}).usage || {};
299
+ return {
300
+ video: {
301
+ url: usage.video_url,
302
+ posterUrl: usage.video_thumbnail_url
303
+ },
304
+ steps: decodeHTML(usage.steps),
305
+ recommendations: decodeHTML(usage.recommendations),
306
+ warnings: decodeHTML(usage.warnings),
307
+ additionalText: usage.additional_text,
308
+ image: mapImage(usage.image_or_video || usage.image),
309
+ youTubeVideoId: usage.youtube_video_id,
310
+ markdown: legacyUsage ? htmlToMarkdown(legacyUsage) : null
311
+ }
312
+ }
313
+
314
+ function mapResource(resource) {
315
+ if (resource.bynder_document && resource.bynder_document.length > 0) {
316
+ const bynder = resource.bynder_document[0];
317
+ return {
318
+ title: bynder.name || bynder.description || '',
319
+ url: (bynder.derivatives || {}).original,
320
+ image: {
321
+ url: (bynder.derivatives || {}).thumbnail,
322
+ alt: (bynder.meta || {}).SEO_Tags || bynder.description || bynder.name || resource.title || ''
323
+ }
324
+ }
325
+ }
326
+
327
+ return {
328
+ title: (resource.download_link || {}).title,
329
+ url: (resource.download_link || {}).href,
330
+ image: {
331
+ url: (resource.link_image || {}).url,
332
+ alt: (resource.link_image || {}).title
333
+ }
334
+ }
335
+ }
336
+
337
+ function mapResources(productOrVariant) {
338
+ if (productOrVariant.resources && Array.isArray(productOrVariant.resources) && productOrVariant.resources.length > 0) {
339
+ return productOrVariant.resources.map(resource => (
340
+ mapResource(resource)
341
+ ));
342
+ }
343
+ try {
344
+ const legacyResources = JSON.parse((productOrVariant.legacy_resources || '[]'));
345
+ return (legacyResources || []).map(resource => ({
346
+ title: resource.title,
347
+ url: resource.url,
348
+ image: {
349
+ url: (resource.link_image || {}).url,
350
+ alt: (resource.link_image || {}).title
351
+ }
352
+ }));
353
+ } catch(error) {
354
+ return [];
355
+ }
356
+ }
357
+
358
+ function mapIngredients(ings, legacyIngredients) {
359
+ if (Array.isArray(ings)) {
360
+ return ings.map(ingredients => ({
361
+ keyIngredients: ((ingredients || {}).key_ingredients || []).map(keyIngredient => ({
362
+ image: {url:(keyIngredient.icon || {}).url, alt:(keyIngredient.icon || {}).title},
363
+ name: keyIngredient.header,
364
+ description: keyIngredient.description
365
+ })),
366
+ nutritionInformationImage: (ingredients || {}).nutrition_information_image
367
+ ? { url: ingredients.nutrition_information_image.url, alt: ingredients.nutrition_information_image.title }
368
+ : null,
369
+ productName: (ingredients || {}).product_name,
370
+ allIngredients: decodeHTML((ingredients || {}).all_ingredients),
371
+ otherIngredients: (ingredients || {}).other_ingredients,
372
+ activeIngredients: (ingredients || {}).active_ingredients,
373
+ inactiveIngredients: (ingredients || {}).inactive_ingredients,
374
+ ingredientDisclaimers: (ingredients || {}).ingredient_disclaimers || [],
375
+ markdown: legacyIngredients ? htmlToMarkdown(legacyIngredients) : null
376
+ }))
377
+ }
378
+
379
+ const keyIngredients = (ings || {}).key_ingredients || [];
380
+ if (keyIngredients.length > 0) {
381
+ return [{
382
+ keyIngredients: keyIngredients.map(keyIngredient => ({
383
+ image: {url:(keyIngredient.icon || {}).url, alt:(keyIngredient.icon || {}).title},
384
+ name: keyIngredient.header,
385
+ description: keyIngredient.description
386
+ })),
387
+ nutritionInformationImage: (ings || {}).nutrition_information_image
388
+ ? { url: ings.nutrition_information_image.url, alt: ings.nutrition_information_image.title }
389
+ : null,
390
+ allIngredients: decodeHTML((ings || {}).all_ingredients),
391
+ otherIngredients: (ings || {}).other_ingredients,
392
+ activeIngredients: (ings || {}).active_ingredients,
393
+ inactiveIngredients: (ings || {}).inactive_ingredients,
394
+ ingredientDisclaimers: (ings || {}).ingredient_disclaimers || [],
395
+ markdown: legacyIngredients ? htmlToMarkdown(legacyIngredients) : null
396
+ }]
397
+ }
398
+ return [];
399
+ }
400
+
401
+ function mapSalesDisclaimer(salesDisclaimer) {
402
+ salesDisclaimer = salesDisclaimer || {};
403
+ const text = stripHtml(salesDisclaimer.text || '');
404
+ const richText = salesDisclaimer.rich_text;
405
+ if (!text && !hasRichTextContent(richText)) {
406
+ return null;
407
+ }
408
+ const boldText = salesDisclaimer.bold_text || '';
409
+ const link = mapLink(salesDisclaimer.link);
410
+ const markdownText = createMarkdown(richText, text, boldText, link);
411
+ return {
412
+ text,
413
+ boldText,
414
+ link,
415
+ markdownText
416
+ };
417
+ }
418
+
419
+ function mapDisclaimerWarnings(disclaimerWarnings) {
420
+ disclaimerWarnings = disclaimerWarnings || [];
421
+ return disclaimerWarnings
422
+ .map((disclaimerWarning) => {
423
+ const text = stripHtml(disclaimerWarning.text || '');
424
+ const richText = disclaimerWarning.rich_text;
425
+ if (!text && !hasRichTextContent(richText)) {
426
+ return null;
427
+ }
428
+ const icon = mapImage(disclaimerWarning.icon);
429
+ const boldText = disclaimerWarning.bold_text || '';
430
+ const link = mapLink(disclaimerWarning.link);
431
+ const markdownText = createMarkdown(richText, text, boldText, link);
432
+ return {
433
+ icon,
434
+ text,
435
+ boldText,
436
+ link,
437
+ markdownText
438
+ };
439
+ })
440
+ .filter((disclaimerWarning) => !!disclaimerWarning);
441
+ }
442
+
443
+ function hasRichTextContent(richText) {
444
+ // when cleared, the rich text json still contains children, this checks for any text or links
445
+ return ((richText || {}).children || []).some((line) =>
446
+ (line.children || []).some(
447
+ (block) => (!!block.text && block.text !=='This block is deleted,Remove this placeholder') || (block.children || []).some((part) => (!!part.text && part.text !== 'This block is deleted,Remove this placeholder'))
448
+ )
449
+ );
450
+ }
451
+
452
+ function createMarkdownWithModifiers(text, bold, italic) {
453
+ let modifier = '';
454
+ if(bold) {
455
+ modifier += '**';
456
+ }
457
+ if(italic){
458
+ modifier += '_';
459
+ }
460
+ return `${modifier}${text}${modifier}`;
461
+ }
462
+
463
+ function createMarkdownFromRichText(richText) {
464
+ let markdown = '';
465
+ for (const [index, line] of ((richText || {}).children || []).entries()) {
466
+ if (index > 0) {
467
+ markdown += '\n';
468
+ }
469
+ for (const block of line.children) {
470
+ if (block.type === 'a') {
471
+ const url = (block.attrs || {}).url || '';
472
+ for (const part of block.children) {
473
+ const linkMarkdown = `[${part.text}](${url})`;
474
+ markdown += createMarkdownWithModifiers(linkMarkdown, part.bold, part.italic);
475
+ }
476
+ } else if (block.text) {
477
+ markdown += createMarkdownWithModifiers(block.text, block.bold, block.italic);
478
+ }
479
+ }
480
+ }
481
+ return stripHtml(markdown);
482
+ }
483
+
484
+ function createMarkdownFromRTE(richText) {
485
+ if (!richText) {
486
+ return null;
487
+ }
488
+ const turndownService = new TurndownService();
489
+ const htmlVal = jsonToHtml(richText);
490
+ const markdown = turndownService.turndown(htmlVal);
491
+ if (markdown === 'This block is deleted,Remove this placeholder') {
492
+ return null
493
+ }
494
+ return stripHtml(markdown);
495
+ }
496
+
497
+ function htmlToMarkdown(html) {
498
+ const turndownService = new TurndownService();
499
+ const markdown = turndownService.turndown(html);
500
+ return stripHtml(markdown);
501
+ }
502
+
503
+ function createMarkdownFromReplacement(text, boldText, link) {
504
+ let markdown = text || '';
505
+ if (boldText) {
506
+ markdown = markdown.replace('%boldText%', `**${boldText}**`);
507
+ }
508
+ if (link && link.text && link.url) {
509
+ markdown = markdown.replace('%linkText%', `[${link.text}](${link.url})`);
510
+ }
511
+ return markdown;
512
+ }
513
+
514
+ function createMarkdown(richText, text, boldText, link) {
515
+ if (hasRichTextContent(richText)) {
516
+ return createMarkdownFromRTE(richText);
517
+ }
518
+
519
+ return createMarkdownFromReplacement(text, boldText, link);
520
+ }
521
+
522
+ const arrayString = (array) => {
523
+ let retString = '['
524
+ if (array[0]) {
525
+ retString += `"${ array[0] }"`;
526
+ }
527
+ for (let index = 1; index < array.length; index++) {
528
+ retString += `, "${ array[index] }"`;
529
+ }
530
+ retString += ']'
531
+ return retString
532
+ }
533
+
534
+ module.exports = {
535
+ getBestSkuData,
536
+ mapImage,
537
+ mapLink,
538
+ mapFeatures,
539
+ mapProductDetails,
540
+ mapSalesDisclaimer,
541
+ mapDisclaimerWarnings,
542
+ mapBenefits,
543
+ mapResults,
544
+ mapSustainability,
545
+ mapUsage,
546
+ mapResources,
547
+ mapIngredients,
548
+ mapCsStatus,
549
+ hasRichTextContent,
550
+ createMarkdownWithModifiers,
551
+ createMarkdownFromRichText,
552
+ createMarkdownFromReplacement,
553
+ createMarkdown,
554
+ isIterable,
555
+ rteHasContent,
556
+ mapSubscriptions,
557
+ arrayString
558
+ };
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ 'use strict'
2
+
3
+ const ContentStackProduct = require("./csProduct")
4
+
5
+ module.exports = {
6
+ ContentStackProduct
7
+ }
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+
3
+ // WARNING: Set to true if running locally to avoid Redis, don't forget to change back
4
+ // const localDev = false;
5
+
6
+ /**
7
+ * @typedef { import("aws-lambda") } AWSLambda
8
+ */
9
+
10
+ const axios = require('axios');
11
+ const axiosRetry = require('axios-retry');
12
+
13
+ axiosRetry(axios, { retries: 1 });
14
+
15
+ const config = require('config');
16
+
17
+ const MarketRegionMappings = require('./resources/marketRegionMappings');
18
+
19
+ const { isNullOrEmpty, getLocaleInfo } = require('@nuskin/ns-common-lib');
20
+
21
+ /**
22
+ * Gets a list of skus.
23
+ * @param {string[]} skus the skus to be retrieved
24
+ * @param {string} locale the country code and language (ex: en_US)
25
+ */
26
+ async function getProductStatus(skus, locale) {
27
+ validateRequest(skus, locale);
28
+
29
+ const localeInfo = getLocaleInfo(locale);
30
+
31
+ // get market configuration from AEM
32
+ const marketConfig = await getMarketConfig(localeInfo);
33
+
34
+ // if (products.length) {
35
+ // get product status data for each product
36
+ const productStatusResponse = await getProductStatusResponse(
37
+ marketConfig,
38
+ localeInfo,
39
+ skus
40
+ );
41
+ if (productStatusResponse && productStatusResponse.items) {
42
+ for (let i = 0; i < skus.length; i++) {
43
+ if (productStatusResponse.items[skus[i]]) {
44
+ const productStatus = productStatusResponse.items[skus[i]];
45
+ const overrideMaxQuantity = marketConfig.skuMaxQty ? marketConfig.skuMaxQty[skus[i]] : undefined;
46
+ const availableQuantity = productStatus.availableQuantity < 999 ? productStatus.availableQuantity : 999;
47
+ productStatus.maxQuantity = overrideMaxQuantity || availableQuantity;
48
+ productStatus.availableQuantity = availableQuantity;
49
+ }
50
+ }
51
+ }
52
+ return productStatusResponse && productStatusResponse.items || {};
53
+ }
54
+
55
+ /**
56
+ * Validates the post request paramaters are acceptible
57
+ * @param {string[]} skus
58
+ * @param {string} locale
59
+ */
60
+ function validateRequest(skus, locale) {
61
+ const errorMessages = [];
62
+ if (!skus || !Array.isArray(skus) || skus.length === 0) {
63
+ errorMessages.push("Required parameter 'skus' is invalid.");
64
+ }
65
+ if (!locale || !locale.toString().match(/^[a-z]{2}_[A-Za-z]{2}$/)) {
66
+ errorMessages.push("Required parameter 'locale' is invalid.");
67
+ }
68
+ if (errorMessages.length) {
69
+ // eslint-disable-next-line no-throw-literal
70
+ throw {
71
+ status: 400,
72
+ message: `Bad Request. ${errorMessages.join(' ')}`
73
+ };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Get AEM market configuration
79
+ * @param {{language: string, countryCode: string}} localeInfo
80
+ */
81
+ async function getMarketConfig(localeInfo) {
82
+ const siteHome = config.properties.home;
83
+ const region = MarketRegionMappings[localeInfo.countryCode];
84
+ const marketConfigUrl = `${siteHome}/content/configuration/global/markets/${region}.service.${localeInfo.countryCode}.json`;
85
+
86
+ let marketConfigResponse = null;
87
+ let marketConfig;
88
+ try {
89
+ if (isNullOrEmpty(marketConfigResponse)) {
90
+ marketConfigResponse = await axios.get(marketConfigUrl);
91
+ marketConfig = marketConfigResponse.data;
92
+ } else if (typeof marketConfigResponse === 'string') {
93
+ marketConfig = JSON.parse(marketConfigResponse);
94
+ } else {
95
+ marketConfig = marketConfigResponse;
96
+ }
97
+ } catch (error) {
98
+ // log.warn({ marketConfigFailed: error });
99
+ }
100
+
101
+ if (marketConfig && !isNullOrEmpty(marketConfig)) {
102
+ marketConfig.productStatusRequestedQuantity =
103
+ Number(marketConfig.productStatusRequestedQuantity) || 999;
104
+
105
+ if (Array.isArray(marketConfig.skuMaxQty)) {
106
+ const skuMaxQuantityMapping = {};
107
+ const skuMaxQuantities = marketConfig.skuMaxQty.map((smq) => JSON.parse(smq));
108
+ for (const skuMaxQuantity of skuMaxQuantities) {
109
+ skuMaxQuantityMapping[skuMaxQuantity.sku] = skuMaxQuantity.maxQty;
110
+ }
111
+ marketConfig.skuMaxQty = skuMaxQuantityMapping;
112
+ }
113
+
114
+ return marketConfig;
115
+ }
116
+ return {};
117
+ }
118
+
119
+
120
+ /**
121
+ * Get product status for each sku
122
+ * @param {object} marketConfig
123
+ * @param {{language: string, countryCode: string}} localeInfo
124
+ * @param {array} skus an array of all variant skus to get status for
125
+ */
126
+ async function getProductStatusResponse(marketConfig, localeInfo, skus) {
127
+ const marketRequestedQuantity = marketConfig.productStatusRequestedQuantity || 999;
128
+
129
+ const skusRequest = skus.reduce((result, sku) => {
130
+ result[sku] = {
131
+ requestedQuantity: marketRequestedQuantity
132
+ };
133
+ return result
134
+ }, {});
135
+
136
+ const productStatusUrl = `${config.properties.awsBase}/product/v2/status?country=${localeInfo.countryCode}`;
137
+ const productStatusPayload = JSON.stringify(skusRequest);
138
+
139
+ try {
140
+ const axiosInstance = axios.create({
141
+ baseURL: productStatusUrl,
142
+ timeout: 10000,
143
+ headers: {
144
+ client_id: config.properties.clientId
145
+ // 'x-correlation-id': correlate.retrieveCorrelationId(),
146
+ }
147
+ });
148
+ axiosRetry(axiosInstance, {
149
+ retries: 2,
150
+ shouldResetTimeout: true,
151
+ // eslint-disable-next-line no-unused-vars
152
+ retryCondition: (_error) => true
153
+ });
154
+
155
+ const productStatusResponse = await axiosInstance.post('', productStatusPayload);
156
+
157
+ return productStatusResponse.data;
158
+ } catch (error) {
159
+ console.log({ productStatusFailed: error });
160
+ }
161
+
162
+ return null;
163
+ }
164
+
165
+ module.exports = {
166
+ getProductStatus
167
+ };