@financial-times/qanda-ui 0.0.1-beta.2 → 0.0.1-beta.4

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.
Files changed (105) hide show
  1. package/README.md +38 -6
  2. package/dist/cjs/components/Author/index.js +19 -0
  3. package/dist/cjs/components/Calendar/Apps.js +48 -0
  4. package/dist/cjs/components/Calendar/CalendarList.js +12 -0
  5. package/dist/cjs/components/Calendar/CalendarListItem.js +22 -0
  6. package/dist/cjs/components/Calendar/index.js +44 -0
  7. package/dist/cjs/components/CountdownTimer/index.js +88 -0
  8. package/dist/cjs/components/EmbedFormField/index.js +24 -0
  9. package/dist/cjs/components/Expander/index.js +59 -0
  10. package/dist/cjs/components/FloatingActionBar/index.js +41 -0
  11. package/dist/cjs/components/Overlay/MessageBox.js +7 -0
  12. package/dist/cjs/components/Overlay/index.js +8 -0
  13. package/dist/cjs/components/Qanda.js +37 -0
  14. package/dist/cjs/components/QandaBlock/index.js +18 -0
  15. package/dist/cjs/components/QandaContainer.js +42 -0
  16. package/dist/cjs/components/QandaOComments.js +43 -0
  17. package/dist/cjs/components/QandaProvider.js +11 -0
  18. package/dist/cjs/components/QuestionForm/index.js +151 -0
  19. package/dist/cjs/components/QuestionForm/messagesByUserType.js +33 -0
  20. package/dist/cjs/components/Stream.js +28 -0
  21. package/dist/cjs/components/UserInfo.js +22 -0
  22. package/dist/cjs/hooks/useIsMobile.js +15 -0
  23. package/dist/cjs/index.js +53 -0
  24. package/dist/cjs/services/comments-api.js +144 -0
  25. package/dist/cjs/services/error-middleware.js +47 -0
  26. package/dist/cjs/services/reliability.js +79 -0
  27. package/dist/cjs/store/index.js +28 -0
  28. package/dist/cjs/store/questionSlice.js +32 -0
  29. package/dist/cjs/store/stream.js +17 -0
  30. package/dist/cjs/store/updateComments.js +38 -0
  31. package/dist/cjs/store/user.js +47 -0
  32. package/dist/cjs/utils/auth.js +28 -0
  33. package/dist/cjs/utils/qandas.js +20 -0
  34. package/dist/cjs/utils/validate-display-name.js +22 -0
  35. package/dist/esm/components/Author/index.js +14 -0
  36. package/dist/esm/components/Calendar/Apps.js +46 -0
  37. package/dist/esm/components/Calendar/CalendarList.js +7 -0
  38. package/dist/esm/components/Calendar/CalendarListItem.js +20 -0
  39. package/dist/esm/components/Calendar/index.js +39 -0
  40. package/dist/esm/components/CountdownTimer/index.js +85 -0
  41. package/dist/esm/components/EmbedFormField/index.js +22 -0
  42. package/dist/esm/components/Expander/index.js +57 -0
  43. package/dist/esm/components/FloatingActionBar/index.js +39 -0
  44. package/dist/esm/components/Overlay/MessageBox.js +5 -0
  45. package/dist/esm/components/Overlay/index.js +6 -0
  46. package/dist/esm/components/Qanda.js +32 -0
  47. package/dist/esm/components/QandaBlock/index.js +13 -0
  48. package/dist/esm/components/QandaContainer.js +37 -0
  49. package/dist/esm/components/QandaOComments.js +38 -0
  50. package/dist/esm/components/QandaProvider.js +8 -0
  51. package/dist/esm/components/QuestionForm/index.js +145 -0
  52. package/dist/esm/components/QuestionForm/messagesByUserType.js +28 -0
  53. package/dist/esm/components/Stream.js +23 -0
  54. package/dist/esm/components/UserInfo.js +20 -0
  55. package/dist/esm/hooks/useIsMobile.js +12 -0
  56. package/dist/esm/index.js +46 -0
  57. package/dist/esm/services/comments-api.js +139 -0
  58. package/dist/esm/services/error-middleware.js +43 -0
  59. package/dist/esm/services/reliability.js +75 -0
  60. package/dist/esm/store/index.js +22 -0
  61. package/dist/esm/store/questionSlice.js +28 -0
  62. package/dist/esm/store/stream.js +14 -0
  63. package/dist/esm/store/updateComments.js +34 -0
  64. package/dist/esm/store/user.js +41 -0
  65. package/dist/esm/utils/auth.js +23 -0
  66. package/dist/esm/utils/qandas.js +16 -0
  67. package/dist/esm/utils/validate-display-name.js +19 -0
  68. package/dist/qanda.scss +733 -0
  69. package/dist/types/components/Author/index.d.ts +18 -0
  70. package/dist/types/components/Calendar/Apps.d.ts +14 -0
  71. package/dist/types/components/Calendar/CalendarList.d.ts +2 -0
  72. package/dist/types/components/Calendar/CalendarListItem.d.ts +8 -0
  73. package/dist/types/components/Calendar/index.d.ts +2 -0
  74. package/dist/types/components/CountdownTimer/index.d.ts +6 -0
  75. package/dist/types/components/EmbedFormField/index.d.ts +5 -0
  76. package/dist/types/components/Expander/index.d.ts +8 -0
  77. package/dist/types/components/FloatingActionBar/index.d.ts +2 -0
  78. package/dist/types/components/Overlay/MessageBox.d.ts +9 -0
  79. package/dist/types/components/Overlay/index.d.ts +7 -0
  80. package/dist/types/components/Qanda.d.ts +3 -0
  81. package/dist/types/components/QandaBlock/index.d.ts +7 -0
  82. package/dist/types/components/QandaContainer.d.ts +6 -0
  83. package/dist/types/components/QandaOComments.d.ts +2 -0
  84. package/dist/types/components/QandaProvider.d.ts +9 -0
  85. package/dist/types/components/QuestionForm/index.d.ts +1 -0
  86. package/dist/types/components/QuestionForm/messagesByUserType.d.ts +7 -0
  87. package/dist/types/components/Stream.d.ts +2 -0
  88. package/dist/types/components/UserInfo.d.ts +2 -0
  89. package/dist/types/hooks/useIsMobile.d.ts +1 -0
  90. package/dist/types/index.d.ts +17 -0
  91. package/dist/types/services/comments-api.d.ts +598 -0
  92. package/dist/types/services/error-middleware.d.ts +3 -0
  93. package/dist/types/services/reliability.d.ts +19 -0
  94. package/dist/types/store/index.d.ts +195 -0
  95. package/dist/types/store/questionSlice.d.ts +15 -0
  96. package/dist/types/store/stream.d.ts +11 -0
  97. package/dist/types/store/updateComments.d.ts +3 -0
  98. package/dist/types/store/user.d.ts +24 -0
  99. package/dist/types/utils/auth.d.ts +5 -0
  100. package/dist/types/utils/qandas.d.ts +2 -0
  101. package/dist/types/utils/validate-display-name.d.ts +1 -0
  102. package/package.json +23 -16
  103. package/dist/index.html +0 -19
  104. package/dist/index.js +0 -2
  105. package/dist/index.js.LICENSE.txt +0 -15
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const jsx_runtime_1 = require("preact/jsx-runtime");
7
+ const o_comments_1 = __importDefault(require("@financial-times/o-comments"));
8
+ const react_1 = require("react");
9
+ const react_redux_1 = require("react-redux");
10
+ const QandaProvider_1 = require("./QandaProvider");
11
+ const comments_api_1 = require("../services/comments-api");
12
+ const reliability_1 = require("../services/reliability");
13
+ function QandaOComments() {
14
+ const allowedRoles = ['ADMIN', 'MODERATOR'];
15
+ const { storyId, useStaging, commentsAPIUrl, title } = (0, react_1.useContext)(QandaProvider_1.QandaContext);
16
+ const profile = (0, react_redux_1.useSelector)((state) => state.user.profile);
17
+ if (!profile) {
18
+ return null;
19
+ }
20
+ const { data: userRole, error, isLoading, } = (0, comments_api_1.useGetUserRolesQuery)({
21
+ storyId,
22
+ useStaging,
23
+ commentsAPIUrl,
24
+ token: profile.token,
25
+ }, { skip: !profile.token });
26
+ (0, react_1.useEffect)(() => {
27
+ if (!userRole) {
28
+ return;
29
+ }
30
+ const { isExpert, isStaffExpert, role } = userRole;
31
+ const showCoral = isExpert || isStaffExpert || (role && allowedRoles.includes(role));
32
+ if (showCoral) {
33
+ try {
34
+ o_comments_1.default.init();
35
+ }
36
+ catch (error) {
37
+ reliability_1.qandaReliability.failure('expert-load-coral-ui', error);
38
+ }
39
+ }
40
+ }, [userRole]);
41
+ return ((0, jsx_runtime_1.jsx)("div", { className: "o-comments", id: "o-comments-stream", "data-o-component": "o-comments", "data-o-comments-article-id": storyId, "data-o-comments-article-url": `https://www.ft.com/content/${storyId}`, "data-o-comments-use-staging-environment": useStaging, "data-o-comments-title": title }));
42
+ }
43
+ exports.default = QandaOComments;
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.QandaContext = void 0;
4
+ const react_1 = require("react");
5
+ exports.QandaContext = (0, react_1.createContext)({
6
+ storyId: '',
7
+ useStaging: true,
8
+ commentsAPIUrl: '',
9
+ title: '',
10
+ maxQuestionLength: 0,
11
+ });
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.default = QuestionForm;
7
+ const jsx_runtime_1 = require("preact/jsx-runtime");
8
+ const react_redux_1 = require("react-redux");
9
+ const classnames_1 = __importDefault(require("classnames"));
10
+ const react_1 = require("react");
11
+ const questionSlice_1 = require("../../store/questionSlice");
12
+ const QandaProvider_1 = require("../QandaProvider");
13
+ const comments_api_1 = require("../../services/comments-api");
14
+ const Overlay_1 = __importDefault(require("../Overlay"));
15
+ const o3_button_1 = require("@financial-times/o3-button");
16
+ const MessageBox_1 = __importDefault(require("../Overlay/MessageBox"));
17
+ const user_1 = require("../../store/user");
18
+ const validate_display_name_1 = __importDefault(require("../../utils/validate-display-name"));
19
+ const messagesByUserType_1 = require("./messagesByUserType");
20
+ function QuestionForm() {
21
+ const dispatch = (0, react_redux_1.useDispatch)();
22
+ const { storyId, useStaging, commentsAPIUrl, maxQuestionLength } = (0, react_1.useContext)(QandaProvider_1.QandaContext);
23
+ const [postAnonymously, setPostAnonymously] = (0, react_1.useState)(false);
24
+ const [newDisplayName, setNewDisplayName] = (0, react_1.useState)(null);
25
+ const [submitEnabled, setSubmitEnabled] = (0, react_1.useState)(false);
26
+ const { isFormOpen, question, displayNameError } = (0, react_redux_1.useSelector)((state) => state.question);
27
+ const { profile, error: userDisplayNameError } = (0, react_redux_1.useSelector)((state) => state.user);
28
+ // NB: reset will have the effect of setting things like error to null and isSuccess to empty
29
+ // you use this when you want to clear the state of the usePostQuestionMutation hook as if you had just started on the page
30
+ // as we allow users to see things like error messages and success messages, we do not clear those until we try to send another mutation, otherwise those things will clear too early
31
+ const [postQuestion, { isLoading, isSuccess, error, reset }] = (0, comments_api_1.usePostQuestionMutation)();
32
+ (0, react_1.useEffect)(() => {
33
+ if (userDisplayNameError) {
34
+ dispatch((0, questionSlice_1.updateDisplayNameError)('Sorry, we are unable to update display names. Please try again.'));
35
+ }
36
+ }, [userDisplayNameError]);
37
+ (0, react_1.useEffect)(() => {
38
+ setSubmitEnabled(Boolean((profile?.token || (newDisplayName && newDisplayName.length > 0)) &&
39
+ question.length >= 20));
40
+ }, [profile?.token, newDisplayName, question]);
41
+ (0, react_1.useEffect)(() => {
42
+ // we want to clear the display name error unless the person has attempted
43
+ // to post anonymously with no display name
44
+ if (!postAnonymously && newDisplayName && newDisplayName?.length > 0) {
45
+ dispatch((0, questionSlice_1.updateDisplayNameError)(null));
46
+ }
47
+ }, [newDisplayName]);
48
+ // HANDLING EVENTS
49
+ const trackEvent = (trackingData) => {
50
+ const event = new CustomEvent('oTracking.event', {
51
+ detail: trackingData,
52
+ bubbles: true,
53
+ });
54
+ document.body.dispatchEvent(event);
55
+ };
56
+ const handleHideDisplayName = (e) => {
57
+ if (!profile?.token && !newDisplayName) {
58
+ dispatch((0, questionSlice_1.updateDisplayNameError)('You need a display name to ask a question, but you can still post it anonymously.'));
59
+ }
60
+ setPostAnonymously(e.target instanceof HTMLInputElement && e.target.checked);
61
+ };
62
+ const handleSubmit = async (e) => {
63
+ e.preventDefault();
64
+ reset();
65
+ let updatedProfile;
66
+ if (newDisplayName) {
67
+ const invalidCharacters = (0, validate_display_name_1.default)(newDisplayName);
68
+ if (invalidCharacters) {
69
+ dispatch((0, questionSlice_1.updateDisplayNameError)(`The display name contains invalid characters: ${invalidCharacters}`));
70
+ return;
71
+ }
72
+ updatedProfile = await dispatch((0, user_1.getUserProfile)({ useStaging, displayName: newDisplayName })).unwrap();
73
+ // The user token is empty when there is no display name set
74
+ // if there are also no errors, we can assume the display name is taken
75
+ if (!updatedProfile?.token && !userDisplayNameError) {
76
+ dispatch((0, questionSlice_1.updateDisplayNameError)('Unfortunately that display name is taken'));
77
+ return;
78
+ }
79
+ if (updatedProfile?.displayName) {
80
+ // NB: we are using a component act event as analytics team can only support existing categories and actions at this time
81
+ trackEvent({
82
+ category: 'component',
83
+ action: 'act',
84
+ trigger_action: 'success_set_displayname',
85
+ });
86
+ }
87
+ }
88
+ try {
89
+ await postQuestion({
90
+ token: updatedProfile?.token || profile?.token || '',
91
+ storyId,
92
+ useStaging,
93
+ commentsAPIUrl,
94
+ question: question,
95
+ postAnonymously,
96
+ }).unwrap();
97
+ }
98
+ catch (e) {
99
+ dispatch((0, questionSlice_1.openForm)());
100
+ }
101
+ };
102
+ (0, react_1.useEffect)(() => {
103
+ if (isSuccess) {
104
+ dispatch((0, questionSlice_1.updateQuestion)(''));
105
+ }
106
+ }, [isSuccess]);
107
+ if (error) {
108
+ // TODO: Remove tracking if confirmed that we can use AWS RUM
109
+ // Note: Events related to the Product, not reliability, should still use tracking
110
+ trackEvent({
111
+ category: 'component',
112
+ action: 'view',
113
+ trigger_action: 'liveqa_question_form_submit',
114
+ custom: [
115
+ {
116
+ name: 'state',
117
+ value: 'error',
118
+ },
119
+ ],
120
+ });
121
+ }
122
+ if (!isFormOpen) {
123
+ return;
124
+ }
125
+ return (
126
+ // This form will use o-forms fields with o3 styling until o3 components
127
+ // are implemented by Origami team.
128
+ // Ticket CI-2770 was created to address this in the future.
129
+ (0, jsx_runtime_1.jsxs)(Overlay_1.default, { title: "Ask a question", closeCallback: () => {
130
+ setNewDisplayName(null);
131
+ dispatch((0, questionSlice_1.closeForm)());
132
+ }, children: [profile.isSubscribed && ((0, jsx_runtime_1.jsx)("div", { className: "overlay__content-box overlay__form-box", "data-testid": "question-form-overlay", children: (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, "data-trackable": `liveqa_submit_question_form_${postAnonymously ? 'anonymous' : 'displayname'}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "overlay__user-container", children: [!postAnonymously && profile?.displayName && ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: (0, jsx_runtime_1.jsxs)("span", { className: "overlay__user-container-display-name o3-type-body-highlight", children: [(0, jsx_runtime_1.jsx)("i", { className: "overlay__message-icon", style: { maskImage: 'var(--o3-icon-user)' } }), profile.displayName] }) })), !profile?.displayName && ((0, jsx_runtime_1.jsxs)("label", { for: "display-name", className: "question-form__displayname-input o-forms-field", children: [(0, jsx_runtime_1.jsx)("span", { className: "o-forms-title", children: (0, jsx_runtime_1.jsx)("span", { className: "o3-type-body-highlight", children: "Choose a display name" }) }), (0, jsx_runtime_1.jsxs)("span", { className: (0, classnames_1.default)([
133
+ 'question-form__displayname__input',
134
+ 'o-forms-input',
135
+ 'o-forms-input--text',
136
+ { 'o-forms-input--invalid': !!displayNameError },
137
+ ]), children: [(0, jsx_runtime_1.jsx)("input", { className: "question-form__displayname__input__field o3-type-body-lg", id: "display-name", value: newDisplayName || '', onChange: (e) => setNewDisplayName(e.currentTarget.value), type: postAnonymously ? 'password' : 'text', "aria-invalid": displayNameError ? 'true' : 'false', "aria-errormessage": displayNameError ??
138
+ 'question_form__displayname-error-message' }), displayNameError && ((0, jsx_runtime_1.jsx)("span", { className: "question-form__displayname__input__error o3-type-detail", id: "question_form__displayname-error-message", children: (0, jsx_runtime_1.jsx)("span", { children: displayNameError }) }))] })] }))] }), (0, jsx_runtime_1.jsx)("div", { className: "o-forms-field", children: (0, jsx_runtime_1.jsxs)("label", { className: "o-forms-input o-forms-input--checkbox", htmlFor: "post-anonymously", children: [(0, jsx_runtime_1.jsx)("input", { type: "checkbox", name: "post-anonymously", id: "post-anonymously", value: "true", onChange: handleHideDisplayName }), (0, jsx_runtime_1.jsx)("span", { className: "o-forms-input__label", children: "Post anonymously (optional)" })] }) }), (0, jsx_runtime_1.jsxs)("label", { for: "question-text", class: "o-forms-field", children: [(0, jsx_runtime_1.jsx)("span", { class: "o-forms-title", children: (0, jsx_runtime_1.jsx)("span", { class: "o3-type-body-highlight", children: "Enter your question" }) }), (0, jsx_runtime_1.jsx)("span", { class: "o-forms-input o-forms-input--textarea", children: (0, jsx_runtime_1.jsx)("textarea", { id: "question-text", name: "question", value: question, onChange: (e) => dispatch((0, questionSlice_1.updateQuestion)(e.currentTarget.value)), "aria-label": "Your question", placeholder: "Type your question", maxLength: maxQuestionLength, "aria-invalid": error ? 'true' : undefined, "aria-describedby": error ? 'question-error-message' : undefined }) })] }), (0, jsx_runtime_1.jsx)(o3_button_1.Button, { attributes: {
139
+ className: 'overlay__submit-button',
140
+ type: 'submit',
141
+ disabled: !submitEnabled,
142
+ ariaLabel: question.length < 20
143
+ ? 'Submit button (disabled until question is at least 20 characters)'
144
+ : isLoading
145
+ ? 'Submitting...'
146
+ : 'Submit your question',
147
+ },
148
+ // @ts-expect-error - o3 Button is currently accepting a JSX element as label
149
+ // but the type definition needs to be updated by Origami team
150
+ label: isLoading ? ((0, jsx_runtime_1.jsx)("div", { class: "o-loading o-loading--light o-loading--small" })) : ('Submit your question'), type: "primary", theme: "mono" }), !profile?.token && ((0, jsx_runtime_1.jsx)("span", { className: "o3-type-detail", children: "Submitting a question will create a community profile with FT." }))] }) })), isSuccess && ((0, jsx_runtime_1.jsx)(MessageBox_1.default, { color: "#00572C", styles: "o-message--alert o-message--success", title: "Question sent", text: "Your question is in the queue and will be reviewed by our editors." })), error && ((0, jsx_runtime_1.jsx)(MessageBox_1.default, { color: "var(--o3-color-use-case-error)", styles: "o-message--alert o-message--error", title: "Connection issue", text: "Could not submit question: [error messages to come in another ticket]" })), (0, jsx_runtime_1.jsx)(MessageBox_1.default, { styles: "o-message--notice o-message--inform", title: (0, messagesByUserType_1.getTitleByUser)(profile), text: (0, messagesByUserType_1.getTextByUser)(profile) })] }));
151
+ }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TERMS_AND_CONDITIONS_URL = exports.COMMUNITY_GUIDELINES_URL = exports.SUBSCRIBE_URL = exports.SIGN_IN_URL = void 0;
4
+ exports.getTitleByUser = getTitleByUser;
5
+ exports.getTextByUser = getTextByUser;
6
+ const jsx_runtime_1 = require("preact/jsx-runtime");
7
+ exports.SIGN_IN_URL = 'https://www.ft.com/login';
8
+ exports.SUBSCRIBE_URL = 'https://subs.ft.com/products';
9
+ exports.COMMUNITY_GUIDELINES_URL = 'https://help.ft.com/help/article-queries/commenting-on-an-article/commenting-guidelines';
10
+ exports.TERMS_AND_CONDITIONS_URL = 'https://help.ft.com/help/legal-privacy/terms-conditions';
11
+ function getTitleByUser(profile) {
12
+ if (profile.isSubscribed) {
13
+ return 'Q&A guidelines';
14
+ }
15
+ if (profile.isTrialist) {
16
+ return 'You are still on a trial period';
17
+ }
18
+ // Same title for anonymous and registered users
19
+ return 'Asking a question is only available to readers with an FT subscription';
20
+ }
21
+ function getTextByUser(profile) {
22
+ if (profile.isSubscribed) {
23
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: ["Keep your comments respectful and easy to understand for our global audience. By submitting, you agree to follow our", ' ', (0, jsx_runtime_1.jsx)("a", { className: "message-box__content-link", "data-trackable": "question-form__community-guidelines", "data-trackable-context-action": "Community guidelines", href: exports.COMMUNITY_GUIDELINES_URL, target: "_parent", children: "community guidelines" }), ' ', "and", ' ', (0, jsx_runtime_1.jsx)("a", { className: "message-box__content-link", "data-trackable": "question-form__terms-and-conditions", "data-trackable-context-action": "Terms and conditions", href: exports.TERMS_AND_CONDITIONS_URL, target: "_parent", children: "terms" }), "."] }));
24
+ }
25
+ if (profile.isTrialist) {
26
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: ["View our full", ' ', (0, jsx_runtime_1.jsx)("a", { className: "message-box__content-link", "data-trackable": "question-form__subscribe-trialist", "data-trackable-context-action": "Subscribe", href: exports.SUBSCRIBE_URL, target: "_parent", children: "subscription packages" }), ' ', "to join the conversation."] }));
27
+ }
28
+ if (profile.isRegistered) {
29
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("a", { className: "message-box__content-link", "data-trackable": "question-form__subscribe-registered", "data-trackable-context-action": "Subscribe", href: exports.SUBSCRIBE_URL, target: "_parent", children: "Subscribe" }), ' ', "to join the conversation."] }));
30
+ }
31
+ // Anonymous users
32
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: ["Please", ' ', (0, jsx_runtime_1.jsx)("a", { className: "message-box__content-link", "data-trackable": "question-form__sign-in-anonymous", "data-trackable-context-action": "Sign in", href: exports.SIGN_IN_URL, target: "_parent", children: "sign in" }), ' ', "or", ' ', (0, jsx_runtime_1.jsx)("a", { className: "message-box__content-link", "data-trackable": "question-form__subscribe-anonymous", "data-trackable-context-action": "Subscribe", href: exports.SUBSCRIBE_URL, target: "_parent", children: "subscribe" }), ' ', "to join the conversation."] }));
33
+ }
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const jsx_runtime_1 = require("preact/jsx-runtime");
7
+ const react_1 = require("react");
8
+ const react_redux_1 = require("react-redux");
9
+ const index_1 = __importDefault(require("./QandaBlock/index"));
10
+ const QandaProvider_1 = require("./QandaProvider");
11
+ const comments_api_1 = require("../services/comments-api");
12
+ function Stream() {
13
+ const latestAnsweredQuestionId = (0, react_redux_1.useSelector)((state) => state.stream.latestAnsweredQuestionId);
14
+ const { storyId, useStaging, commentsAPIUrl } = (0, react_1.useContext)(QandaProvider_1.QandaContext);
15
+ const { data, error, isLoading } = (0, comments_api_1.useGetQandAStreamQuery)({
16
+ storyId,
17
+ useStaging,
18
+ commentsAPIUrl,
19
+ });
20
+ (0, react_1.useEffect)(() => {
21
+ const element = document.getElementById(latestAnsweredQuestionId);
22
+ element?.scrollIntoView({ behavior: 'smooth' });
23
+ }, [latestAnsweredQuestionId]);
24
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "qanda__blocks-container", "data-testid": "qanda__blocks-container", id: "qanda__blocks-container", children: [isLoading && (0, jsx_runtime_1.jsx)("p", { children: "Loading Stream for you, be patient..." }), error && (0, jsx_runtime_1.jsx)("p", { children: "Failed to load Q&A stream" }), data &&
25
+ data.qandas &&
26
+ data.qandas.map((qandaBlock) => ((0, jsx_runtime_1.jsx)(index_1.default, { id: qandaBlock.id, body: qandaBlock.body, type: "comment", publishedDate: qandaBlock.publishedDate, author: qandaBlock.author, byline: qandaBlock.byline, showAnswerTime: data.status === 'live', children: qandaBlock.children }, qandaBlock.id)))] }));
27
+ }
28
+ exports.default = Stream;
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const jsx_runtime_1 = require("preact/jsx-runtime");
4
+ const react_1 = require("react");
5
+ const react_redux_1 = require("react-redux");
6
+ const comments_api_1 = require("../services/comments-api");
7
+ const QandaProvider_1 = require("./QandaProvider");
8
+ function UserInfo() {
9
+ const profile = (0, react_redux_1.useSelector)((state) => state.user.profile);
10
+ if (!profile) {
11
+ return null;
12
+ }
13
+ const { storyId, useStaging, commentsAPIUrl } = (0, react_1.useContext)(QandaProvider_1.QandaContext);
14
+ const { data: userRole, error, isLoading, } = (0, comments_api_1.useGetUserRolesQuery)({
15
+ storyId,
16
+ useStaging,
17
+ commentsAPIUrl,
18
+ token: profile.token,
19
+ }, { skip: !profile.token });
20
+ return ((0, jsx_runtime_1.jsxs)("div", { children: [isLoading && (0, jsx_runtime_1.jsx)("p", { children: "Loading your profile..." }), error && ((0, jsx_runtime_1.jsx)("p", { children: (0, jsx_runtime_1.jsx)("b", { children: error }) }))] }));
21
+ }
22
+ exports.default = UserInfo;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useIsMobile = useIsMobile;
4
+ const react_1 = require("react");
5
+ function useIsMobile(breakpoint = 740) {
6
+ const [isMobile, setIsMobile] = (0, react_1.useState)(false);
7
+ (0, react_1.useEffect)(() => {
8
+ const mediaQuery = window.matchMedia(`(max-width: ${breakpoint}px)`);
9
+ const update = () => setIsMobile(mediaQuery.matches);
10
+ update();
11
+ mediaQuery.addEventListener('change', update);
12
+ return () => mediaQuery.removeEventListener('change', update);
13
+ }, [breakpoint]);
14
+ return isMobile;
15
+ }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.QandaBlock = exports.QandaUI = void 0;
7
+ const jsx_runtime_1 = require("preact/jsx-runtime");
8
+ const preact_1 = require("preact");
9
+ const Qanda_1 = __importDefault(require("./components/Qanda"));
10
+ const QandaBlock_1 = __importDefault(require("./components/QandaBlock"));
11
+ exports.QandaBlock = QandaBlock_1.default;
12
+ class QandaUI {
13
+ constructor(containerElement, options) {
14
+ this.containerElement = containerElement;
15
+ this.embedFormFieldContainer =
16
+ options?.embedFormFieldContainer || document.body;
17
+ this.countdownTimerContainer =
18
+ options?.countdownTimerContainer || document.body;
19
+ if (!options && !containerElement) {
20
+ throw new Error('Required properties are missing');
21
+ }
22
+ const storyId = options?.storyId ?? containerElement.dataset.qandaStoryid;
23
+ const useStaging = options?.useStaging ??
24
+ containerElement.dataset.qandaUsestaging === 'true' ??
25
+ false;
26
+ const title = options?.title ?? containerElement.dataset.qandaTitle;
27
+ const commentsAPIHost = options?.commentsAPIHost ?? containerElement.dataset.qandaCommentsapihost;
28
+ const commentsAPIVersion = options?.commentsAPIVersion ??
29
+ containerElement.dataset.qandaCommentsapiversion;
30
+ const maxQuestionLength = options?.maxQuestionLength ??
31
+ (containerElement.dataset.qandaMaxquestionlength
32
+ ? parseInt(containerElement.dataset.qandaMaxquestionlength, 10)
33
+ : undefined) ??
34
+ 2000;
35
+ if (!storyId || !title || !commentsAPIHost || !commentsAPIVersion) {
36
+ throw new Error('Required properties are missing');
37
+ }
38
+ //TODO: test this doesn't break all the other bits of js, needs to be throw because of typescript, can't just bail out normally
39
+ this.storyId = storyId;
40
+ this.useStaging = useStaging;
41
+ this.title = title;
42
+ this.commentsAPIHost = commentsAPIHost;
43
+ this.commentsAPIVersion = commentsAPIVersion;
44
+ this.maxQuestionLength = maxQuestionLength;
45
+ }
46
+ init() {
47
+ (0, preact_1.render)((0, jsx_runtime_1.jsx)(Qanda_1.default, { storyId: this.storyId, useStaging: this.useStaging, title: this.title, commentsAPIHost: this.commentsAPIHost, commentsAPIVersion: this.commentsAPIVersion, maxQuestionLength: this.maxQuestionLength, embedFormFieldContainer: this.embedFormFieldContainer, countdownTimerContainer: this.countdownTimerContainer }), this.containerElement);
48
+ }
49
+ destroy() {
50
+ (0, preact_1.render)(null, this.containerElement);
51
+ }
52
+ }
53
+ exports.QandaUI = QandaUI;
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useUpdatedQA = exports.useUpdatedComments = exports.usePostQuestionMutation = exports.useGetUserRolesQuery = exports.useGetQandAUpdatesQuery = exports.useGetQandAStreamQuery = exports.nextCommentsApi = exports.eventSource = void 0;
4
+ const react_1 = require("@reduxjs/toolkit/query/react");
5
+ // TODO: ensure that users don't miss out on updates in this window
6
+ // https://financialtimes.atlassian.net/browse/CI-2715
7
+ const retryDelay = 1000;
8
+ const updateTypes = {
9
+ QA_NEW_REPLY: 'updatedComments',
10
+ QA_UPDATE: 'updatedQA',
11
+ };
12
+ exports.eventSource = null;
13
+ exports.nextCommentsApi = (0, react_1.createApi)({
14
+ reducerPath: 'nextCommentsApi',
15
+ refetchOnReconnect: true, // refetches all subscribed queries on regaining a network connection
16
+ baseQuery: (0, react_1.fetchBaseQuery)({
17
+ timeout: 10000,
18
+ }),
19
+ endpoints: (builder) => ({
20
+ // QA STREAM
21
+ getQandAStream: builder.query({
22
+ query: ({ storyId, useStaging, commentsAPIUrl }) => `${commentsAPIUrl}/story/${storyId}/stream${useStaging ? '?staging=1' : ''}`,
23
+ transformResponse: (response) => {
24
+ return {
25
+ qandas: response?.children || [],
26
+ type: response.type,
27
+ status: response?.status || 'pre-live',
28
+ startDateTime: response?.startDateTime || '',
29
+ endDateTime: response?.endDateTime || '',
30
+ };
31
+ },
32
+ }),
33
+ // UPDATES LISTENER
34
+ getQandAUpdates: builder.query({
35
+ queryFn: async ({ storyId, useStaging, commentsAPIUrl, }) => {
36
+ return { data: {} };
37
+ },
38
+ async onCacheEntryAdded(arg, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) {
39
+ await cacheDataLoaded;
40
+ const connectToSSE = () => {
41
+ const sseEndpoint = `${arg.commentsAPIUrl}/story/${arg.storyId}/updates${arg.useStaging ? '?staging=1' : ''}`;
42
+ exports.eventSource = new EventSource(sseEndpoint);
43
+ exports.eventSource.onopen = () => {
44
+ console.log(`Connected to SSE: ${sseEndpoint}`);
45
+ };
46
+ exports.eventSource.onmessage = (event) => {
47
+ try {
48
+ const data = JSON.parse(event.data);
49
+ console.log('Received SSE update:', data);
50
+ if (data && data.type) {
51
+ updateCachedData((draft) => {
52
+ const updatedData = updateTypes[data.type];
53
+ draft[updatedData] = draft[updatedData] || [];
54
+ draft[updatedData].push(data.payload);
55
+ });
56
+ }
57
+ }
58
+ catch (error) {
59
+ console.warn('Failed to parse SSE message:', event.data);
60
+ }
61
+ };
62
+ exports.eventSource.onerror = (event) => {
63
+ const es = event.target;
64
+ if (es.readyState === EventSource.CONNECTING) {
65
+ console.error('SSE connection lost. Attempting to reconnect...');
66
+ }
67
+ else if (es.readyState === EventSource.CLOSED) {
68
+ console.error(`SSE connection closed. Will try to reconnect in ${retryDelay / 1000} seconds...`);
69
+ exports.eventSource?.close();
70
+ exports.eventSource = null;
71
+ setTimeout(() => connectToSSE(), retryDelay);
72
+ }
73
+ else {
74
+ console.error('SSE error occurred.', event);
75
+ }
76
+ };
77
+ };
78
+ try {
79
+ connectToSSE();
80
+ }
81
+ catch (error) {
82
+ console.error('Failed to subscribe to SSE:', error);
83
+ console.error(`Reconnecting in ${retryDelay / 1000} seconds...`);
84
+ exports.eventSource = null;
85
+ setTimeout(() => connectToSSE(), retryDelay);
86
+ }
87
+ // cleanup when there are no subscribers to the cache anymore
88
+ await cacheEntryRemoved;
89
+ if (exports.eventSource !== null) {
90
+ exports.eventSource.close();
91
+ exports.eventSource = null;
92
+ }
93
+ },
94
+ }),
95
+ // USER ROLES
96
+ getUserRoles: builder.query({
97
+ query: ({ storyId, useStaging, commentsAPIUrl, token, }) => ({
98
+ url: `${commentsAPIUrl}/user/user-details/story/${storyId}${useStaging ? '?staging=1' : ''}`,
99
+ method: 'POST',
100
+ headers: {
101
+ Authorization: `Bearer ${token}`,
102
+ },
103
+ }),
104
+ }),
105
+ // POST A QUESTION
106
+ postQuestion: builder.mutation({
107
+ query: ({ storyId, useStaging, commentsAPIUrl, token, question, postAnonymously, }) => ({
108
+ url: `${commentsAPIUrl}/story/${storyId}/question${useStaging ? '?staging=1' : ''}`,
109
+ method: 'POST',
110
+ headers: {
111
+ 'Content-Type': 'application/json',
112
+ Authorization: `Bearer ${token}`,
113
+ },
114
+ body: JSON.stringify({ question, postAnonymously, token }),
115
+ }),
116
+ transformResponse: (response, meta, arg) => {
117
+ return response.data;
118
+ },
119
+ }),
120
+ }),
121
+ });
122
+ exports.useGetQandAStreamQuery = exports.nextCommentsApi.useGetQandAStreamQuery, exports.useGetQandAUpdatesQuery = exports.nextCommentsApi.useGetQandAUpdatesQuery, exports.useGetUserRolesQuery = exports.nextCommentsApi.useGetUserRolesQuery, exports.usePostQuestionMutation = exports.nextCommentsApi.usePostQuestionMutation;
123
+ // UPDATES QUERY CUSTOM HOOKS
124
+ // custom hook for getting just the updated comments from the updates, so we are more efficient
125
+ const useUpdatedComments = ({ storyId, useStaging, commentsAPIUrl, }) => {
126
+ return exports.nextCommentsApi.useGetQandAUpdatesQuery({ storyId, useStaging, commentsAPIUrl }, {
127
+ selectFromResult: ({ data }) => ({
128
+ updatedComments: data?.updatedComments || [],
129
+ }),
130
+ }).updatedComments;
131
+ };
132
+ exports.useUpdatedComments = useUpdatedComments;
133
+ // Custom hook to get the updates on the information about the Q&A
134
+ const useUpdatedQA = ({ storyId, useStaging, commentsAPIUrl, }) => {
135
+ return exports.nextCommentsApi.useGetQandAUpdatesQuery({ storyId, useStaging, commentsAPIUrl }, {
136
+ selectFromResult: ({ data }) => ({
137
+ updatedQA: (data?.updatedQA &&
138
+ data.updatedQA.length &&
139
+ data.updatedQA[data.updatedQA.length - 1]) ||
140
+ {},
141
+ }),
142
+ }).updatedQA;
143
+ };
144
+ exports.useUpdatedQA = useUpdatedQA;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.rtkQueryErrorLogger = void 0;
4
+ const toolkit_1 = require("@reduxjs/toolkit");
5
+ const reliability_1 = require("./reliability");
6
+ // Tracking successes for updates may generate a lot of events
7
+ // Disable it when not needed
8
+ const endpointToOperationMap = {
9
+ postQuestion: { operation: 'question-submission', trackSuccess: true },
10
+ getUserRoles: { operation: 'profile-load', trackSuccess: true },
11
+ getQandAStream: { operation: 'stream-load', trackSuccess: true },
12
+ getQandAUpdates: { operation: 'qa-updates', trackSuccess: true },
13
+ };
14
+ const rtkQueryErrorLogger = () => (next) => (action) => {
15
+ // Log failures
16
+ if ((0, toolkit_1.isRejectedWithValue)(action)) {
17
+ const rejected = action;
18
+ const isHttpError = typeof rejected.payload?.status === 'number';
19
+ const endpointName = rejected.meta?.arg?.endpointName ?? '';
20
+ const mapEntry = endpointToOperationMap[endpointName];
21
+ const operation = mapEntry?.operation ?? 'unmapped-operation';
22
+ const statusCodeInfo = isHttpError
23
+ ? ` with status code ${rejected.payload?.status}`
24
+ : '';
25
+ const errorMessage = rejected.payload?.data?.error ||
26
+ rejected.payload?.error ||
27
+ 'Unknown error';
28
+ const rtkNormalisedError = new Error(`Network error${statusCodeInfo}: ${errorMessage}`);
29
+ reliability_1.qandaReliability.failure(operation, rtkNormalisedError);
30
+ }
31
+ // Fullfilled actions
32
+ if (typeof action === 'object' &&
33
+ action !== null &&
34
+ 'type' in action &&
35
+ typeof action.type === 'string' &&
36
+ action.type.endsWith('/fulfilled')) {
37
+ const meta = action.meta;
38
+ const endpointName = meta?.arg?.endpointName ?? '';
39
+ const mapEntry = endpointToOperationMap[endpointName];
40
+ // Send success event only if trackSucces is enabled for that particular endpoint
41
+ if (mapEntry?.trackSuccess) {
42
+ reliability_1.qandaReliability.success(mapEntry.operation);
43
+ }
44
+ }
45
+ return next(action);
46
+ };
47
+ exports.rtkQueryErrorLogger = rtkQueryErrorLogger;
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Reliability = exports.qandaReliability = void 0;
4
+ /*
5
+ * @param outcome - success or failure
6
+ * @param operation - initialisation or question-submission
7
+ * @returns void
8
+ *
9
+ * This class is used to dispatch events to the client-metrics-web library
10
+ * for tracking the success or failure of operations.
11
+ * The class checks if the client-metrics-web library is available, if not it will log warnings
12
+ *
13
+ * For failures, it will log errors for debugging purposes
14
+ */
15
+ class Reliability {
16
+ constructor(options) {
17
+ this.testOnly = options.testOnly || false;
18
+ this.topLevelNamespace = options.topLevelNamespace;
19
+ this.articleId = '';
20
+ }
21
+ setArticleId(articleId) {
22
+ this.articleId = articleId;
23
+ }
24
+ dispatch(outcome, operation, error = null) {
25
+ if (!this.articleId) {
26
+ console.warn('(Qanda Reliability) Article ID not set. Please call setArticleId() before dispatching events.');
27
+ }
28
+ const eventNamespace = `${this.topLevelNamespace}.${operation}.${outcome}`;
29
+ if (this.testOnly) {
30
+ console.warn(`(Reliability) Event not sent: client-metrics-web not available %c${eventNamespace}`, 'font-weight: bold;');
31
+ }
32
+ else {
33
+ window.dispatchEvent(new CustomEvent('ft.clientMetric', {
34
+ detail: {
35
+ namespace: eventNamespace,
36
+ operation,
37
+ articleId: this.articleId,
38
+ ...(error && {
39
+ error: {
40
+ name: error?.name,
41
+ message: error?.message,
42
+ stack: error?.stack,
43
+ },
44
+ }),
45
+ },
46
+ }));
47
+ }
48
+ }
49
+ normaliseError(error) {
50
+ let normalisedError;
51
+ if (error instanceof Error) {
52
+ normalisedError = error;
53
+ }
54
+ else if (typeof error === 'string') {
55
+ normalisedError = new Error(error);
56
+ }
57
+ else if (typeof error === 'object' && error !== null) {
58
+ normalisedError = new Error(JSON.stringify(error));
59
+ }
60
+ else {
61
+ normalisedError = new Error('Unknown error type: ' + JSON.stringify(error));
62
+ }
63
+ return normalisedError;
64
+ }
65
+ failure(operation, error) {
66
+ // Dispatch a metric for failure and log the error
67
+ this.dispatch('failure', operation, this.normaliseError(error));
68
+ }
69
+ success(operation) {
70
+ this.dispatch('success', operation);
71
+ }
72
+ // Use this method to test the error handling
73
+ throw(error = 'Throwing an error for testing purposes') {
74
+ throw new Error(error);
75
+ }
76
+ }
77
+ exports.Reliability = Reliability;
78
+ const qandaReliability = new Reliability({ topLevelNamespace: 'live-qanda' });
79
+ exports.qandaReliability = qandaReliability;