@shopgate/pwa-common 7.32.0-beta.2 → 7.32.0-beta.21
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/actions/app/handleLink.js +6 -2
- package/components/Button/index.js +6 -0
- package/components/Image/Image.d.ts +78 -0
- package/components/Image/Image.d.ts.map +1 -0
- package/components/Image/Image.js +123 -91
- package/components/Image/ImageInner.d.ts +45 -0
- package/components/Image/ImageInner.d.ts.map +1 -0
- package/components/Image/ImageInner.js +23 -29
- package/components/Image/index.d.ts +3 -0
- package/components/Image/index.d.ts.map +1 -0
- package/components/Swiper/index.js +17 -2
- package/constants/Configuration.js +7 -0
- package/helpers/html/decodeHTML.js +5 -3
- package/helpers/html/htmlToText.js +15 -0
- package/helpers/mocks/mockRenderOptions.js +21 -0
- package/helpers/router/index.js +85 -0
- package/package.json +3 -3
- package/providers/toast/index.js +54 -40
- package/subscriptions/error.js +50 -27
- package/subscriptions/helpers/pipeline.js +68 -0
- package/subscriptions/router.js +22 -8
- package/action-creators/app/spec.js +0 -96
- package/action-creators/client/spec.js +0 -44
- package/action-creators/menu/spec.js +0 -37
- package/action-creators/modal/spec.js +0 -26
- package/action-creators/page/spec.js +0 -38
- package/action-creators/url/spec.js +0 -45
- package/action-creators/user/spec.js +0 -186
- package/components/Backdrop/spec.js +0 -24
- package/components/Button/spec.js +0 -42
- package/components/Checkbox/spec.js +0 -111
- package/components/CountdownTimer/spec.js +0 -141
- package/components/Drawer/spec.js +0 -79
- package/components/Ellipsis/spec.js +0 -15
- package/components/EmbeddedMedia/spec.js +0 -61
- package/components/Grid/components/Item/spec.js +0 -24
- package/components/Grid/spec.js +0 -24
- package/components/HtmlSanitizer/spec.js +0 -229
- package/components/I18n/components/FormatDate/spec.js +0 -54
- package/components/I18n/components/FormatNumber/spec.js +0 -51
- package/components/I18n/components/FormatPrice/spec.js +0 -54
- package/components/I18n/components/FormatTime/spec.js +0 -51
- package/components/I18n/components/I18nProvider/spec.js +0 -40
- package/components/I18n/components/Placeholder/spec.js +0 -37
- package/components/I18n/components/Translate/spec.js +0 -33
- package/components/InfiniteContainer/spec.js +0 -204
- package/components/Input/spec.js +0 -123
- package/components/Link/spec.js +0 -60
- package/components/List/spec.js +0 -26
- package/components/ModalContainer/spec.js +0 -115
- package/components/Select/spec.js +0 -122
- package/components/SelectBox/spec.js +0 -68
- package/components/Swiper/components/SwiperItem/spec.js +0 -26
- package/components/Widgets/components/Widget/spec.js +0 -75
- package/components/Widgets/components/WidgetGrid/spec.js +0 -53
- package/components/Widgets/spec.js +0 -234
- package/helpers/data/spec.js +0 -194
- package/helpers/date/spec.js +0 -92
- package/helpers/style/spec.js +0 -108
- package/helpers/validation/spec.js +0 -10
- package/styles/reset/form.js +0 -58
- package/styles/reset/index.js +0 -8
- package/styles/reset/media.js +0 -26
- package/styles/reset/root.js +0 -37
- package/styles/reset/table.js +0 -14
- package/styles/reset/typography.js +0 -30
- package/tsconfig.build.json +0 -16
- package/tsconfig.json +0 -3
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import PropTypes from 'prop-types';
|
|
2
|
+
const options = {
|
|
3
|
+
context: {
|
|
4
|
+
i18n: () => ({
|
|
5
|
+
// eslint-disable-next-line no-underscore-dangle
|
|
6
|
+
__(input) {
|
|
7
|
+
if (input) {
|
|
8
|
+
return input;
|
|
9
|
+
}
|
|
10
|
+
return '';
|
|
11
|
+
},
|
|
12
|
+
_p: () => 'p',
|
|
13
|
+
_d: () => 'd',
|
|
14
|
+
_n: () => 'n'
|
|
15
|
+
})
|
|
16
|
+
},
|
|
17
|
+
childContextTypes: {
|
|
18
|
+
i18n: PropTypes.func
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
export default options;
|
package/helpers/router/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import "core-js/modules/es.array.reduce.js";
|
|
2
|
+
import "core-js/modules/es.string.replace.js";
|
|
2
3
|
import "core-js/modules/web.url-search-params.js";
|
|
3
4
|
import { createBrowserHistory } from 'history';
|
|
4
5
|
import { router } from '@virtuous/conductor';
|
|
6
|
+
import { logger } from '@shopgate/pwa-core/helpers';
|
|
5
7
|
const match = /^(.*)index.html/.exec(window.location.pathname);
|
|
6
8
|
const {
|
|
7
9
|
getCurrentRoute
|
|
@@ -56,4 +58,87 @@ export const parseObjectToQueryString = (obj, includePrefix = true) => {
|
|
|
56
58
|
return `?${urlParams.toString()}`;
|
|
57
59
|
}
|
|
58
60
|
return urlParams.toString();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Angle brackets and their HTML entity representations.
|
|
64
|
+
const ANGLE_BRACKET_ENTITIES = '&(lt|gt|#0*6[02]|#x0*3[ce]);?';
|
|
65
|
+
const UNSAFE_CHARS_REGEX = new RegExp(`[<>]|${ANGLE_BRACKET_ENTITIES}`, 'i');
|
|
66
|
+
const UNSAFE_PROTOCOL_REGEX = /^\s*(javascript|data|vbscript):/i;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Removes HTML tags, angle brackets and their entity representations from a string.
|
|
70
|
+
* @param {string} value The (decoded) value.
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
const stripHtml = value => value.replace(new RegExp(ANGLE_BRACKET_ENTITIES, 'gi'), '').replace(/<[^>]*>/g, '').replace(/[<>]/g, '');
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Removes HTML from a single URI component. The component is only modified when it contains
|
|
77
|
+
* suspicious characters, so regular components (e.g. encoded product ids) stay untouched.
|
|
78
|
+
* @param {string} component A single URI encoded component like a path segment.
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
const sanitizeUriComponent = component => {
|
|
82
|
+
let decoded;
|
|
83
|
+
try {
|
|
84
|
+
decoded = decodeURIComponent(component);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
// Malformed encoding - fall back to the raw component.
|
|
87
|
+
decoded = component;
|
|
88
|
+
}
|
|
89
|
+
if (!UNSAFE_CHARS_REGEX.test(decoded)) {
|
|
90
|
+
return component;
|
|
91
|
+
}
|
|
92
|
+
return encodeURIComponent(stripHtml(decoded));
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Removes HTML from a decoded query parameter key or value.
|
|
97
|
+
* @param {string} value The decoded value.
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
const sanitizeQueryValue = value => UNSAFE_CHARS_REGEX.test(value) ? stripHtml(value) : value;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Sanitizes a link which was received from an external source (e.g. deep links or push messages)
|
|
104
|
+
* before it's passed to the router. HTML markup is removed from the path, query and hash to prevent
|
|
105
|
+
* that it's rendered somewhere within the app. Links with a script protocol are rejected.
|
|
106
|
+
* Links without suspicious content are returned unchanged.
|
|
107
|
+
* @param {string} link The link to sanitize.
|
|
108
|
+
* @returns {string} The sanitized link or an empty string when the link was rejected.
|
|
109
|
+
*/
|
|
110
|
+
export const sanitizeLink = link => {
|
|
111
|
+
if (typeof link !== 'string' || link === '') {
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
if (UNSAFE_PROTOCOL_REGEX.test(link)) {
|
|
115
|
+
logger.warn('sanitizeLink: Rejected link with unsafe protocol', link);
|
|
116
|
+
return '';
|
|
117
|
+
}
|
|
118
|
+
const hashIndex = link.indexOf('#');
|
|
119
|
+
const beforeHash = hashIndex === -1 ? link : link.slice(0, hashIndex);
|
|
120
|
+
const hash = hashIndex === -1 ? null : link.slice(hashIndex + 1);
|
|
121
|
+
const queryIndex = beforeHash.indexOf('?');
|
|
122
|
+
const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);
|
|
123
|
+
const query = queryIndex === -1 ? null : beforeHash.slice(queryIndex + 1);
|
|
124
|
+
let sanitized = base.split('/').map(sanitizeUriComponent).join('/');
|
|
125
|
+
if (query !== null) {
|
|
126
|
+
const params = new URLSearchParams(query);
|
|
127
|
+
const sanitizedParams = new URLSearchParams();
|
|
128
|
+
let modified = false;
|
|
129
|
+
params.forEach((value, key) => {
|
|
130
|
+
const sanitizedKey = sanitizeQueryValue(key);
|
|
131
|
+
const sanitizedValue = sanitizeQueryValue(value);
|
|
132
|
+
modified = modified || sanitizedKey !== key || sanitizedValue !== value;
|
|
133
|
+
sanitizedParams.append(sanitizedKey, sanitizedValue);
|
|
134
|
+
});
|
|
135
|
+
sanitized += `?${modified ? sanitizedParams.toString() : query}`;
|
|
136
|
+
}
|
|
137
|
+
if (hash !== null) {
|
|
138
|
+
sanitized += `#${sanitizeUriComponent(hash)}`;
|
|
139
|
+
}
|
|
140
|
+
if (sanitized !== link) {
|
|
141
|
+
logger.warn('sanitizeLink: Removed unsafe content from link', link);
|
|
142
|
+
}
|
|
143
|
+
return sanitized;
|
|
59
144
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shopgate/pwa-common",
|
|
3
|
-
"version": "7.32.0-beta.
|
|
3
|
+
"version": "7.32.0-beta.21",
|
|
4
4
|
"description": "Common library for the Shopgate Connect PWA.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Shopgate <support@shopgate.com>",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@redux-devtools/extension": "^3.3.0",
|
|
19
19
|
"@sentry/browser": "6.0.1",
|
|
20
|
-
"@shopgate/pwa-benchmark": "7.32.0-beta.
|
|
20
|
+
"@shopgate/pwa-benchmark": "7.32.0-beta.21",
|
|
21
21
|
"@virtuous/conductor": "~2.5.0",
|
|
22
22
|
"@virtuous/react-conductor": "~2.5.0",
|
|
23
23
|
"@virtuous/redux-persister": "1.1.0-beta.7",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"swiper": "12.2.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@shopgate/pwa-core": "7.32.0-beta.
|
|
43
|
+
"@shopgate/pwa-core": "7.32.0-beta.21",
|
|
44
44
|
"@types/lodash": "^4.17.24",
|
|
45
45
|
"@types/react-portal": "^3.0.9",
|
|
46
46
|
"lodash": "^4.17.23",
|
package/providers/toast/index.js
CHANGED
|
@@ -32,47 +32,41 @@ let ToastProvider = /*#__PURE__*/function (_Component) {
|
|
|
32
32
|
if (!toast.message) {
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
-
const {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
const nextToast = {
|
|
36
|
+
id: toast.id,
|
|
37
|
+
action: toast.action,
|
|
38
|
+
actionLabel: toast.actionLabel,
|
|
39
|
+
onLongPress: toast.onLongPress,
|
|
40
|
+
message: toast.message,
|
|
41
|
+
messageParams: toast.messageParams,
|
|
42
|
+
duration: toast.duration || _this.props.theme.transitions.duration.toast
|
|
43
|
+
};
|
|
43
44
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
found.message = toast.message;
|
|
49
|
-
found.messageParams = toast.messageParams;
|
|
50
|
-
found.duration = toast.duration || _this.props.theme.transitions.duration.toast;
|
|
51
|
-
} else {
|
|
52
|
-
toasts.push({
|
|
53
|
-
id: toast.id,
|
|
54
|
-
action: toast.action,
|
|
55
|
-
actionLabel: toast.actionLabel,
|
|
56
|
-
message: toast.message,
|
|
57
|
-
messageParams: toast.messageParams,
|
|
58
|
-
duration: toast.duration || _this.props.theme.transitions.duration.toast
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
_this.setState({
|
|
45
|
+
// Update the queue immutably: consumers rely on the array reference changing to re-render
|
|
46
|
+
// (e.g. the SnackBar memoizes the currently shown toast on this reference). A toast whose id
|
|
47
|
+
// is already queued replaces that entry with the new data; otherwise it is appended.
|
|
48
|
+
_this.setState(({
|
|
62
49
|
toasts
|
|
50
|
+
}) => {
|
|
51
|
+
const exists = toasts.some(({
|
|
52
|
+
id
|
|
53
|
+
}) => id === toast.id);
|
|
54
|
+
return {
|
|
55
|
+
toasts: exists ? toasts.map(item => item.id === toast.id ? nextToast : item) : [].concat(toasts, [nextToast])
|
|
56
|
+
};
|
|
63
57
|
});
|
|
64
58
|
};
|
|
65
59
|
/**
|
|
66
60
|
* Removes the first toast from the list.
|
|
67
61
|
*/
|
|
68
62
|
_this.removeToast = () => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
toasts.shift();
|
|
73
|
-
_this.setState({
|
|
63
|
+
// Drop the first toast immutably so the array reference changes and the SnackBar advances to
|
|
64
|
+
// the next queued toast (rather than re-rendering the same, now-stale, memoized toast).
|
|
65
|
+
_this.setState(({
|
|
74
66
|
toasts
|
|
75
|
-
})
|
|
67
|
+
}) => ({
|
|
68
|
+
toasts: toasts.slice(1)
|
|
69
|
+
}));
|
|
76
70
|
};
|
|
77
71
|
_this.flushToasts = () => {
|
|
78
72
|
if (_this.state.toasts.length) {
|
|
@@ -90,11 +84,21 @@ let ToastProvider = /*#__PURE__*/function (_Component) {
|
|
|
90
84
|
}
|
|
91
85
|
|
|
92
86
|
/**
|
|
93
|
-
*
|
|
94
|
-
* @returns {Object}
|
|
87
|
+
* Removes the UIEvents listeners registered in the constructor.
|
|
95
88
|
*/
|
|
96
89
|
_inheritsLoose(ToastProvider, _Component);
|
|
97
90
|
var _proto = ToastProvider.prototype;
|
|
91
|
+
_proto.componentWillUnmount = function componentWillUnmount() {
|
|
92
|
+
UIEvents.removeListener(this.constructor.ADD, this.addToast);
|
|
93
|
+
UIEvents.removeListener(this.constructor.FLUSH, this.flushToasts);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Returns the context value to be passed to consumers. The object reference is cached and only
|
|
98
|
+
* rebuilt when `toasts` changes, so consumers (Toaster → SnackBarContainer → SnackBar) don't
|
|
99
|
+
* re-render on every unrelated ToastProvider render.
|
|
100
|
+
* @returns {Object}
|
|
101
|
+
*/;
|
|
98
102
|
/**
|
|
99
103
|
* @returns {JSX}
|
|
100
104
|
*/
|
|
@@ -107,14 +111,24 @@ let ToastProvider = /*#__PURE__*/function (_Component) {
|
|
|
107
111
|
return _createClass(ToastProvider, [{
|
|
108
112
|
key: "provided",
|
|
109
113
|
get: function () {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
if (!this.providedCache || this.providedCache.toasts !== this.state.toasts) {
|
|
115
|
+
this.providedCache = {
|
|
116
|
+
addToast: this.addToast,
|
|
117
|
+
removeToast: this.removeToast,
|
|
118
|
+
toasts: this.state.toasts
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return this.providedCache;
|
|
115
122
|
}
|
|
116
123
|
}]);
|
|
117
124
|
}(Component);
|
|
118
125
|
ToastProvider.ADD = 'toast_add';
|
|
119
126
|
ToastProvider.FLUSH = 'toast_flush';
|
|
120
|
-
|
|
127
|
+
const ThemedToastProvider = withTheme(ToastProvider);
|
|
128
|
+
|
|
129
|
+
// The HOC returns a new component which doesn't carry over the statics of the wrapped class. They
|
|
130
|
+
// are part of the public API of this module (consumers emit UIEvents on ToastProvider.ADD), so they
|
|
131
|
+
// are re-attached explicitly.
|
|
132
|
+
ThemedToastProvider.ADD = ToastProvider.ADD;
|
|
133
|
+
ThemedToastProvider.FLUSH = ToastProvider.FLUSH;
|
|
134
|
+
export default ThemedToastProvider;
|
package/subscriptions/error.js
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import "core-js/modules/es.array.includes.js";
|
|
2
|
-
import after from 'lodash/after';
|
|
3
|
-
import before from 'lodash/before';
|
|
4
|
-
import over from 'lodash/over';
|
|
5
2
|
import { isAvailable } from '@shopgate/native-modules';
|
|
6
3
|
import { init, addBreadcrumb, configureScope, captureException, captureMessage, captureEvent, withScope, Severity as SentrySeverity } from '@sentry/browser';
|
|
7
4
|
import { EBIGAPI, emitter, errorManager, ETIMEOUT, ENETUNREACH, EUNKNOWN, EFAVORITE } from '@shopgate/pwa-core';
|
|
@@ -12,7 +9,7 @@ import {
|
|
|
12
9
|
// eslint-disable-next-line import/no-named-default
|
|
13
10
|
default as appConfig, themeName, pckVersion } from "../helpers/config";
|
|
14
11
|
import { env } from "../helpers/environment";
|
|
15
|
-
import { transformGeneralPipelineError } from "./helpers/pipeline";
|
|
12
|
+
import { transformGeneralPipelineError, getDisplayErrorMessage } from "./helpers/pipeline";
|
|
16
13
|
import { historyPop } from "../actions/router";
|
|
17
14
|
import showModal from "../actions/modal/showModal";
|
|
18
15
|
import { getUserData } from "../selectors/user";
|
|
@@ -24,6 +21,9 @@ import { getRouterStack } from "../selectors/router";
|
|
|
24
21
|
import { MODAL_PIPELINE_ERROR } from "../constants/ModalTypes";
|
|
25
22
|
import ToastProvider from "../providers/toast";
|
|
26
23
|
|
|
24
|
+
// Generic, translated fallback shown when a backend error carries no code we can map to a message.
|
|
25
|
+
const GENERIC_ERROR_MESSAGE = 'modal.body_error';
|
|
26
|
+
|
|
27
27
|
/**
|
|
28
28
|
* App errors subscriptions.
|
|
29
29
|
* @param {Function} subscribe The subscribe function.
|
|
@@ -60,15 +60,35 @@ export default subscribe => {
|
|
|
60
60
|
error
|
|
61
61
|
} = action;
|
|
62
62
|
const {
|
|
63
|
-
message,
|
|
64
63
|
code,
|
|
65
64
|
context,
|
|
66
65
|
meta = {}
|
|
67
66
|
} = error;
|
|
68
67
|
const {
|
|
69
|
-
behavior
|
|
68
|
+
behavior,
|
|
69
|
+
message: originalMessage
|
|
70
70
|
} = meta;
|
|
71
|
-
|
|
71
|
+
|
|
72
|
+
// Never surface a raw backend message to the user: show the extension's own translated
|
|
73
|
+
// message, a code-mapped translated message, or a generic fallback. The resolver is the single
|
|
74
|
+
// source of truth for both the text and whether it is ready-to-display (`displayTranslated`) or
|
|
75
|
+
// a locale key that must still go through I18n.Text.
|
|
76
|
+
const {
|
|
77
|
+
message: displayMessage,
|
|
78
|
+
translated: displayTranslated
|
|
79
|
+
} = getDisplayErrorMessage(error, GENERIC_ERROR_MESSAGE);
|
|
80
|
+
|
|
81
|
+
// Unknown/generic and connection-style backend errors are treated as general platform issues.
|
|
82
|
+
// EUNKNOWN is always treated as general — even when the backend supplied a translated message —
|
|
83
|
+
// so it is shown as a toast with its translated message or generic fallback, never a modal.
|
|
84
|
+
const isConnectionError = displayMessage === 'error.general' || [ETIMEOUT, ENETUNREACH].includes(code);
|
|
85
|
+
const isGeneralError = isConnectionError || displayMessage === GENERIC_ERROR_MESSAGE || code === EUNKNOWN;
|
|
86
|
+
|
|
87
|
+
// Some pipeline actions (e.g. fetchCategory) register an error behavior for *every* error via
|
|
88
|
+
// `setResponseBehavior`. Those behaviors are only meaningful for the specific errors they
|
|
89
|
+
// expect; when the platform fails with an unexpected/unmappable message we skip the behavior
|
|
90
|
+
// and fall through to the generic toast handling instead of, for example, showing a modal.
|
|
91
|
+
if (behavior && !isGeneralError) {
|
|
72
92
|
behavior({
|
|
73
93
|
dispatch,
|
|
74
94
|
getState,
|
|
@@ -78,38 +98,41 @@ export default subscribe => {
|
|
|
78
98
|
return;
|
|
79
99
|
}
|
|
80
100
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Shows the pipeline error modal. When openWithDetails is set, the modal opens directly on the
|
|
103
|
+
* developer detail view (pipeline, code, raw message, params) — used for the toast long-press.
|
|
104
|
+
* @param {boolean} [openWithDetails=false] Whether to open the modal in developer detail mode.
|
|
105
|
+
*/
|
|
106
|
+
const showModalError = (openWithDetails = false) => {
|
|
83
107
|
dispatch(showModal({
|
|
84
108
|
confirm: 'modal.ok',
|
|
85
109
|
dismiss: null,
|
|
86
110
|
title: null,
|
|
87
|
-
message,
|
|
111
|
+
message: displayMessage,
|
|
88
112
|
type: MODAL_PIPELINE_ERROR,
|
|
89
113
|
params: {
|
|
90
114
|
pipeline: context,
|
|
91
115
|
request: meta.input,
|
|
92
|
-
message:
|
|
93
|
-
code
|
|
116
|
+
message: originalMessage,
|
|
117
|
+
code,
|
|
118
|
+
translated: displayTranslated,
|
|
119
|
+
messageParams: meta.additionalParams,
|
|
120
|
+
openWithDetails
|
|
94
121
|
}
|
|
95
122
|
}));
|
|
96
123
|
};
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
message: 'error.general',
|
|
109
|
-
action: over([showToast, showToastAfter])
|
|
110
|
-
});
|
|
124
|
+
|
|
125
|
+
// General platform errors surface as a toast instead of a modal.
|
|
126
|
+
// Long-pressing the toast opens the error modal in developer detail mode.
|
|
127
|
+
if (isGeneralError) {
|
|
128
|
+
// Connection-style errors show the generic connection message, but never override a message
|
|
129
|
+
// an extension already translated for us.
|
|
130
|
+
const useGenericConnectionText = isConnectionError && !displayTranslated;
|
|
131
|
+
events.emit(ToastProvider.ADD, {
|
|
132
|
+
id: 'pipeline.error',
|
|
133
|
+
message: useGenericConnectionText ? 'error.general' : displayMessage,
|
|
134
|
+
onLongPress: () => showModalError(true)
|
|
111
135
|
});
|
|
112
|
-
showToast();
|
|
113
136
|
return;
|
|
114
137
|
}
|
|
115
138
|
showModalError();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { i18n } from '@shopgate/engage/core/helpers';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @param {Object} error error
|
|
3
5
|
* @returns {string|*}
|
|
@@ -12,4 +14,70 @@ export function transformGeneralPipelineError(error) {
|
|
|
12
14
|
return 'error.general';
|
|
13
15
|
}
|
|
14
16
|
return message;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} DisplayErrorMessage
|
|
21
|
+
* @property {string} message The text to display — an already-translated string or a locale key.
|
|
22
|
+
* @property {boolean} translated Whether `message` is ready-to-display text (`true`) or a locale
|
|
23
|
+
* key that still needs to go through I18n.Text (`false`).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolves the message to display for a pipeline error, without ever surfacing raw backend text.
|
|
28
|
+
* Also reports whether the result is ready-to-display text or a locale key
|
|
29
|
+
* Precedence:
|
|
30
|
+
* 1. An extension's own message when it is explicitly flagged as already translated.
|
|
31
|
+
* 2. A resolvable i18n key — safe to surface even when errorManager did not remap it, because a
|
|
32
|
+
* translation key (e.g. `cart.error_out_of_stock`) is not raw backend text. `i18n.has` confirms
|
|
33
|
+
* the key actually resolves in the loaded locales, rather than merely looking like a key.
|
|
34
|
+
* 3. The code-mapped message. When errorManager maps the error code it resolves a message that
|
|
35
|
+
* differs from the raw backend text (`error.meta.message`).
|
|
36
|
+
* 4. A generic, translated fallback otherwise — including when nothing mapped and errorManager fell
|
|
37
|
+
* back to the raw backend message (`error.message === error.meta.message`).
|
|
38
|
+
* @param {Object} error The queued pipeline error.
|
|
39
|
+
* @param {string} error.message The message resolved by errorManager (code-mapped or raw fallback).
|
|
40
|
+
* @param {Object} [error.meta] The error meta data.
|
|
41
|
+
* @param {boolean} [error.meta.translated] Whether the backend message is already translated.
|
|
42
|
+
* @param {string} [error.meta.message] The original, raw backend message.
|
|
43
|
+
* @param {string} genericMessage The generic fallback message key.
|
|
44
|
+
* @returns {DisplayErrorMessage}
|
|
45
|
+
*/
|
|
46
|
+
export function getDisplayErrorMessage(error, genericMessage) {
|
|
47
|
+
const {
|
|
48
|
+
message,
|
|
49
|
+
meta = {}
|
|
50
|
+
} = error;
|
|
51
|
+
const {
|
|
52
|
+
translated,
|
|
53
|
+
message: originalMessage
|
|
54
|
+
} = meta;
|
|
55
|
+
if (translated && originalMessage) {
|
|
56
|
+
return {
|
|
57
|
+
message: originalMessage,
|
|
58
|
+
translated: true
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A translation key is never raw backend text, so surface it even when errorManager left it
|
|
63
|
+
// untouched (`message === originalMessage`).
|
|
64
|
+
if (typeof message === 'string' && i18n.has(message)) {
|
|
65
|
+
return {
|
|
66
|
+
message,
|
|
67
|
+
translated: false
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Only trust `message` when we have the raw backend message to compare against and a mapping
|
|
72
|
+
// actually replaced it. Without the raw message we cannot rule out that `message` is raw text.
|
|
73
|
+
if (originalMessage !== undefined && message !== originalMessage) {
|
|
74
|
+
return {
|
|
75
|
+
message,
|
|
76
|
+
translated: false
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
message: genericMessage,
|
|
81
|
+
translated: false
|
|
82
|
+
};
|
|
15
83
|
}
|
package/subscriptions/router.js
CHANGED
|
@@ -9,7 +9,7 @@ import Route from '@virtuous/conductor/Route';
|
|
|
9
9
|
import { Linking } from '@shopgate/native-modules';
|
|
10
10
|
import { HISTORY_RESET_TO } from '@shopgate/pwa-common/constants/ActionTypes';
|
|
11
11
|
import { logger } from '@shopgate/pwa-core';
|
|
12
|
-
import {
|
|
12
|
+
import { isNavigationBlocked } from '@shopgate/engage/admin-preview/helpers';
|
|
13
13
|
import addCouponsToCart from '@shopgate/pwa-common-commerce/cart/actions/addCouponsToCart';
|
|
14
14
|
import { getCurrentRoute, getRouterStackIndex } from "../selectors/router";
|
|
15
15
|
import { LoadingProvider } from "../providers";
|
|
@@ -21,6 +21,7 @@ import { navigate$, userDidLogin$, appWillStart$, windowOpenOverride$ } from "..
|
|
|
21
21
|
import { isUserLoggedIn } from "../selectors/user";
|
|
22
22
|
import { getIsConnected } from "../selectors/client";
|
|
23
23
|
import { INDEX_PATH } from "../constants/RoutePaths";
|
|
24
|
+
import { sanitizeLink } from "../helpers/router";
|
|
24
25
|
import appConfig from "../helpers/config";
|
|
25
26
|
import authRoutes from "../collections/AuthRoutes";
|
|
26
27
|
import ToastProvider from "../providers/toast";
|
|
@@ -31,7 +32,7 @@ import ToastProvider from "../providers/toast";
|
|
|
31
32
|
*/
|
|
32
33
|
export default function routerSubscriptions(subscribe) {
|
|
33
34
|
subscribe(navigate$, async params => {
|
|
34
|
-
if (
|
|
35
|
+
if (isNavigationBlocked()) {
|
|
35
36
|
// No navigation is allowed in page preview mode.
|
|
36
37
|
return;
|
|
37
38
|
}
|
|
@@ -108,6 +109,10 @@ export default function routerSubscriptions(subscribe) {
|
|
|
108
109
|
}
|
|
109
110
|
case HISTORY_RESET_TO:
|
|
110
111
|
{
|
|
112
|
+
const sanitizedResetToPathname = sanitizeLink(String(resetToPathname || ''));
|
|
113
|
+
if (!sanitizedResetToPathname) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
111
116
|
await router.pop({
|
|
112
117
|
steps: historyLength - 1,
|
|
113
118
|
state: routeState,
|
|
@@ -115,7 +120,7 @@ export default function routerSubscriptions(subscribe) {
|
|
|
115
120
|
emitAfter: false
|
|
116
121
|
});
|
|
117
122
|
await router.replace({
|
|
118
|
-
pathname:
|
|
123
|
+
pathname: sanitizedResetToPathname,
|
|
119
124
|
state: routeState
|
|
120
125
|
});
|
|
121
126
|
return;
|
|
@@ -124,6 +129,13 @@ export default function routerSubscriptions(subscribe) {
|
|
|
124
129
|
break;
|
|
125
130
|
}
|
|
126
131
|
|
|
132
|
+
// Remove HTML markup from the location (e.g. within query parameters of links from CMS content)
|
|
133
|
+
// to prevent that it's rendered within pages or sent within requests. Links with a script
|
|
134
|
+
// protocol are rejected.
|
|
135
|
+
if (location) {
|
|
136
|
+
location = sanitizeLink(String(location));
|
|
137
|
+
}
|
|
138
|
+
|
|
127
139
|
// Remove trailing slashes from internal links, since they might break the routing mechanism.
|
|
128
140
|
// External links are treated as valid, since we don't know about the requirements at the
|
|
129
141
|
// 3rd party server (e.g. google maps links might require trailing slashes).
|
|
@@ -331,9 +343,10 @@ export default function routerSubscriptions(subscribe) {
|
|
|
331
343
|
* so that the router can decide how to handle the URL.
|
|
332
344
|
*/
|
|
333
345
|
Linking.addEventListener('windowOpenRequested', event => {
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
346
|
+
const targetUrl = sanitizeLink(String(event.detail?.targetUrl || ''));
|
|
347
|
+
if (!targetUrl) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
337
350
|
dispatch(historyPush({
|
|
338
351
|
pathname: targetUrl,
|
|
339
352
|
state: {}
|
|
@@ -371,9 +384,10 @@ export default function routerSubscriptions(subscribe) {
|
|
|
371
384
|
action,
|
|
372
385
|
dispatch
|
|
373
386
|
}) => {
|
|
374
|
-
|
|
387
|
+
const pathname = sanitizeLink(String(action.pathname || ''));
|
|
388
|
+
if (pathname) {
|
|
375
389
|
dispatch(historyPush({
|
|
376
|
-
pathname
|
|
390
|
+
pathname
|
|
377
391
|
}));
|
|
378
392
|
}
|
|
379
393
|
});
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { APP_WILL_START, APP_DID_START, PWA_DID_APPEAR, PWA_DID_DISAPPEAR, WILL_REGISTER_LINK_EVENTS, DID_REGISTER_LINK_EVENTS, OPEN_DEEP_LINK, OPEN_PUSH_NOTIFICATION, OPEN_UNIVERSAL_LINK } from "../../constants/ActionTypes";
|
|
2
|
-
import { appWillStart, appDidStart, pwaDidAppear, pwaDidDisappear, willRegisterLinkEvents, didRegisterLinkEvents, openDeepLink, openPushNotification, openUniversalLink } from "./index";
|
|
3
|
-
const dataMock = {
|
|
4
|
-
some: 'data'
|
|
5
|
-
};
|
|
6
|
-
describe('Action Creators: app', () => {
|
|
7
|
-
describe('appWillStart()', () => {
|
|
8
|
-
it('should work as expected', () => {
|
|
9
|
-
const expected = {
|
|
10
|
-
type: APP_WILL_START,
|
|
11
|
-
location: dataMock
|
|
12
|
-
};
|
|
13
|
-
expect(appWillStart(dataMock)).toEqual(expected);
|
|
14
|
-
});
|
|
15
|
-
});
|
|
16
|
-
describe('appDidStart()', () => {
|
|
17
|
-
it('should work as expected', () => {
|
|
18
|
-
const expected = {
|
|
19
|
-
type: APP_DID_START
|
|
20
|
-
};
|
|
21
|
-
expect(appDidStart()).toEqual(expected);
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
describe('pwaDidAppear()', () => {
|
|
25
|
-
it('should work as expected', () => {
|
|
26
|
-
const expected = {
|
|
27
|
-
type: PWA_DID_APPEAR
|
|
28
|
-
};
|
|
29
|
-
expect(pwaDidAppear()).toEqual(expected);
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
describe('pwaDidDisappear()', () => {
|
|
33
|
-
it('should work as expected', () => {
|
|
34
|
-
const expected = {
|
|
35
|
-
type: PWA_DID_DISAPPEAR
|
|
36
|
-
};
|
|
37
|
-
expect(pwaDidDisappear()).toEqual(expected);
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
describe('willRegisterLinkEvents()', () => {
|
|
41
|
-
it('should work as expected', () => {
|
|
42
|
-
const expected = {
|
|
43
|
-
type: WILL_REGISTER_LINK_EVENTS
|
|
44
|
-
};
|
|
45
|
-
expect(willRegisterLinkEvents()).toEqual(expected);
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
describe('didRegisterLinkEvents()', () => {
|
|
49
|
-
it('should work as expected', () => {
|
|
50
|
-
const expected = {
|
|
51
|
-
type: DID_REGISTER_LINK_EVENTS
|
|
52
|
-
};
|
|
53
|
-
expect(didRegisterLinkEvents()).toEqual(expected);
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
describe('openDeepLink()', () => {
|
|
57
|
-
it('should work as expected', () => {
|
|
58
|
-
const expected = {
|
|
59
|
-
type: OPEN_DEEP_LINK,
|
|
60
|
-
payload: dataMock
|
|
61
|
-
};
|
|
62
|
-
expect(openDeepLink(dataMock)).toEqual(expected);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
describe('openPushNotification()', () => {
|
|
66
|
-
const notificationId = 'abc123';
|
|
67
|
-
const link = '/link/to/somewhere';
|
|
68
|
-
it('should work as expected', () => {
|
|
69
|
-
const expected = {
|
|
70
|
-
type: OPEN_PUSH_NOTIFICATION,
|
|
71
|
-
notificationId,
|
|
72
|
-
link
|
|
73
|
-
};
|
|
74
|
-
expect(openPushNotification(notificationId, link)).toEqual(expected);
|
|
75
|
-
});
|
|
76
|
-
it('should work as expected when the link is empty', () => {
|
|
77
|
-
const expected = {
|
|
78
|
-
type: OPEN_PUSH_NOTIFICATION,
|
|
79
|
-
notificationId,
|
|
80
|
-
link: ''
|
|
81
|
-
};
|
|
82
|
-
expect(openPushNotification(notificationId)).toEqual(expected);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
describe('openUniversalLink()', () => {
|
|
86
|
-
const expected = {
|
|
87
|
-
type: OPEN_UNIVERSAL_LINK,
|
|
88
|
-
payload: {
|
|
89
|
-
link: 'https://testshop.shopgate.com/item/313131313132',
|
|
90
|
-
wasOpenedFromSearchIndex: true,
|
|
91
|
-
linkSerial: 1003
|
|
92
|
-
}
|
|
93
|
-
};
|
|
94
|
-
expect(openUniversalLink(expected.payload)).toEqual(expected);
|
|
95
|
-
});
|
|
96
|
-
});
|