Recommendations v2 (#1040)
* feat: add personalized recommendations (#1024)
* use Algolia for personalized recommendations
* show personalized recommendations to use that have consented
to functional cookies
* update tests
VAN-1599
* Revert "fix: special characters in redirect url getting decoded to space (#1029)" (#1030)
This reverts commit fc62241332.
* feat: update recommendations page design (#1036)
VAN-1598
* feat: add events for recommendations (#1039)
* feat: remove static recommendations
---------
Co-authored-by: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com>
This commit is contained in:
2
.env
2
.env
@@ -25,7 +25,7 @@ SEARCH_CATALOG_URL=''
|
||||
DISABLE_ENTERPRISE_LOGIN=''
|
||||
ENABLE_DYNAMIC_REGISTRATION_FIELDS=''
|
||||
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN=''
|
||||
ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS=''
|
||||
ENABLE_POST_REGISTRATION_RECOMMENDATIONS=''
|
||||
MARKETING_EMAILS_OPT_IN=''
|
||||
SHOW_CONFIGURABLE_EDX_FIELDS=''
|
||||
# ***** Zendesk related keys *****
|
||||
|
||||
222
package-lock.json
generated
222
package-lock.json
generated
@@ -18,7 +18,10 @@
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@optimizely/react-sdk": "^2.9.1",
|
||||
"@redux-devtools/extension": "3.2.5",
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"algoliasearch": "^4.14.3",
|
||||
"algoliasearch-helper": "^3.14.0",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.32.0",
|
||||
"fastest-levenshtein": "1.0.16",
|
||||
@@ -137,6 +140,11 @@
|
||||
"@algolia/transporter": "4.19.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@algolia/events": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz",
|
||||
"integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ=="
|
||||
},
|
||||
"node_modules/@algolia/logger-common": {
|
||||
"version": "4.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.19.1.tgz",
|
||||
@@ -5384,6 +5392,158 @@
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz",
|
||||
"integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.1.3",
|
||||
"chalk": "^4.1.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "12.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz",
|
||||
"integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@testing-library/dom": "^8.0.0",
|
||||
"@types/react-dom": "<18.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "<18.0.0",
|
||||
"react-dom": "<18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react-hooks": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz",
|
||||
"integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"react-error-boundary": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.9.0 || ^17.0.0",
|
||||
"react": "^16.9.0 || ^17.0.0",
|
||||
"react-dom": "^16.9.0 || ^17.0.0",
|
||||
"react-test-renderer": "^16.9.0 || ^17.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-test-renderer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
||||
@@ -5400,6 +5560,11 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz",
|
||||
"integrity": "sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q=="
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz",
|
||||
@@ -5665,15 +5830,23 @@
|
||||
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw=="
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.11.tgz",
|
||||
"integrity": "sha512-+hsJr9hmwyDecSMQAmX7drgbDpyE+EgSF6t7+5QEBAn1tQK7kl1vWZ4iRf6SjQ8lk7dyEULxUmZOIpN0W5baZA==",
|
||||
"version": "17.0.63",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.63.tgz",
|
||||
"integrity": "sha512-T+aaG8RlIkgJ4VzWLJYbMW9QX7sIAV8CcuyV6FU6Hm7yu3Bee1YBZQRu2vYEm/dU8kre+/mzl2aGYh5MFgVLaQ==",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "17.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.20.tgz",
|
||||
"integrity": "sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA==",
|
||||
"dependencies": {
|
||||
"@types/react": "^17"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-redux": {
|
||||
"version": "7.1.25",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.25.tgz",
|
||||
@@ -6191,6 +6364,17 @@
|
||||
"@algolia/transporter": "4.19.1"
|
||||
}
|
||||
},
|
||||
"node_modules/algoliasearch-helper": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.14.0.tgz",
|
||||
"integrity": "sha512-gXDXzsSS0YANn5dHr71CUXOo84cN4azhHKUbg71vAWnH+1JBiR4jf7to3t3JHXknXkbV0F7f055vUSBKrltHLQ==",
|
||||
"dependencies": {
|
||||
"@algolia/events": "^4.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"algoliasearch": ">= 3.1 < 6"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
||||
@@ -8693,6 +8877,11 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="
|
||||
},
|
||||
"node_modules/dom-converter": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
|
||||
@@ -15517,6 +15706,14 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/mailto-link": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mailto-link/-/mailto-link-2.0.0.tgz",
|
||||
@@ -18041,6 +18238,21 @@
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-boundary": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
|
||||
"integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"npm": ">=6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-overlay": {
|
||||
"version": "6.0.11",
|
||||
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz",
|
||||
@@ -18342,7 +18554,7 @@
|
||||
"version": "16.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz",
|
||||
"integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
|
||||
@@ -18397,7 +18609,7 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz",
|
||||
"integrity": "sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^17.0.2",
|
||||
|
||||
@@ -41,7 +41,10 @@
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@optimizely/react-sdk": "^2.9.1",
|
||||
"@redux-devtools/extension": "3.2.5",
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"algoliasearch": "^4.14.3",
|
||||
"algoliasearch-helper": "^3.14.0",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.32.0",
|
||||
"fastest-levenshtein": "1.0.16",
|
||||
|
||||
@@ -6,7 +6,7 @@ const configuration = {
|
||||
DISABLE_ENTERPRISE_LOGIN: process.env.DISABLE_ENTERPRISE_LOGIN || '',
|
||||
ENABLE_DYNAMIC_REGISTRATION_FIELDS: process.env.ENABLE_DYNAMIC_REGISTRATION_FIELDS || false,
|
||||
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: process.env.ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN || false,
|
||||
ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS: process.env.ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS || false,
|
||||
ENABLE_POST_REGISTRATION_RECOMMENDATIONS: process.env.ENABLE_POST_REGISTRATION_RECOMMENDATIONS || false,
|
||||
MARKETING_EMAILS_OPT_IN: process.env.MARKETING_EMAILS_OPT_IN || '',
|
||||
SHOW_CONFIGURABLE_EDX_FIELDS: process.env.SHOW_CONFIGURABLE_EDX_FIELDS || false,
|
||||
// Links
|
||||
@@ -26,12 +26,12 @@ const configuration = {
|
||||
BANNER_IMAGE_EXTRA_SMALL: process.env.BANNER_IMAGE_EXTRA_SMALL || '',
|
||||
// Recommendation constants
|
||||
GENERAL_RECOMMENDATIONS: process.env.GENERAL_RECOMMENDATIONS || '[]',
|
||||
POPULAR_PRODUCTS: process.env.POPULAR_PRODUCTS || '[]',
|
||||
TRENDING_PRODUCTS: process.env.TRENDING_PRODUCTS || '[]',
|
||||
// Miscellaneous
|
||||
INFO_EMAIL: process.env.INFO_EMAIL || '',
|
||||
ZENDESK_KEY: process.env.ZENDESK_KEY,
|
||||
ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL,
|
||||
ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '',
|
||||
ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '',
|
||||
};
|
||||
|
||||
export default configuration;
|
||||
|
||||
20
src/data/algolia.js
Normal file
20
src/data/algolia.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import algoliasearch from 'algoliasearch';
|
||||
|
||||
// initialize Algolia workers
|
||||
const initializeSearchClient = () => algoliasearch(
|
||||
getConfig().ALGOLIA_APP_ID,
|
||||
getConfig().ALGOLIA_SEARCH_API_KEY,
|
||||
);
|
||||
|
||||
const getLocationRestrictionFilter = (userCountry) => {
|
||||
if (userCountry) {
|
||||
return `NOT blocked_in:"${userCountry}" AND (allowed_in:"null" OR allowed_in:"${userCountry}")`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export {
|
||||
initializeSearchClient,
|
||||
getLocationRestrictionFilter,
|
||||
};
|
||||
3
src/data/oneTrust.js
Normal file
3
src/data/oneTrust.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const isOneTrustFunctionalCookieEnabled = () => !!window?.OnetrustActiveGroups?.includes('C0003');
|
||||
|
||||
export default isOneTrustFunctionalCookieEnabled;
|
||||
16
src/data/tests/algolia.test.js
Normal file
16
src/data/tests/algolia.test.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getLocationRestrictionFilter } from '../algolia';
|
||||
|
||||
describe('algoliaUtilsTests', () => {
|
||||
it('test getLocationRestrictionFilter returns filter if country is passed', () => {
|
||||
const countryCode = 'PK';
|
||||
const filter = getLocationRestrictionFilter(countryCode);
|
||||
const expectedFilter = `NOT blocked_in:"${countryCode}" AND (allowed_in:"null" OR allowed_in:"${countryCode}")`;
|
||||
expect(filter).toEqual(expectedFilter);
|
||||
});
|
||||
it('test getLocationRestrictionFilter returns empty string if country is not passed', () => {
|
||||
const countryCode = '';
|
||||
const filter = getLocationRestrictionFilter(countryCode);
|
||||
const expectedFilter = '';
|
||||
expect(filter).toEqual(expectedFilter);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { updatePathWithQueryParams } from './dataUtils';
|
||||
import { LOGIN_PAGE } from '../constants';
|
||||
import { updatePathWithQueryParams } from '../utils/dataUtils';
|
||||
|
||||
describe('updatePathWithQueryParams', () => {
|
||||
it('should append query params into the path', () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import AsyncActionType from './reduxUtils';
|
||||
import AsyncActionType from '../utils/reduxUtils';
|
||||
|
||||
describe('AsyncActionType', () => {
|
||||
it('should return well formatted action strings', () => {
|
||||
@@ -35,12 +35,9 @@ import {
|
||||
FAILURE_STATE,
|
||||
PENDING_STATE,
|
||||
} from '../data/constants';
|
||||
import isOneTrustFunctionalCookieEnabled from '../data/oneTrust';
|
||||
import { getAllPossibleQueryParams, isHostAvailableInQueryParams } from '../data/utils';
|
||||
import { FormFieldRenderer } from '../field-renderer';
|
||||
import {
|
||||
activateRecommendationsExperiment, RECOMMENDATIONS_EXP_VARIATION,
|
||||
} from '../recommendations/optimizelyExperiment';
|
||||
import { trackRecommendationsGroup, trackRecommendationsViewed } from '../recommendations/track';
|
||||
|
||||
const ProgressiveProfiling = (props) => {
|
||||
const { formatMessage } = useIntl();
|
||||
@@ -56,7 +53,10 @@ const ProgressiveProfiling = (props) => {
|
||||
|
||||
const queryParams = getAllPossibleQueryParams();
|
||||
const authenticatedUser = getAuthenticatedUser() || location.state?.authenticatedUser;
|
||||
const enablePopularAndTrendingRecommendations = getConfig().ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS;
|
||||
const functionalCookiesConsent = isOneTrustFunctionalCookieEnabled();
|
||||
const enablePostRegistrationRecommendations = (
|
||||
getConfig().ENABLE_POST_REGISTRATION_RECOMMENDATIONS && functionalCookiesConsent
|
||||
);
|
||||
|
||||
const [registrationResult, setRegistrationResult] = useState({ redirectUrl: '' });
|
||||
const [formFieldData, setFormFieldData] = useState({ fields: {}, extendedProfile: [] });
|
||||
@@ -102,21 +102,27 @@ const ProgressiveProfiling = (props) => {
|
||||
}, [authenticatedUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enablePostRegistrationRecommendations) {
|
||||
sendTrackEvent(
|
||||
'edx.bi.user.recommendations.not.enabled',
|
||||
{ functionalCookiesConsent, page: 'authn_recommendations' },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (registrationResult.redirectUrl && authenticatedUser?.userId) {
|
||||
const redirectQueryParams = getAllPossibleQueryParams(registrationResult.redirectUrl);
|
||||
if (enablePopularAndTrendingRecommendations && !('enrollment_action' in redirectQueryParams) && !queryParams?.next) {
|
||||
const userIdStr = authenticatedUser.userId.toString();
|
||||
const variation = activateRecommendationsExperiment(userIdStr);
|
||||
const showRecommendations = variation === RECOMMENDATIONS_EXP_VARIATION;
|
||||
|
||||
trackRecommendationsGroup(variation, authenticatedUser.userId);
|
||||
setShowRecommendationsPage(showRecommendations);
|
||||
if (!showRecommendations) {
|
||||
trackRecommendationsViewed([], '', true, authenticatedUser.userId);
|
||||
}
|
||||
if (!('enrollment_action' in redirectQueryParams || queryParams?.next)) {
|
||||
setShowRecommendationsPage(true);
|
||||
}
|
||||
}
|
||||
}, [authenticatedUser, enablePopularAndTrendingRecommendations, registrationResult.redirectUrl, queryParams?.next]);
|
||||
}, [
|
||||
authenticatedUser,
|
||||
enablePostRegistrationRecommendations,
|
||||
functionalCookiesConsent,
|
||||
registrationResult.redirectUrl,
|
||||
queryParams?.next,
|
||||
]);
|
||||
|
||||
if (
|
||||
!authenticatedUser
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
FAILURE_STATE,
|
||||
RECOMMENDATIONS,
|
||||
} from '../../data/constants';
|
||||
import { activateRecommendationsExperiment } from '../../recommendations/optimizelyExperiment';
|
||||
import { saveUserProfile } from '../data/actions';
|
||||
import ProgressiveProfiling from '../ProgressiveProfiling';
|
||||
|
||||
@@ -35,11 +34,6 @@ jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
getLoggingService: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../recommendations/optimizelyExperiment.js', () => ({
|
||||
activateRecommendationsExperiment: jest.fn(),
|
||||
trackRecommendationViewedOptimizely: jest.fn(),
|
||||
RECOMMENDATIONS_EXP_VARIATION: 'welcome_page_recommendations_enabled',
|
||||
}));
|
||||
jest.mock('react-router-dom', () => {
|
||||
const mockNavigation = jest.fn();
|
||||
|
||||
@@ -220,8 +214,9 @@ describe('ProgressiveProfilingTests', () => {
|
||||
});
|
||||
|
||||
describe('Recommendations test', () => {
|
||||
window.OnetrustActiveGroups = 'C0003';
|
||||
mergeConfig({
|
||||
ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS: true,
|
||||
ENABLE_POST_REGISTRATION_RECOMMENDATIONS: true,
|
||||
});
|
||||
|
||||
it('should redirect to recommendations page if recommendations are enabled', () => {
|
||||
@@ -232,41 +227,13 @@ describe('ProgressiveProfilingTests', () => {
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
activateRecommendationsExperiment.mockImplementation(() => 'welcome_page_recommendations_enabled');
|
||||
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
|
||||
|
||||
expect(progressiveProfilingPage.find('button.btn-brand').text()).toEqual('Next');
|
||||
expect(mockNavigate).toHaveBeenCalledWith(RECOMMENDATIONS);
|
||||
});
|
||||
|
||||
it('should fire segments recommendations viewed and variation group events', () => {
|
||||
const viewedEventProperties = {
|
||||
page: 'authn_recommendations',
|
||||
products: [],
|
||||
recommendation_type: '',
|
||||
is_control: true,
|
||||
user_id: 3,
|
||||
};
|
||||
const groupEventProperties = {
|
||||
page: 'authn_recommendations',
|
||||
variation: 'control',
|
||||
user_id: 3,
|
||||
};
|
||||
activateRecommendationsExperiment.mockImplementation(() => 'control');
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
welcomePage: {
|
||||
...initialState.welcomePage,
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
|
||||
mount(reduxWrapper(<IntlProgressiveProfilingPage />));
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.recommendations.group', groupEventProperties);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.recommendations.viewed', viewedEventProperties);
|
||||
});
|
||||
|
||||
it('should not redirect to recommendations page if user is on its way to enroll in a course', () => {
|
||||
it('should not redirect to recommendations page if user is on its way to enroll in a course', async () => {
|
||||
const redirectUrl = `${getConfig().LMS_BASE_URL}${DEFAULT_REDIRECT_URL}?enrollment_action=1`;
|
||||
useLocation.mockReturnValue({
|
||||
state: {
|
||||
|
||||
@@ -15,9 +15,9 @@ const BaseCard = ({
|
||||
productTypeCopy,
|
||||
footer,
|
||||
handleOnClick,
|
||||
isLoading = false,
|
||||
isLoading,
|
||||
}) => (
|
||||
<div className="mr-4 recommendation-card" key={`container-${uuid}`}>
|
||||
<div className="recommendation-card" key={`container-${uuid}`}>
|
||||
<Hyperlink
|
||||
target="_blank"
|
||||
className="card-box"
|
||||
|
||||
@@ -15,6 +15,7 @@ const ProductCard = ({
|
||||
product,
|
||||
userId,
|
||||
position,
|
||||
isLoading,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
@@ -66,7 +67,6 @@ const ProductCard = ({
|
||||
trackRecommendationClick(
|
||||
product,
|
||||
position + 1,
|
||||
false,
|
||||
userId,
|
||||
);
|
||||
};
|
||||
@@ -82,6 +82,7 @@ const ProductCard = ({
|
||||
productTypeCopy={productTypeCopy}
|
||||
productType={productType}
|
||||
variant={variant}
|
||||
isLoading={isLoading}
|
||||
footer={(
|
||||
<Footer
|
||||
quickFacts={product.degree?.quickFacts}
|
||||
@@ -105,8 +106,10 @@ ProductCard.propTypes = {
|
||||
]).isRequired,
|
||||
userId: PropTypes.number.isRequired,
|
||||
position: PropTypes.number.isRequired,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
ProductCard.defaultProps = {
|
||||
isLoading: false,
|
||||
};
|
||||
export default ProductCard;
|
||||
|
||||
@@ -5,19 +5,19 @@ import PropTypes from 'prop-types';
|
||||
import ProductCard from './ProductCard';
|
||||
|
||||
const RecommendationsList = (props) => {
|
||||
const { recommendations, userId } = props;
|
||||
const { recommendations, userId, isLoading } = props;
|
||||
|
||||
return (
|
||||
<div className="d-flex recommendations-container__card-list">
|
||||
<div className="d-flex flex-wrap mb-3 recommendations-container__card-list">
|
||||
{
|
||||
recommendations.map((recommendation, idx) => (
|
||||
<span key={recommendation.uuid}>
|
||||
<ProductCard
|
||||
product={recommendation}
|
||||
position={idx}
|
||||
userId={userId}
|
||||
/>
|
||||
</span>
|
||||
<ProductCard
|
||||
key={recommendation.uuid}
|
||||
product={recommendation}
|
||||
position={idx}
|
||||
userId={userId}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -29,11 +29,13 @@ RecommendationsList.propTypes = {
|
||||
uuid: PropTypes.string,
|
||||
})),
|
||||
userId: PropTypes.number,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
RecommendationsList.defaultProps = {
|
||||
recommendations: [],
|
||||
userId: null,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
export default RecommendationsList;
|
||||
|
||||
@@ -1,36 +1,50 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Container, Hyperlink, Image, StatefulButton, Tab, Tabs,
|
||||
breakpoints,
|
||||
Container,
|
||||
Hyperlink,
|
||||
Image, Skeleton,
|
||||
StatefulButton,
|
||||
useMediaQuery,
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { POPULAR, TRENDING } from './data/constants';
|
||||
import useProducts from './data/hooks/useProducts';
|
||||
import { EDUCATION_LEVEL_MAPPING, PERSONALIZED } from './data/constants';
|
||||
import useAlgoliaRecommendations from './data/hooks/useAlgoliaRecommendations';
|
||||
import messages from './messages';
|
||||
import RecommendationsList from './RecommendationsList';
|
||||
import { trackRecommendationsViewed } from './track';
|
||||
import RecommendationsLargeLayout from './RecommendationsPageLayouts/LargeLayout';
|
||||
import RecommendationsSmallLayout from './RecommendationsPageLayouts/SmallLayout';
|
||||
import { trackRecommendationsViewed, trackSkipButtonClicked } from './track';
|
||||
import { DEFAULT_REDIRECT_URL } from '../data/constants';
|
||||
|
||||
const RecommendationsPage = ({ countryCode }) => {
|
||||
const RecommendationsPage = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth - 1 });
|
||||
const location = useLocation();
|
||||
|
||||
const registrationResponse = location.state?.registrationResult;
|
||||
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
const educationLevel = EDUCATION_LEVEL_MAPPING[location.state?.educationLevel];
|
||||
const userId = location.state?.userId;
|
||||
|
||||
const { popularProducts, trendingProducts, isLoading } = useProducts(countryCode);
|
||||
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
const userCountry = useSelector((state) => state.register.backendCountryCode);
|
||||
const {
|
||||
recommendations: algoliaRecommendations,
|
||||
isLoading,
|
||||
} = useAlgoliaRecommendations(userCountry, educationLevel);
|
||||
|
||||
useEffect(() => {
|
||||
trackRecommendationsViewed(popularProducts, POPULAR, false, userId);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (!isLoading && algoliaRecommendations.length > 0) {
|
||||
trackRecommendationsViewed(algoliaRecommendations, PERSONALIZED, userId);
|
||||
}
|
||||
}, [isLoading, algoliaRecommendations, userId]);
|
||||
|
||||
const handleRedirection = () => {
|
||||
const handleSkipRecommendationPage = () => {
|
||||
window.history.replaceState(location.state, null, '');
|
||||
if (registrationResponse) {
|
||||
window.location.href = registrationResponse.redirectUrl;
|
||||
@@ -41,7 +55,8 @@ const RecommendationsPage = ({ countryCode }) => {
|
||||
|
||||
const handleSkip = (e) => {
|
||||
e.preventDefault();
|
||||
handleRedirection();
|
||||
trackSkipButtonClicked(userId);
|
||||
handleSkipRecommendationPage();
|
||||
};
|
||||
|
||||
if (!registrationResponse) {
|
||||
@@ -49,15 +64,10 @@ const RecommendationsPage = ({ countryCode }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isLoading && (!popularProducts.length || !trendingProducts.length)) {
|
||||
handleRedirection();
|
||||
if (!isLoading && !algoliaRecommendations.length) {
|
||||
handleSkipRecommendationPage();
|
||||
}
|
||||
|
||||
const handleOnSelect = (tabKey) => {
|
||||
const recommendations = tabKey === POPULAR ? popularProducts : trendingProducts;
|
||||
trackRecommendationsViewed(recommendations, tabKey, false, userId);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -65,64 +75,55 @@ const RecommendationsPage = ({ countryCode }) => {
|
||||
{ siteName: getConfig().SITE_NAME })}
|
||||
</title>
|
||||
</Helmet>
|
||||
<div className="d-flex flex-column vh-100 bg-light-200">
|
||||
<div className="d-flex flex-column bg-light-200 min-vh-100">
|
||||
<div className="mb-2">
|
||||
<div className="col-md-12 small-screen-top-stripe medium-screen-top-stripe extra-large-screen-top-stripe" />
|
||||
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
|
||||
<Image className="logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
|
||||
</Hyperlink>
|
||||
</div>
|
||||
<div className="d-flex flex-column align-items-center justify-content-center flex-grow-1 p-1">
|
||||
<Container id="course-recommendations" size="lg" className="recommendations-container">
|
||||
<h2 className="text-sm-center mb-4 text-left recommendations-container__heading">
|
||||
{formatMessage(messages['recommendation.page.heading'])}
|
||||
</h2>
|
||||
<Tabs
|
||||
variant="tabs"
|
||||
defaultActiveKey={POPULAR}
|
||||
id="recommendations-selection"
|
||||
onSelect={handleOnSelect}
|
||||
>
|
||||
<Tab tabClassName="mb-3" eventKey={POPULAR} title={formatMessage(messages['recommendation.option.popular'])}>
|
||||
<RecommendationsList
|
||||
recommendations={popularProducts}
|
||||
userId={userId}
|
||||
<div className="d-flex flex-column align-items-center justify-content-center flex-grow-1">
|
||||
<Container
|
||||
id="course-recommendations"
|
||||
size="lg"
|
||||
className="pr-4 pl-4 mt-4.5 mb-4.5 mb-md-5"
|
||||
>
|
||||
{isExtraSmall ? (
|
||||
<RecommendationsSmallLayout
|
||||
userId={userId}
|
||||
isLoading={isLoading}
|
||||
personalizedRecommendations={algoliaRecommendations}
|
||||
/>
|
||||
) : (
|
||||
<RecommendationsLargeLayout
|
||||
userId={userId}
|
||||
isLoading={isLoading}
|
||||
personalizedRecommendations={algoliaRecommendations}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-3 mt-sm-4.5 text-center">
|
||||
{isLoading && (
|
||||
<Skeleton height={40} width={140} />
|
||||
)}
|
||||
{!isLoading && algoliaRecommendations.length && (
|
||||
<StatefulButton
|
||||
className="font-weight-500"
|
||||
type="submit"
|
||||
variant="outline-brand"
|
||||
labels={{
|
||||
default: formatMessage(messages['recommendation.skip.button']),
|
||||
}}
|
||||
onClick={handleSkip}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab tabClassName="mb-3" eventKey={TRENDING} title={formatMessage(messages['recommendation.option.trending'])}>
|
||||
<RecommendationsList
|
||||
recommendations={trendingProducts}
|
||||
userId={userId}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
<div className="text-center">
|
||||
<StatefulButton
|
||||
className="font-weight-500"
|
||||
type="submit"
|
||||
variant="brand"
|
||||
labels={{
|
||||
default: formatMessage(messages['recommendation.skip.button']),
|
||||
}}
|
||||
onClick={handleSkip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
RecommendationsPage.propTypes = {
|
||||
countryCode: PropTypes.string.isRequired,
|
||||
};
|
||||
RecommendationsPage.propTypes = {};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
countryCode: state.register.backendCountryCode,
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
null,
|
||||
)(RecommendationsPage);
|
||||
export default RecommendationsPage;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Skeleton } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import loadingCoursesPlaceholders from '../data/loadingCoursesPlaceholders';
|
||||
import messages from '../messages';
|
||||
import RecommendationsList from '../RecommendationsList';
|
||||
|
||||
const RecommendationsLargeLayout = (props) => {
|
||||
const {
|
||||
userId,
|
||||
isLoading,
|
||||
personalizedRecommendations,
|
||||
} = props;
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={32} width={300} className="mb-5" />
|
||||
<RecommendationsList
|
||||
recommendations={loadingCoursesPlaceholders}
|
||||
userId={userId}
|
||||
isLoading
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (personalizedRecommendations.length) {
|
||||
return (
|
||||
<span id="recommendations-large-layout">
|
||||
<h1 className="h2 text-sm-center mb-5 mb-sm-4.5 text-left recommendations-container__heading">
|
||||
{formatMessage(messages['recommendation.page.heading'])}
|
||||
</h1>
|
||||
|
||||
<RecommendationsList
|
||||
recommendations={personalizedRecommendations}
|
||||
userId={userId}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
RecommendationsLargeLayout.propTypes = {
|
||||
userId: PropTypes.number.isRequired,
|
||||
isLoading: PropTypes.bool,
|
||||
personalizedRecommendations: PropTypes.arrayOf(PropTypes.shape({})),
|
||||
};
|
||||
|
||||
RecommendationsLargeLayout.defaultProps = {
|
||||
isLoading: true,
|
||||
personalizedRecommendations: [],
|
||||
};
|
||||
|
||||
export default RecommendationsLargeLayout;
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Skeleton } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import loadingCoursesPlaceholders from '../data/loadingCoursesPlaceholders';
|
||||
import messages from '../messages';
|
||||
import RecommendationsList from '../RecommendationsList';
|
||||
|
||||
const RecommendationsSmallLayout = (props) => {
|
||||
const {
|
||||
userId,
|
||||
isLoading,
|
||||
personalizedRecommendations,
|
||||
} = props;
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={36} className="mb-3" />
|
||||
<RecommendationsList
|
||||
recommendations={loadingCoursesPlaceholders}
|
||||
userId={userId}
|
||||
isLoading
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (personalizedRecommendations.length) {
|
||||
return (
|
||||
<span id="recommendations-small-layout">
|
||||
<h1 className="h3 text-sm-center mb-4.5 text-left recommendations-container__heading">
|
||||
{formatMessage(messages['recommendation.page.heading'])}
|
||||
</h1>
|
||||
|
||||
<RecommendationsList
|
||||
recommendations={personalizedRecommendations}
|
||||
userId={userId}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
RecommendationsSmallLayout.propTypes = {
|
||||
userId: PropTypes.number.isRequired,
|
||||
isLoading: PropTypes.bool,
|
||||
personalizedRecommendations: PropTypes.arrayOf(PropTypes.shape({})),
|
||||
};
|
||||
|
||||
RecommendationsSmallLayout.defaultProps = {
|
||||
isLoading: true,
|
||||
personalizedRecommendations: [],
|
||||
};
|
||||
|
||||
export default RecommendationsSmallLayout;
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
|
||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import SmallLayout from './SmallLayout';
|
||||
import mockedRecommendedProducts from '../data/tests/mockedData';
|
||||
|
||||
const IntlRecommendationsSmallLayoutPage = injectIntl(SmallLayout);
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
...jest.requireActual('@edx/paragon'),
|
||||
useMediaQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('RecommendationsPageTests', () => {
|
||||
let props = {};
|
||||
|
||||
const reduxWrapper = children => (
|
||||
<IntlProvider locale="en">
|
||||
{children}
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
userId: 123,
|
||||
personalizedRecommendations: mockedRecommendedProducts,
|
||||
isLoading: false,
|
||||
};
|
||||
});
|
||||
|
||||
it('should render recommendations when recommendations are not loading', () => {
|
||||
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsSmallLayoutPage {...props} />));
|
||||
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should render loading state when recommendations are loading', () => {
|
||||
props = {
|
||||
...props,
|
||||
isLoading: true,
|
||||
};
|
||||
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsSmallLayoutPage {...props} />));
|
||||
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
29
src/recommendations/data/algoliaResultsParser.js
Normal file
29
src/recommendations/data/algoliaResultsParser.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const { camelCaseObject } = require('@edx/frontend-platform');
|
||||
|
||||
const processCourseSearchResult = (searchResultCourse) => {
|
||||
const camelCasedResult = camelCaseObject(searchResultCourse);
|
||||
|
||||
return {
|
||||
activeCourseRun: {
|
||||
key: camelCasedResult.activeRunKey,
|
||||
type: camelCasedResult.activeRunType,
|
||||
marketingUrl: camelCasedResult.marketingUrl,
|
||||
},
|
||||
allowedIn: camelCasedResult.allowedIn,
|
||||
blockedIn: camelCasedResult.blockedIn,
|
||||
cardType: 'course',
|
||||
courseType: 'course',
|
||||
image: {
|
||||
src: camelCasedResult.cardImageUrl,
|
||||
},
|
||||
owners: camelCasedResult.owners,
|
||||
title: camelCasedResult.title,
|
||||
uuid: camelCasedResult.uuid,
|
||||
objectID: `course-${camelCasedResult.uuid}`,
|
||||
productSource: {
|
||||
slug: camelCasedResult.productSource,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default processCourseSearchResult;
|
||||
@@ -10,5 +10,9 @@ export const EDUCATION_LEVEL_MAPPING = {
|
||||
jhs: 'Introductory',
|
||||
};
|
||||
|
||||
export const POPULAR = 'popular';
|
||||
export const TRENDING = 'trending';
|
||||
export const PERSONALIZED = 'personalized';
|
||||
|
||||
export const LEVEL_FACET = 'level';
|
||||
export const PRODUCT_FACET = 'product';
|
||||
export const PRODUCT_TYPE_COURSE = 'course';
|
||||
export const MAX_RECOMMENDATIONS = 4;
|
||||
|
||||
77
src/recommendations/data/hooks/useAlgoliaRecommendations.jsx
Normal file
77
src/recommendations/data/hooks/useAlgoliaRecommendations.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import algoliasearchHelper from 'algoliasearch-helper';
|
||||
|
||||
import {
|
||||
getLocationRestrictionFilter,
|
||||
initializeSearchClient,
|
||||
} from '../../../data/algolia';
|
||||
import isOneTrustFunctionalCookieEnabled from '../../../data/oneTrust';
|
||||
import processCourseSearchResult from '../algoliaResultsParser';
|
||||
import {
|
||||
LEVEL_FACET, MAX_RECOMMENDATIONS, PRODUCT_FACET, PRODUCT_TYPE_COURSE,
|
||||
} from '../constants';
|
||||
|
||||
const INDEX_NAME = process.env.ALGOLIA_AUTHN_RECOMMENDATIONS_INDEX;
|
||||
|
||||
/**
|
||||
* This hooks returns Algolia recommendations only if functional cookies are enabled. * @param userCountry
|
||||
* @param userCountry
|
||||
* @param educationLevel
|
||||
* @returns {{isLoading: boolean, recommendations: *[]}}
|
||||
*/
|
||||
const useAlgoliaRecommendations = (userCountry, educationLevel) => {
|
||||
const functionalCookiesEnabled = isOneTrustFunctionalCookieEnabled();
|
||||
const [recommendations, setRecommendations] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!functionalCookiesEnabled) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const getSearchFiltersQueryString = () => getLocationRestrictionFilter(userCountry);
|
||||
|
||||
const algoliaSearchParams = {
|
||||
facets: [LEVEL_FACET, PRODUCT_FACET],
|
||||
filters: getSearchFiltersQueryString(),
|
||||
aroundLatLngViaIP: true,
|
||||
};
|
||||
|
||||
const searchClient = initializeSearchClient();
|
||||
const searchHelper = algoliasearchHelper(
|
||||
searchClient,
|
||||
INDEX_NAME,
|
||||
algoliaSearchParams,
|
||||
);
|
||||
|
||||
searchHelper.addFacetRefinement(PRODUCT_FACET, PRODUCT_TYPE_COURSE);
|
||||
if (educationLevel) {
|
||||
searchHelper.addFacetRefinement(LEVEL_FACET, educationLevel);
|
||||
}
|
||||
|
||||
const searchIndex = () => {
|
||||
searchHelper.search();
|
||||
};
|
||||
|
||||
searchIndex();
|
||||
|
||||
searchHelper.on('result', ({ results }) => {
|
||||
const parsedCourses = results.hits.slice(0, MAX_RECOMMENDATIONS).map(
|
||||
(course) => processCourseSearchResult(course),
|
||||
);
|
||||
setRecommendations(parsedCourses);
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
searchHelper.on('error', () => setIsLoading(false));
|
||||
}, [educationLevel, functionalCookiesEnabled, userCountry]);
|
||||
|
||||
return {
|
||||
recommendations,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAlgoliaRecommendations;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { filterLocationRestriction } from '../utils';
|
||||
|
||||
export default function useProducts(countryCode) {
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const [popularProducts, setPopularProducts] = useState([]);
|
||||
const [trendingProducts, setTrendingProducts] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const popular = filterLocationRestriction(JSON.parse(getConfig().POPULAR_PRODUCTS), countryCode);
|
||||
const trending = filterLocationRestriction(JSON.parse(getConfig().TRENDING_PRODUCTS), countryCode);
|
||||
|
||||
setPopularProducts(popular);
|
||||
setTrendingProducts(trending);
|
||||
setLoading(false);
|
||||
}, [countryCode]);
|
||||
|
||||
return { popularProducts, trendingProducts, isLoading };
|
||||
}
|
||||
36
src/recommendations/data/loadingCoursesPlaceholders.js
Normal file
36
src/recommendations/data/loadingCoursesPlaceholders.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const placeholderCourse = {
|
||||
activeCourseRun: {
|
||||
key: 'course',
|
||||
marketingUrl: '/',
|
||||
type: 'Verified and Audit',
|
||||
},
|
||||
cardType: 'course',
|
||||
image: {
|
||||
src: './',
|
||||
},
|
||||
inProspectus: true,
|
||||
objectID: 'skeleton',
|
||||
owners: [{
|
||||
key: 'skeleton',
|
||||
logoImageUrl: './',
|
||||
name: 'skeleton',
|
||||
}],
|
||||
position: 0,
|
||||
prospectusPath: './',
|
||||
queryID: 'skeleton',
|
||||
recentEnrollmentCount: 0,
|
||||
title: 'skeleton',
|
||||
topics: [{
|
||||
topic: 'skeleton',
|
||||
}],
|
||||
uuid: 'skeleton',
|
||||
};
|
||||
|
||||
const loadingCoursesPlaceHolders = [
|
||||
{ ...placeholderCourse, uuid: '294ea4rtn2117', courseType: 'course' },
|
||||
{ ...placeholderCourse, uuid: '294fga4681117', courseType: 'course' },
|
||||
{ ...placeholderCourse, uuid: '294ea4278e117', courseType: 'course' },
|
||||
{ ...placeholderCourse, uuid: '294eax2rtg117', courseType: 'course' },
|
||||
];
|
||||
|
||||
export default loadingCoursesPlaceHolders;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import algoliasearch from 'algoliasearch/lite';
|
||||
|
||||
const INDEX_NAME = process.env.ALGOLIA_AUTHN_RECOMMENDATIONS_INDEX;
|
||||
|
||||
const getPersonalizedRecommendations = async (educationLevel) => {
|
||||
const facetFilters = ['product:Course', 'availability:Available now'];
|
||||
|
||||
if (educationLevel) {
|
||||
facetFilters.push(`level:${educationLevel}`);
|
||||
}
|
||||
const client = algoliasearch(process.env.ALGOLIA_APP_ID, process.env.ALGOLIA_SEARCH_KEY);
|
||||
const index = client.initIndex(INDEX_NAME);
|
||||
const { hits } = await index.search('', {
|
||||
aroundLatLngViaIP: true,
|
||||
facetFilters,
|
||||
});
|
||||
|
||||
return camelCaseObject(hits);
|
||||
};
|
||||
|
||||
export default getPersonalizedRecommendations;
|
||||
56
src/recommendations/data/tests/hooks.test.jsx
Normal file
56
src/recommendations/data/tests/hooks.test.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import algoliasearchHelper from 'algoliasearch-helper';
|
||||
|
||||
import mockedRecommendedProducts from './mockedData';
|
||||
import CreateAlgoliaSearchHelperMock from './test_utils/test_utils';
|
||||
import isOneTrustFunctionalCookieEnabled from '../../../data/oneTrust';
|
||||
import useAlgoliaRecommendations from '../hooks/useAlgoliaRecommendations';
|
||||
|
||||
jest.mock('algoliasearch-helper');
|
||||
|
||||
jest.mock('../../../data/oneTrust');
|
||||
|
||||
jest.mock('../../../data/algolia', () => ({
|
||||
initializeSearchClient: jest.fn(),
|
||||
getLocationRestrictionFilter: jest.fn((countryCode) => `NOT BLOCKED IN ${countryCode}`),
|
||||
}));
|
||||
|
||||
jest.mock('../algoliaResultsParser', () => jest.fn((course) => course));
|
||||
|
||||
describe('useAlgoliaRecommendations Tests', () => {
|
||||
const MockSearchHelperWithData = new CreateAlgoliaSearchHelperMock(mockedRecommendedProducts);
|
||||
const MockSearchHelperWithoutData = new CreateAlgoliaSearchHelperMock();
|
||||
|
||||
it('should fetch recommendations only if functional cookies are set', async () => {
|
||||
isOneTrustFunctionalCookieEnabled.mockImplementation(() => true);
|
||||
algoliasearchHelper.mockImplementation(() => MockSearchHelperWithData);
|
||||
const { result } = renderHook(
|
||||
() => useAlgoliaRecommendations('PK', 'Introductory'),
|
||||
);
|
||||
|
||||
expect(result.current.recommendations).toEqual(mockedRecommendedProducts);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should not fetch recommendations if functional cookies are not set', async () => {
|
||||
isOneTrustFunctionalCookieEnabled.mockImplementation(() => false);
|
||||
algoliasearchHelper.mockImplementation(() => MockSearchHelperWithData);
|
||||
const { result } = renderHook(
|
||||
() => useAlgoliaRecommendations('PK', 'Introductory'),
|
||||
);
|
||||
|
||||
expect(result.current.recommendations).toEqual([]);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should return empty list if no recommendations returned from Algolia', async () => {
|
||||
isOneTrustFunctionalCookieEnabled.mockImplementation(() => true);
|
||||
algoliasearchHelper.mockImplementation(() => MockSearchHelperWithoutData);
|
||||
const { result } = renderHook(
|
||||
() => useAlgoliaRecommendations('PK', 'Introductory'),
|
||||
);
|
||||
|
||||
expect(result.current.recommendations).toEqual([]);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
88
src/recommendations/data/tests/mockedData.js
Normal file
88
src/recommendations/data/tests/mockedData.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const mockedRecommendedProducts = [
|
||||
{
|
||||
activeCourseRun: {
|
||||
key: 'course-v1:TEST_COURSE_RUN',
|
||||
type: 'test_course_run_type',
|
||||
marketingUrl: 'test_marketingUrl',
|
||||
},
|
||||
allowedIn: [],
|
||||
blockedIn: [],
|
||||
cardType: 'course',
|
||||
courseType: 'course',
|
||||
image: {
|
||||
src: 'test_src',
|
||||
},
|
||||
owners: [],
|
||||
title: 'test_title',
|
||||
uuid: 'test_uuid',
|
||||
objectID: 'course-test_uuid',
|
||||
productSource: {
|
||||
slug: 'test_source',
|
||||
},
|
||||
},
|
||||
{
|
||||
activeCourseRun: {
|
||||
key: 'course-v1:TEST_COURSE_RUN',
|
||||
type: 'test_course_run_type',
|
||||
marketingUrl: 'test_marketingUrl',
|
||||
},
|
||||
allowedIn: [],
|
||||
blockedIn: [],
|
||||
cardType: 'course',
|
||||
courseType: 'course',
|
||||
image: {
|
||||
src: 'test_src',
|
||||
},
|
||||
owners: [],
|
||||
title: 'test_title',
|
||||
uuid: 'test_uuid2',
|
||||
objectID: 'course-test_uuid',
|
||||
productSource: {
|
||||
slug: 'test_source',
|
||||
},
|
||||
},
|
||||
{
|
||||
activeCourseRun: {
|
||||
key: 'course-v1:TEST_COURSE_RUN',
|
||||
type: 'test_course_run_type',
|
||||
marketingUrl: 'test_marketingUrl',
|
||||
},
|
||||
allowedIn: [],
|
||||
blockedIn: [],
|
||||
cardType: 'course',
|
||||
courseType: 'course',
|
||||
image: {
|
||||
src: 'test_src',
|
||||
},
|
||||
owners: [],
|
||||
title: 'test_title',
|
||||
uuid: 'test_uuid3',
|
||||
objectID: 'course-test_uuid',
|
||||
productSource: {
|
||||
slug: 'test_source',
|
||||
},
|
||||
},
|
||||
{
|
||||
activeCourseRun: {
|
||||
key: 'course-v1:TEST_COURSE_RUN',
|
||||
type: 'test_course_run_type',
|
||||
marketingUrl: 'test_marketingUrl',
|
||||
},
|
||||
allowedIn: [],
|
||||
blockedIn: [],
|
||||
cardType: 'course',
|
||||
courseType: 'course',
|
||||
image: {
|
||||
src: 'test_src',
|
||||
},
|
||||
owners: [],
|
||||
title: 'test_title',
|
||||
uuid: 'test_uuid4',
|
||||
objectID: 'course-test_uuid',
|
||||
productSource: {
|
||||
slug: 'test_source',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default mockedRecommendedProducts;
|
||||
26
src/recommendations/data/tests/parser.test.jsx
Normal file
26
src/recommendations/data/tests/parser.test.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import mockedRecommendedProducts from './mockedData';
|
||||
import processCourseSearchResult from '../algoliaResultsParser';
|
||||
|
||||
describe('AlgoliaResultsParserTests', () => {
|
||||
const dataToBeProcessed = {
|
||||
activeRunKey: 'course-v1:TEST_COURSE_RUN',
|
||||
activeRunType: 'test_course_run_type',
|
||||
marketingUrl: 'test_marketingUrl',
|
||||
minEffort: 1,
|
||||
maxEffort: 2,
|
||||
weeksToComplete: 3,
|
||||
allowedIn: [],
|
||||
blockedIn: [],
|
||||
cardImageUrl: 'test_src',
|
||||
owners: [],
|
||||
title: 'test_title',
|
||||
uuid: 'test_uuid',
|
||||
recentEnrollmentCount: 1,
|
||||
productSource: 'test_source',
|
||||
};
|
||||
|
||||
it('should parse results returned by Algolia', async () => {
|
||||
const parsedData = processCourseSearchResult(dataToBeProcessed);
|
||||
expect(parsedData).toEqual(mockedRecommendedProducts[0]);
|
||||
});
|
||||
});
|
||||
63
src/recommendations/data/tests/test_utils/test_utils.jsx
Normal file
63
src/recommendations/data/tests/test_utils/test_utils.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// Algolia Search Helper mock data
|
||||
class CreateAlgoliaSearchHelperMock {
|
||||
hits = [];
|
||||
|
||||
eventCb = () => {};
|
||||
|
||||
derived = [];
|
||||
|
||||
maxLimit = 0;
|
||||
|
||||
refineProp = null;
|
||||
|
||||
refineVal = null;
|
||||
|
||||
constructor(mockData = [], limit = 0) {
|
||||
this.hits = mockData;
|
||||
this.maxLimit = limit;
|
||||
}
|
||||
|
||||
onMock(eventName, callback) {
|
||||
if (eventName === 'result') {
|
||||
const hitData = this.hits.slice(0, this.maxLimit > 0 ? this.maxLimit : this.hits.length);
|
||||
callback({
|
||||
results: {
|
||||
hits: hitData,
|
||||
},
|
||||
});
|
||||
} else if (eventName === 'error') {
|
||||
callback({});
|
||||
}
|
||||
}
|
||||
|
||||
setQueryMock = () => {};
|
||||
|
||||
searchMock = () => {
|
||||
this.eventCb();
|
||||
};
|
||||
|
||||
clearRefinementsMock = () => this;
|
||||
|
||||
refinementMock(refineBy, value) {
|
||||
// addDisjunctiveFacetRefinement // addFacetRefinement
|
||||
this.refineProp = refineBy;
|
||||
this.refineVal = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
on = this.onMock;
|
||||
|
||||
setQuery = this.setQueryMock;
|
||||
|
||||
search = this.searchMock;
|
||||
|
||||
derive = this.deriveMock;
|
||||
|
||||
clearRefinements = this.clearRefinementsMock;
|
||||
|
||||
addDisjunctiveFacetRefinement = this.refinementMock;
|
||||
|
||||
addFacetRefinement = this.refinementMock;
|
||||
}
|
||||
|
||||
export default CreateAlgoliaSearchHelperMock;
|
||||
@@ -18,7 +18,7 @@ const messages = defineMessages({
|
||||
},
|
||||
'recommendation.option.trending': {
|
||||
id: 'recommendation.option.trending',
|
||||
defaultMessage: 'Trending',
|
||||
defaultMessage: 'Trending Now',
|
||||
description: 'Title for trending products',
|
||||
},
|
||||
'recommendation.option.popular': {
|
||||
@@ -26,6 +26,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Most Popular',
|
||||
description: 'Title for popular products',
|
||||
},
|
||||
'recommendation.option.recommended.for.you': {
|
||||
id: 'recommendation.option.recommended.for.you',
|
||||
defaultMessage: 'Recommended For You',
|
||||
description: 'Title for personalized products',
|
||||
},
|
||||
});
|
||||
|
||||
export const cardBadgesMessages = defineMessages({
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import optimizelyInstance from '../data/optimizely';
|
||||
|
||||
const RECOMMENDATIONS_EXP_KEY = 'popular_and_trending_recommendations_exp';
|
||||
const RECOMMENDATIONS_EXP_VARIATION = 'popular_and_trending_recommendations';
|
||||
|
||||
export const eventNames = {
|
||||
recommendedCourseClicked: 'welcome_page_recommendation_card_click',
|
||||
recommendationsViewed: 'welcome_page_recommendations_viewed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Activate the post registration recommendations optimizely experiment
|
||||
* and return the true if the user is in variation else false.
|
||||
* @param {String} userId user id of authenticated user.
|
||||
* @return {string} variation the user belong in
|
||||
*/
|
||||
const activateRecommendationsExperiment = (userId) => optimizelyInstance?.activate(RECOMMENDATIONS_EXP_KEY, userId);
|
||||
|
||||
/**
|
||||
* Fire an optimizely track event for post registration recommended course card clicked.
|
||||
* @param {String} userId user id of authenticated user.
|
||||
* @param {Object} userAttributes Dictionary of user attributes (optional).
|
||||
*/
|
||||
const trackRecommendationCardClickOptimizely = (userId, userAttributes = {}) => {
|
||||
optimizelyInstance?.track(eventNames.recommendedCourseClicked, userId, userAttributes);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fire an optimizely track event for post registration recommendation viewed.
|
||||
* @param {String} userId user id of authenticated user.
|
||||
* @param {Object} userAttributes Dictionary of user attributes (optional).
|
||||
*/
|
||||
const trackRecommendationViewedOptimizely = (userId, userAttributes = {}) => {
|
||||
optimizelyInstance?.track(eventNames.recommendationsViewed, userId, userAttributes);
|
||||
};
|
||||
|
||||
export {
|
||||
RECOMMENDATIONS_EXP_VARIATION,
|
||||
activateRecommendationsExperiment,
|
||||
trackRecommendationCardClickOptimizely,
|
||||
trackRecommendationViewedOptimizely,
|
||||
};
|
||||
@@ -1,14 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { getConfig, mergeConfig } from '@edx/frontend-platform';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useMediaQuery } from '@edx/paragon';
|
||||
import { mount } from 'enzyme';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import { DEFAULT_REDIRECT_URL } from '../../data/constants';
|
||||
import { PERSONALIZED } from '../data/constants';
|
||||
import useAlgoliaRecommendations from '../data/hooks/useAlgoliaRecommendations';
|
||||
import mockedRecommendedProducts from '../data/tests/mockedData';
|
||||
import RecommendationsPage from '../RecommendationsPage';
|
||||
import { eventNames, getProductMapping } from '../track';
|
||||
|
||||
const IntlRecommendationsPage = injectIntl(RecommendationsPage);
|
||||
const mockStore = configureStore();
|
||||
@@ -16,22 +22,20 @@ const mockStore = configureStore();
|
||||
jest.mock('@edx/frontend-platform/analytics', () => ({
|
||||
sendTrackEvent: jest.fn(),
|
||||
}));
|
||||
jest.mock('../data/service', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('RecommendationsPageTests', () => {
|
||||
mergeConfig({
|
||||
GENERAL_RECOMMENDATIONS: '[]',
|
||||
POPULAR_PRODUCTS: '[]',
|
||||
TRENDING_PRODUCTS: '[]',
|
||||
});
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
...jest.requireActual('@edx/paragon'),
|
||||
useMediaQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../data/hooks/useAlgoliaRecommendations', () => jest.fn());
|
||||
|
||||
describe('RecommendationsPageTests', () => {
|
||||
let store = {};
|
||||
|
||||
const dashboardUrl = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
@@ -65,6 +69,11 @@ describe('RecommendationsPageTests', () => {
|
||||
useLocation.mockReturnValue({
|
||||
state: {},
|
||||
});
|
||||
|
||||
useAlgoliaRecommendations.mockReturnValue({
|
||||
recommendations: mockedRecommendedProducts,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to dashboard if user is not coming from registration workflow', () => {
|
||||
@@ -72,10 +81,13 @@ describe('RecommendationsPageTests', () => {
|
||||
expect(window.location.href).toEqual(dashboardUrl);
|
||||
});
|
||||
|
||||
it('should redirect if either popular or trending recommendations are not configured', () => {
|
||||
mockUseLocation();
|
||||
it('should redirect user if no personalized recommendations are available', () => {
|
||||
useAlgoliaRecommendations.mockReturnValue({
|
||||
recommendations: [],
|
||||
isLoading: false,
|
||||
});
|
||||
mount(reduxWrapper(<IntlRecommendationsPage />));
|
||||
expect(window.location.href).toEqual(redirectUrl);
|
||||
expect(window.location.href).toEqual(dashboardUrl);
|
||||
});
|
||||
|
||||
it('should redirect user if they click "Skip for now" button', () => {
|
||||
@@ -86,9 +98,67 @@ describe('RecommendationsPageTests', () => {
|
||||
expect(window.location.href).toEqual(redirectUrl);
|
||||
});
|
||||
|
||||
it('displays popular products as default recommendations', () => {
|
||||
it('should display recommendations small layout for small screen', () => {
|
||||
mockUseLocation();
|
||||
useMediaQuery.mockReturnValue(true);
|
||||
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
|
||||
expect(recommendationsPage.find('.nav-link .active a').text()).toEqual('Most Popular');
|
||||
|
||||
expect(recommendationsPage.find('#recommendations-small-layout').exists()).toBeTruthy();
|
||||
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should display recommendations large layout for large screen', () => {
|
||||
mockUseLocation();
|
||||
useMediaQuery.mockReturnValue(false);
|
||||
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
|
||||
|
||||
expect(recommendationsPage.find('.pgn_collapsible').exists()).toBeFalsy();
|
||||
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should display skeletons if recommendations are loading for large screen', () => {
|
||||
mockUseLocation();
|
||||
useMediaQuery.mockReturnValue(false);
|
||||
useAlgoliaRecommendations.mockReturnValueOnce({
|
||||
recommendations: [],
|
||||
isLoading: true,
|
||||
});
|
||||
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
|
||||
|
||||
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display skeletons if recommendations are loading for small screen', () => {
|
||||
mockUseLocation();
|
||||
useMediaQuery.mockReturnValue(true);
|
||||
useAlgoliaRecommendations.mockReturnValueOnce({
|
||||
recommendations: [],
|
||||
isLoading: true,
|
||||
});
|
||||
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
|
||||
|
||||
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should fire recommendations viewed event', () => {
|
||||
mockUseLocation();
|
||||
useAlgoliaRecommendations.mockReturnValue({
|
||||
recommendations: mockedRecommendedProducts,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
useMediaQuery.mockReturnValue(false);
|
||||
mount(reduxWrapper(<IntlRecommendationsPage />));
|
||||
|
||||
expect(sendTrackEvent).toBeCalled();
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith(
|
||||
eventNames.recommendationsViewed,
|
||||
{
|
||||
page: 'authn_recommendations',
|
||||
recommendation_type: PERSONALIZED,
|
||||
products: getProductMapping(mockedRecommendedProducts),
|
||||
user_id: 111,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
131
src/recommendations/tests/track.test.js
Normal file
131
src/recommendations/tests/track.test.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
import {
|
||||
eventNames, trackRecommendationClick, trackRecommendationsViewed, trackSkipButtonClicked,
|
||||
} from '../track';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics', () => ({
|
||||
sendTrackEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('SegmentEventTrackingTest', () => {
|
||||
global.open = jest.fn();
|
||||
const userId = 1;
|
||||
|
||||
it('test click event is fired properly and correct link opens for program product type', async () => {
|
||||
jest.useFakeTimers();
|
||||
const program = {
|
||||
cardType: 'program',
|
||||
title: 'test program',
|
||||
uuid: 'test_uuid',
|
||||
productSource: {
|
||||
name: 'test_source',
|
||||
},
|
||||
recommendationType: 'static',
|
||||
url: 'test_url',
|
||||
};
|
||||
const position = 0;
|
||||
|
||||
trackRecommendationClick(program, position, userId);
|
||||
jest.advanceTimersByTime(300);
|
||||
|
||||
expect(sendTrackEvent).toBeCalled();
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith(
|
||||
eventNames.recommendedProductClicked,
|
||||
{
|
||||
page: 'authn_recommendations',
|
||||
position,
|
||||
product_key: `${program.title} [${program.uuid}]`,
|
||||
product_line: program.cardType,
|
||||
product_source: program.productSource.name,
|
||||
recommendation_type: program.recommendationType,
|
||||
user_id: userId,
|
||||
},
|
||||
);
|
||||
|
||||
expect(global.open).toBeCalled();
|
||||
expect(global.open).toHaveBeenCalledWith(program.url, '_blank');
|
||||
});
|
||||
|
||||
it('test click event is fired properly and correct link opens for course product type', async () => {
|
||||
jest.useFakeTimers();
|
||||
const course = {
|
||||
cardType: 'course',
|
||||
activeRunKey: 'test_key',
|
||||
productSource: {
|
||||
name: 'test_source',
|
||||
},
|
||||
recommendationType: 'static',
|
||||
activeCourseRun: {
|
||||
marketingUrl: 'test_url',
|
||||
},
|
||||
};
|
||||
const position = 0;
|
||||
|
||||
trackRecommendationClick(course, position, userId);
|
||||
jest.advanceTimersByTime(300);
|
||||
|
||||
expect(sendTrackEvent).toBeCalled();
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith(
|
||||
eventNames.recommendedProductClicked,
|
||||
{
|
||||
page: 'authn_recommendations',
|
||||
position,
|
||||
product_key: course.activeRunKey,
|
||||
product_line: course.cardType,
|
||||
product_source: course.productSource.name,
|
||||
recommendation_type: course.recommendationType,
|
||||
user_id: userId,
|
||||
},
|
||||
);
|
||||
|
||||
expect(global.open).toBeCalled();
|
||||
expect(global.open).toHaveBeenCalledWith(course.activeCourseRun.marketingUrl, '_blank');
|
||||
});
|
||||
|
||||
it('test viewed events are fired properly', () => {
|
||||
const productList = [
|
||||
{
|
||||
title: 'Test Program',
|
||||
uuid: '1234-5678-9101-1213',
|
||||
cardType: 'program',
|
||||
productSource: {
|
||||
name: 'org name',
|
||||
},
|
||||
},
|
||||
];
|
||||
const recommendationsType = 'static';
|
||||
const expectedProductList = [
|
||||
{
|
||||
product_key: 'Test Program [1234-5678-9101-1213]',
|
||||
product_line: 'program',
|
||||
product_source: 'org name',
|
||||
},
|
||||
];
|
||||
|
||||
trackRecommendationsViewed(productList, recommendationsType, userId);
|
||||
expect(sendTrackEvent).toBeCalled();
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith(
|
||||
eventNames.recommendationsViewed,
|
||||
{
|
||||
page: 'authn_recommendations',
|
||||
products: expectedProductList,
|
||||
recommendation_type: recommendationsType,
|
||||
user_id: userId,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('test skip button event is fired with correct properties', () => {
|
||||
trackSkipButtonClicked(userId);
|
||||
|
||||
expect(sendTrackEvent).toBeCalled();
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith(
|
||||
eventNames.skipButtonClicked,
|
||||
{
|
||||
page: 'authn_recommendations',
|
||||
user_id: userId,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,25 +4,21 @@ export const LINK_TIMEOUT = 300;
|
||||
|
||||
export const eventNames = {
|
||||
recommendedProductClicked: 'edx.bi.user.recommended.product.clicked',
|
||||
recommendationsGroup: 'edx.bi.user.recommendations.group',
|
||||
recommendationsViewed: 'edx.bi.user.recommendations.viewed',
|
||||
skipButtonClicked: 'edx.bi.user.recommendations.skip.btn.clicked',
|
||||
};
|
||||
|
||||
export const createLinkTracker = (tracker, href, openInNewTab = false) => (e) => {
|
||||
e.preventDefault();
|
||||
tracker();
|
||||
if (openInNewTab) {
|
||||
return setTimeout(() => { global.open(href, '_blank'); }, LINK_TIMEOUT);
|
||||
}
|
||||
return setTimeout(() => { global.location.href = href; }, LINK_TIMEOUT);
|
||||
};
|
||||
const generateProductKey = (product) => (
|
||||
product.cardType === 'program' ? `${product.title} [${product.uuid}]` : product.activeRunKey
|
||||
);
|
||||
|
||||
const generateProductKey = (product) => {
|
||||
const productKey = product.cardType === 'program' ? `${product.title} [${product.uuid}]` : product.activeRunKey;
|
||||
return productKey;
|
||||
};
|
||||
export const getProductMapping = (recommendedProducts) => recommendedProducts.map((product) => ({
|
||||
product_key: generateProductKey(product),
|
||||
product_line: product.cardType,
|
||||
product_source: product.productSource.name,
|
||||
}));
|
||||
|
||||
export const trackRecommendationClick = (product, position, isControl, userId) => {
|
||||
export const trackRecommendationClick = (product, position, userId) => {
|
||||
sendTrackEvent(eventNames.recommendedProductClicked, {
|
||||
page: 'authn_recommendations',
|
||||
position,
|
||||
@@ -30,33 +26,31 @@ export const trackRecommendationClick = (product, position, isControl, userId) =
|
||||
product_key: generateProductKey(product),
|
||||
product_line: product.cardType,
|
||||
product_source: product.productSource.name,
|
||||
is_control: isControl,
|
||||
user_id: userId,
|
||||
});
|
||||
return setTimeout(() => { global.open(product.url, '_blank'); }, LINK_TIMEOUT);
|
||||
|
||||
const productUrl = product.url || product?.activeCourseRun?.marketingUrl;
|
||||
return setTimeout(() => { global.open(productUrl, '_blank'); }, LINK_TIMEOUT);
|
||||
};
|
||||
|
||||
export const trackRecommendationsViewed = (recommendedProducts, type, isControl, userId) => {
|
||||
const viewedProductsList = recommendedProducts.map((product) => ({
|
||||
product_key: generateProductKey(product),
|
||||
product_line: product.cardType,
|
||||
product_source: product.productSource.name,
|
||||
}));
|
||||
sendTrackEvent(
|
||||
eventNames.recommendationsViewed, {
|
||||
page: 'authn_recommendations',
|
||||
recommendation_type: type,
|
||||
products: viewedProductsList,
|
||||
is_control: isControl,
|
||||
user_id: userId,
|
||||
},
|
||||
);
|
||||
export const trackRecommendationsViewed = (recommendedProducts, type, userId) => {
|
||||
const viewedProductsList = getProductMapping(recommendedProducts);
|
||||
|
||||
if (viewedProductsList && viewedProductsList.length) {
|
||||
sendTrackEvent(
|
||||
eventNames.recommendationsViewed, {
|
||||
page: 'authn_recommendations',
|
||||
recommendation_type: type,
|
||||
products: viewedProductsList,
|
||||
user_id: userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackRecommendationsGroup = (variation, userId) => {
|
||||
export const trackSkipButtonClicked = (userId) => {
|
||||
sendTrackEvent(
|
||||
eventNames.recommendationsGroup, {
|
||||
variation,
|
||||
eventNames.skipButtonClicked, {
|
||||
page: 'authn_recommendations',
|
||||
user_id: userId,
|
||||
},
|
||||
@@ -65,6 +59,6 @@ export const trackRecommendationsGroup = (variation, userId) => {
|
||||
|
||||
export default {
|
||||
trackRecommendationClick,
|
||||
trackRecommendationsGroup,
|
||||
trackRecommendationsViewed,
|
||||
trackSkipButtonClicked,
|
||||
};
|
||||
|
||||
@@ -1,78 +1,31 @@
|
||||
.nav-tabs {
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
$card-gap: 24px;
|
||||
|
||||
.recommendations-container__card-list {
|
||||
padding-left: 0.0625rem;
|
||||
padding-bottom: 0.125rem;
|
||||
gap: $card-gap $card-gap;
|
||||
|
||||
@include media-breakpoint-down(xl) {
|
||||
overflow-x: scroll;
|
||||
overflow-y: hidden;
|
||||
@include media-breakpoint-down(sm) {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.recommendation-card {
|
||||
flex: 0 1 100%;
|
||||
cursor: pointer;
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
flex: 0 1 calc(50% - #{$card-gap - 12});
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
flex: 0 1 calc(33.333% - #{$card-gap - 8});
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
flex: 0 1 calc(25% - #{$card-gap - 6});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recommendations-container__heading {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.recommendations-container {
|
||||
padding: 0 1rem;
|
||||
margin: 0 0 1.875rem 0;
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
max-width: $max-width-sm + 5 * $grid-gutter-width !important;
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(xl) {
|
||||
max-width: $max-width-md + $grid-gutter-width !important;
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(xxl) {
|
||||
max-width: $max-width-lg + $grid-gutter-width !important;
|
||||
}
|
||||
}
|
||||
|
||||
.recommendation-card {
|
||||
cursor: pointer;
|
||||
.pgn__hyperlink {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pgn__card {
|
||||
width: 281px;
|
||||
height: 332px;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.pgn__card-image-cap {
|
||||
height: 6.5rem;
|
||||
object-position: top;
|
||||
}
|
||||
|
||||
.pgn__card-header-title-md {
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.pgn__card-header-subtitle-md{
|
||||
font-weight: 400;
|
||||
font-size:0.875rem;
|
||||
line-height: 1.5rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
.pgn__card-footer {
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
padding-bottom: 1rem !important;
|
||||
}
|
||||
.pgn__card__footer-text{
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
}
|
||||
.footer-icon{
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user