@financial-times/qanda-ui 0.0.1-beta.2 → 0.0.1-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.
Files changed (123) 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 +24 -0
  6. package/dist/cjs/components/Calendar/index.js +47 -0
  7. package/dist/cjs/components/CountdownTimer/index.js +91 -0
  8. package/dist/cjs/components/EmbedFormField/index.js +24 -0
  9. package/dist/cjs/components/ErrorMessage/humanReadableError.js +74 -0
  10. package/dist/cjs/components/ErrorMessage/index.js +63 -0
  11. package/dist/cjs/components/Expander/index.js +71 -0
  12. package/dist/cjs/components/ExpertDrawer/index.js +36 -0
  13. package/dist/cjs/components/FloatingActionBar/index.js +59 -0
  14. package/dist/cjs/components/Loader/index.js +7 -0
  15. package/dist/cjs/components/MessageBox/index.js +7 -0
  16. package/dist/cjs/components/Overlay/index.js +18 -0
  17. package/dist/cjs/components/Qanda.js +37 -0
  18. package/dist/cjs/components/QandaBlock/index.js +78 -0
  19. package/dist/cjs/components/QandaContainer.js +99 -0
  20. package/dist/cjs/components/QandaProvider.js +11 -0
  21. package/dist/cjs/components/QuestionForm/index.js +165 -0
  22. package/dist/cjs/components/QuestionForm/messagesByUserType.js +33 -0
  23. package/dist/cjs/components/Stream/index.js +37 -0
  24. package/dist/cjs/components/Survey/index.js +36 -0
  25. package/dist/cjs/hooks/useIsMobile.js +15 -0
  26. package/dist/cjs/index.js +53 -0
  27. package/dist/cjs/services/comments-api.js +152 -0
  28. package/dist/cjs/services/error-middleware.js +47 -0
  29. package/dist/cjs/services/reliability.js +79 -0
  30. package/dist/cjs/store/expertSlice.js +26 -0
  31. package/dist/cjs/store/index.js +30 -0
  32. package/dist/cjs/store/questionSlice.js +32 -0
  33. package/dist/cjs/store/stream.js +17 -0
  34. package/dist/cjs/store/updateComments.js +40 -0
  35. package/dist/cjs/store/user.js +51 -0
  36. package/dist/cjs/utils/auth.js +26 -0
  37. package/dist/cjs/utils/qandas.js +20 -0
  38. package/dist/cjs/utils/strings.js +16 -0
  39. package/dist/cjs/utils/tracking.js +11 -0
  40. package/dist/cjs/utils/validate-display-name.js +22 -0
  41. package/dist/esm/components/Author/index.js +14 -0
  42. package/dist/esm/components/Calendar/Apps.js +46 -0
  43. package/dist/esm/components/Calendar/CalendarList.js +7 -0
  44. package/dist/esm/components/Calendar/CalendarListItem.js +22 -0
  45. package/dist/esm/components/Calendar/index.js +42 -0
  46. package/dist/esm/components/CountdownTimer/index.js +88 -0
  47. package/dist/esm/components/EmbedFormField/index.js +22 -0
  48. package/dist/esm/components/ErrorMessage/humanReadableError.js +71 -0
  49. package/dist/esm/components/ErrorMessage/index.js +57 -0
  50. package/dist/esm/components/Expander/index.js +69 -0
  51. package/dist/esm/components/ExpertDrawer/index.js +31 -0
  52. package/dist/esm/components/FloatingActionBar/index.js +57 -0
  53. package/dist/esm/components/Loader/index.js +4 -0
  54. package/dist/esm/components/MessageBox/index.js +5 -0
  55. package/dist/esm/components/Overlay/index.js +13 -0
  56. package/dist/esm/components/Qanda.js +32 -0
  57. package/dist/esm/components/QandaBlock/index.js +73 -0
  58. package/dist/esm/components/QandaContainer.js +94 -0
  59. package/dist/esm/components/QandaProvider.js +8 -0
  60. package/dist/esm/components/QuestionForm/index.js +159 -0
  61. package/dist/esm/components/QuestionForm/messagesByUserType.js +28 -0
  62. package/dist/esm/components/Stream/index.js +32 -0
  63. package/dist/esm/components/Survey/index.js +34 -0
  64. package/dist/esm/hooks/useIsMobile.js +12 -0
  65. package/dist/esm/index.js +46 -0
  66. package/dist/esm/services/comments-api.js +147 -0
  67. package/dist/esm/services/error-middleware.js +43 -0
  68. package/dist/esm/services/reliability.js +75 -0
  69. package/dist/esm/store/expertSlice.js +22 -0
  70. package/dist/esm/store/index.js +24 -0
  71. package/dist/esm/store/questionSlice.js +28 -0
  72. package/dist/esm/store/stream.js +14 -0
  73. package/dist/esm/store/updateComments.js +36 -0
  74. package/dist/esm/store/user.js +45 -0
  75. package/dist/esm/utils/auth.js +24 -0
  76. package/dist/esm/utils/qandas.js +16 -0
  77. package/dist/esm/utils/strings.js +13 -0
  78. package/dist/esm/utils/tracking.js +7 -0
  79. package/dist/esm/utils/validate-display-name.js +19 -0
  80. package/dist/qanda.scss +802 -0
  81. package/dist/types/components/Author/index.d.ts +18 -0
  82. package/dist/types/components/Calendar/Apps.d.ts +14 -0
  83. package/dist/types/components/Calendar/CalendarList.d.ts +2 -0
  84. package/dist/types/components/Calendar/CalendarListItem.d.ts +8 -0
  85. package/dist/types/components/Calendar/index.d.ts +2 -0
  86. package/dist/types/components/CountdownTimer/index.d.ts +6 -0
  87. package/dist/types/components/EmbedFormField/index.d.ts +5 -0
  88. package/dist/types/components/ErrorMessage/humanReadableError.d.ts +29 -0
  89. package/dist/types/components/ErrorMessage/index.d.ts +8 -0
  90. package/dist/types/components/Expander/index.d.ts +8 -0
  91. package/dist/types/components/ExpertDrawer/index.d.ts +2 -0
  92. package/dist/types/components/FloatingActionBar/index.d.ts +4 -0
  93. package/dist/types/components/Loader/index.d.ts +14 -0
  94. package/dist/types/components/MessageBox/index.d.ts +10 -0
  95. package/dist/types/components/Overlay/index.d.ts +12 -0
  96. package/dist/types/components/Qanda.d.ts +3 -0
  97. package/dist/types/components/QandaBlock/index.d.ts +7 -0
  98. package/dist/types/components/QandaContainer.d.ts +6 -0
  99. package/dist/types/components/QandaProvider.d.ts +9 -0
  100. package/dist/types/components/QuestionForm/index.d.ts +1 -0
  101. package/dist/types/components/QuestionForm/messagesByUserType.d.ts +7 -0
  102. package/dist/types/components/Stream/index.d.ts +5 -0
  103. package/dist/types/components/Survey/index.d.ts +6 -0
  104. package/dist/types/hooks/useIsMobile.d.ts +1 -0
  105. package/dist/types/index.d.ts +17 -0
  106. package/dist/types/services/comments-api.d.ts +52 -0
  107. package/dist/types/services/error-middleware.d.ts +2 -0
  108. package/dist/types/services/reliability.d.ts +19 -0
  109. package/dist/types/store/expertSlice.d.ts +14 -0
  110. package/dist/types/store/index.d.ts +218 -0
  111. package/dist/types/store/questionSlice.d.ts +16 -0
  112. package/dist/types/store/stream.d.ts +7 -0
  113. package/dist/types/store/updateComments.d.ts +3 -0
  114. package/dist/types/store/user.d.ts +26 -0
  115. package/dist/types/utils/auth.d.ts +6 -0
  116. package/dist/types/utils/qandas.d.ts +2 -0
  117. package/dist/types/utils/strings.d.ts +1 -0
  118. package/dist/types/utils/tracking.d.ts +2 -0
  119. package/dist/types/utils/validate-display-name.d.ts +1 -0
  120. package/package.json +35 -29
  121. package/dist/index.html +0 -19
  122. package/dist/index.js +0 -2
  123. package/dist/index.js.LICENSE.txt +0 -15
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ function formatDateForIcs(date) {
3
+ return date.replace(/-|:|\.\d+/g, '');
4
+ }
5
+ function createIcsFile(title, startDateTime, endDateTime, url) {
6
+ const icsContent = `
7
+ BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nSUMMARY:${title}\nDESCRIPTION:${description(url)}\nDTSTART:${formatDateForIcs(startDateTime)}\nDTEND:${formatDateForIcs(endDateTime)}\nLOCATION:${url}\nEND:VEVENT\nEND:VCALENDAR
8
+ `.trim();
9
+ return URL.createObjectURL(new Blob([icsContent], { type: 'title/calendar' }));
10
+ }
11
+ const CalendarLink = ({ href, children, }) => (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", role: "menuitem", "data-trackable": "liveqa_calendar-link", className: "live-qa-calendar-list__item__link", children: children }));
12
+ function itemChildren(name, icon) {
13
+ return (_jsxs(_Fragment, { children: [_jsx("span", { className: "live-qa-calendar-list__item__icon", children: icon }), _jsx("span", { className: "live-qa-calendar-list__item__text", children: name })] }));
14
+ }
15
+ const description = (url) => `To join the Q&A session, visit: ${url}`;
16
+ const apps = [
17
+ {
18
+ name: 'Gmail',
19
+ icon: (_jsx("svg", { className: "live-qa-calendar-icon", xmlns: "http://www.w3.org/2000/svg", width: "24", height: "25", viewBox: "0 0 24 25", fill: "none", children: _jsxs("g", { transform: "translate(4, 3.36)", children: [_jsx("path", { "fill-rule": "evenodd", "clip-rule": "evenodd", d: "M15.8514 7.64502H8.16309V10.8162H12.5564C12.3672 11.841 11.792 12.7093 10.9275 13.2906V15.3476H13.5657C15.1093 13.9204 15.9998 11.8187 15.9998 9.32193C15.9998 8.7406 15.9479 8.18163 15.8514 7.64502Z", fill: "black" }), _jsx("path", { "fill-rule": "evenodd", "clip-rule": "evenodd", d: "M8.16315 17.3339C10.3672 17.3339 12.2151 16.5998 13.5658 15.3477L10.9275 13.2907C10.1966 13.7826 9.26149 14.0733 8.16315 14.0733C6.037 14.0733 4.23737 12.6311 3.59544 10.6934H0.868164V12.8174C2.21139 15.4968 4.97206 17.3339 8.16315 17.3339Z", fill: "black" }), _jsx("path", { "fill-rule": "evenodd", "clip-rule": "evenodd", d: "M3.59555 10.6933C3.43228 10.2014 3.33952 9.67595 3.33952 9.13561C3.33952 8.59527 3.43228 8.06984 3.59555 7.57795V5.45386H0.868275C0.315399 6.56062 0 7.81272 0 9.13561C0 10.4585 0.315399 11.7106 0.868275 12.8174L3.59555 10.6933Z", fill: "black" }), _jsx("path", { "fill-rule": "evenodd", "clip-rule": "evenodd", d: "M8.16316 4.19816C9.36167 4.19816 10.4377 4.6118 11.2837 5.42417L13.6251 3.07277C12.2114 1.74987 10.3635 0.9375 8.16316 0.9375C4.97206 0.9375 2.21139 2.77465 0.868164 5.45399L3.59544 7.57808C4.23737 5.64031 6.037 4.19816 8.16316 4.19816Z", fill: "black" })] }) })),
20
+ getLink: ({ title, startDateTime, endDateTime, url }) => `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${startDateTime}/${endDateTime}&details=${encodeURIComponent(description(url))}&location=${encodeURIComponent(url)}`,
21
+ },
22
+ {
23
+ name: 'Outlook',
24
+ icon: (_jsx("svg", { className: "live-qa-calendar-icon", xmlns: "http://www.w3.org/2000/svg", width: "24", height: "25", viewBox: "0 0 24 25", fill: "none", children: _jsx("path", { d: "M21.9979 8.49306V17.3819C22.0068 17.5304 21.987 17.6792 21.9399 17.8185C21.8927 17.9578 21.8194 18.0842 21.7247 18.1894C21.63 18.2946 21.5162 18.3761 21.3909 18.4285C21.2656 18.4809 21.1316 18.5029 20.9981 18.4931H13.9988V22.9375L2 20.9375V4.9375L13.9988 2.9375V7.38194H20.9981C21.1316 7.37212 21.2656 7.39413 21.3909 7.4465C21.5162 7.49886 21.63 7.58037 21.7247 7.68558C21.8194 7.79078 21.8927 7.91724 21.9399 8.0565C21.987 8.19576 22.0068 8.3446 21.9979 8.49306ZM20.9981 8.60417L16.9985 11.1597L13.9988 9.27083V11.0486L16.9985 12.9375L20.9981 10.3819V8.60417ZM7.91939 8.60417C5.81961 8.52639 4.78971 10.5819 4.78971 13.0486C4.78971 15.5153 5.8596 17.4931 7.91939 17.4931C9.7892 17.4931 10.9991 15.6153 10.9991 13.1708C10.9991 10.7264 10.1292 8.72639 7.91939 8.6375M7.60943 10.3819C8.44934 10.3819 9.19926 11.2042 9.19926 12.8819C9.19926 14.5597 8.44934 15.5042 7.60943 15.5042C6.76951 15.5042 6.09958 14.2708 6.09958 12.9375C6.09958 11.6042 6.77951 10.3819 7.60943 10.3819Z", fill: "black" }) })),
25
+ getLink: ({ title, startDateTime, endDateTime, url }) => `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent(title)}&startdt=${startDateTime}&enddt=${endDateTime}&body=${encodeURIComponent(description(url))}&location=${encodeURIComponent(url)}`,
26
+ },
27
+ {
28
+ name: 'Apple',
29
+ icon: (_jsx("svg", { className: "live-qa-calendar-icon", xmlns: "http://www.w3.org/2000/svg", width: "24", height: "25", viewBox: "0 0 24 25", fill: "none", children: _jsx("path", { d: "M19.8023 9.75502C19.6851 9.84618 17.5884 11.002 17.5884 13.5675C17.5884 16.5367 20.2548 17.5851 20.3362 17.6111C20.3232 17.6762 19.9097 19.0501 18.9297 20.4501C18.0507 21.684 17.1358 22.9115 15.7424 22.9115C14.3522 22.9115 13.994 22.1236 12.3857 22.1236C10.8197 22.1236 10.263 22.9375 8.98671 22.9375C7.71371 22.9375 6.8249 21.798 5.80259 20.398C4.62076 18.7539 3.66357 16.1981 3.66357 13.7759C3.66357 9.88525 6.25189 7.82436 8.80113 7.82436C10.1523 7.82436 11.282 8.69365 12.1317 8.69365C12.9392 8.69365 14.2024 7.77227 15.7424 7.77227C16.3251 7.77227 18.4218 7.82436 19.8023 9.75502ZM15.0098 6.12487C15.6479 5.38907 16.0972 4.36351 16.0972 3.33796C16.0972 3.19796 16.0842 3.05471 16.0582 2.9375C15.0228 2.97657 13.7889 3.61144 13.0466 4.45467C12.4638 5.10257 11.9169 6.12487 11.9169 7.16345C11.9169 7.31972 11.9462 7.476 11.9559 7.52483C12.0243 7.53786 12.1285 7.55088 12.2359 7.55088C13.1671 7.55088 14.3359 6.94531 15.0098 6.12487Z", fill: "black" }) })),
30
+ getLink: ({ title, startDateTime, endDateTime, url }) => createIcsFile(title, startDateTime, endDateTime, url),
31
+ isDownload: true,
32
+ },
33
+ {
34
+ name: 'Yahoo',
35
+ icon: (_jsx("svg", { className: "live-qa-calendar-icon", xmlns: "http://www.w3.org/2000/svg", width: "24", height: "25", viewBox: "0 0 24 25", fill: "none", children: _jsx("path", { d: "M15.3929 13.7528C15.3614 13.7513 15.3247 13.7506 15.2879 13.7506C13.8674 13.7506 12.7109 14.8793 12.6659 16.2886V16.2931C12.7049 17.6536 13.8172 18.7411 15.1837 18.7411C15.2182 18.7411 15.2519 18.7403 15.2857 18.7388H15.2812C15.3194 18.7411 15.3637 18.7418 15.4087 18.7418C16.8217 18.7418 17.9737 17.6228 18.0269 16.2226V16.2181C18.0037 14.8501 16.8892 13.7498 15.5174 13.7498C15.4739 13.7498 15.4304 13.7513 15.3869 13.7528H15.3929ZM0.75293 7.97933L5.57993 18.8138L3.84593 22.7221H8.09168L14.5214 7.97933H10.3042L7.67993 14.2868L5.08343 7.97933H0.75368H0.75293ZM18.4297 3.15308L14.1277 12.8161H18.9359L23.2469 3.15308H18.4297Z", fill: "black" }) })),
36
+ getLink: ({ title, startDateTime, endDateTime, url }) => `https://calendar.yahoo.com/?v=60&title=${encodeURIComponent(title)}&st=${formatDateForIcs(startDateTime)}&et=${formatDateForIcs(endDateTime)}&desc=${encodeURIComponent(description(url))}&in_loc=${encodeURIComponent(url)}`,
37
+ },
38
+ ];
39
+ export default apps.map(({ name, icon, getLink, isDownload }) => ({
40
+ name,
41
+ icon,
42
+ link: ({ title, startDateTime, endDateTime, url, }) => {
43
+ const href = getLink({ title, startDateTime, endDateTime, url });
44
+ return isDownload ? (_jsx("a", { href: href, download: "event.ics", role: "menuitem", "data-trackable": "liveqa_calendar-link", className: "live-qa-calendar-list__item__link", children: itemChildren(name, icon) })) : (_jsx(CalendarLink, { href: href, children: itemChildren(name, icon) }));
45
+ },
46
+ }));
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "preact/jsx-runtime";
2
+ import CalendarListItem from './CalendarListItem';
3
+ import apps from './Apps';
4
+ function CalendarList() {
5
+ return (_jsx("ul", { className: "live-qa-calendar-list", id: "live-qa-calendar-list-menu", "data-testid": "live-qa-calendar-list-menu", role: "menu", "aria-labelledby": "live-qa-calendar-button-menu", children: apps && apps.map((app) => _jsx(CalendarListItem, { ...app })) }));
6
+ }
7
+ export default CalendarList;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, Fragment as _Fragment } from "preact/jsx-runtime";
2
+ import { useContext } from 'react';
3
+ import { QandaContext } from '../QandaProvider';
4
+ import { useGetQandAStreamQuery } from '../../services/comments-api';
5
+ function CalendarListItem({ name, link }) {
6
+ const { storyId, useStaging, commentsAPIUrl, title } = useContext(QandaContext);
7
+ const { data } = useGetQandAStreamQuery({
8
+ storyId,
9
+ useStaging,
10
+ commentsAPIUrl,
11
+ // Calendar is only relevant for pre-live Q&A
12
+ status: 'pre-live',
13
+ });
14
+ return (_jsx(_Fragment, { children: data?.startDateTime && data?.endDateTime && (_jsx("li", { className: 'live-qa-calendar-list__item', role: "presentation", "data-testid": "live-qa-calendar-list-item", children: link({
15
+ title,
16
+ startDateTime: data.startDateTime,
17
+ endDateTime: data.endDateTime,
18
+ url: `https://www.ft.com/content/${storyId}`,
19
+ children: name,
20
+ }) })) }));
21
+ }
22
+ export default CalendarListItem;
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
2
+ import { Button } from '@financial-times/o3-button';
3
+ import { useState, useRef, useEffect, useContext } from 'react';
4
+ import CalendarList from './CalendarList';
5
+ import { useGetQandAStreamQuery } from '../../services/comments-api';
6
+ import { QandaContext } from '../QandaProvider';
7
+ function Calendar() {
8
+ const [dropDownVisible, setDropDownVisible] = useState(false);
9
+ const containerRef = useRef(null);
10
+ const { storyId, useStaging, commentsAPIUrl } = useContext(QandaContext);
11
+ const { data } = useGetQandAStreamQuery({
12
+ storyId,
13
+ useStaging,
14
+ commentsAPIUrl,
15
+ // Calendar is only relevant for pre-live Q&A
16
+ // so we can hardcode the status here
17
+ status: 'pre-live',
18
+ });
19
+ useEffect(() => {
20
+ const handleClickOutside = (event) => {
21
+ if (containerRef.current &&
22
+ event.target instanceof Node &&
23
+ !containerRef.current.contains(event.target)) {
24
+ setDropDownVisible(false);
25
+ }
26
+ };
27
+ document.addEventListener('click', handleClickOutside);
28
+ return () => {
29
+ document.removeEventListener('click', handleClickOutside);
30
+ };
31
+ }, []);
32
+ return (_jsx(_Fragment, { children: data?.status === 'pre-live' && (_jsxs("div", { className: "live-qa-calendar-container", ref: containerRef, children: [_jsx(Button, { label: "Add to your calendar", type: "secondary", theme: "mono", icon: "calendar", iconOnly: false, attributes: {
33
+ className: 'live-qa-calendar-button',
34
+ 'aria-pressed': dropDownVisible,
35
+ 'aria-haspopup': 'true',
36
+ 'aria-controls': 'menu',
37
+ id: 'live-qa-calendar-button-menu',
38
+ 'data-testid': 'live-qa-calendar-button',
39
+ 'data-trackable': 'liveqa_add-to-calendar-button',
40
+ }, onClick: () => setDropDownVisible(!dropDownVisible) }), dropDownVisible && _jsx(CalendarList, {})] })) }));
41
+ }
42
+ export default Calendar;
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx, Fragment as _Fragment } from "preact/jsx-runtime";
2
+ import { useEffect, useState, useContext, createPortal } from 'react';
3
+ import { QandaContext } from '../QandaProvider';
4
+ import { useGetQandAStreamQuery, useUpdatedQA, } from '../../services/comments-api';
5
+ export function formatTime(remainingTime) {
6
+ const days = Math.floor(remainingTime / (1000 * 60 * 60 * 24));
7
+ if (days >= 1) {
8
+ return `${days} day${days === 1 ? '' : 's'} `;
9
+ }
10
+ const hours = Math.floor((remainingTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
11
+ if (hours >= 1) {
12
+ return `${hours} hour${hours === 1 ? '' : 's'}`;
13
+ }
14
+ const minutes = Math.floor((remainingTime % (1000 * 60 * 60)) / (1000 * 60));
15
+ if (minutes >= 1) {
16
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
17
+ }
18
+ const seconds = Math.floor((remainingTime % (1000 * 60)) / 1000);
19
+ if (seconds >= 1) {
20
+ return `${seconds} second${seconds === 1 ? '' : 's'}`;
21
+ }
22
+ }
23
+ function CountdownTimer({ container }) {
24
+ const { storyId, useStaging, commentsAPIUrl } = useContext(QandaContext);
25
+ const { data } = useGetQandAStreamQuery({
26
+ storyId,
27
+ useStaging,
28
+ commentsAPIUrl,
29
+ // Countdown is only relevant for pre-live Q&A
30
+ // so we can hardcode the status here
31
+ status: 'pre-live',
32
+ });
33
+ const updatedQA = useUpdatedQA({
34
+ storyId,
35
+ useStaging,
36
+ commentsAPIUrl,
37
+ });
38
+ const [count, setCount] = useState('');
39
+ const status = updatedQA?.status || data?.status || 'pre-live';
40
+ const startDateTime = updatedQA?.startDateTime || data?.startDateTime;
41
+ const endDateTime = updatedQA?.endDateTime || data?.endDateTime;
42
+ useEffect(() => {
43
+ function calculateTimeLeft() {
44
+ const currentDate = Date.now();
45
+ const isLive = status === 'live';
46
+ const timeForCountdown = isLive ? endDateTime : startDateTime;
47
+ if (!timeForCountdown) {
48
+ return;
49
+ }
50
+ const remainingTime = new Date(timeForCountdown).getTime() - currentDate;
51
+ const eventInfo = isLive ? 'Ending' : 'Starting';
52
+ if (remainingTime !== null && !isNaN(remainingTime)) {
53
+ if (remainingTime >= 1000) {
54
+ setCount(`${eventInfo} in ${formatTime(remainingTime)}`);
55
+ }
56
+ else {
57
+ setCount(`${eventInfo} Soon`);
58
+ }
59
+ }
60
+ }
61
+ calculateTimeLeft();
62
+ const interval = setInterval(calculateTimeLeft, 1000);
63
+ return () => clearInterval(interval);
64
+ }, [
65
+ data?.status,
66
+ data?.startDateTime,
67
+ data?.endDateTime,
68
+ updatedQA?.status,
69
+ updatedQA?.startDateTime,
70
+ updatedQA?.endDateTime,
71
+ ]);
72
+ const [portalTarget, setPortalTarget] = useState();
73
+ useEffect(() => {
74
+ if (container) {
75
+ const portalContainer = document.createElement('div');
76
+ container.prepend(portalContainer);
77
+ setPortalTarget(portalContainer);
78
+ return () => {
79
+ portalContainer.remove();
80
+ };
81
+ }
82
+ }, []);
83
+ const portalElement = data &&
84
+ ((status === 'pre-live' && startDateTime) ||
85
+ (status === 'live' && endDateTime)) && (_jsx("div", { className: "qanda-portal countdown-wrapper", children: _jsx("div", { className: "countdown-block", children: _jsx("p", { className: "countdown", children: count && (_jsx("time", { datetime: status === 'pre-live' ? startDateTime : endDateTime, children: count })) }) }) }));
86
+ return _jsx(_Fragment, { children: createPortal(portalElement, portalTarget || document.body) });
87
+ }
88
+ export default CountdownTimer;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
2
+ import { useDispatch } from 'react-redux';
3
+ import { openForm } from '../../store/questionSlice';
4
+ import { useEffect, useState } from 'react';
5
+ import { createPortal } from 'react-dom';
6
+ function EmbedFormField({ container }) {
7
+ const dispatch = useDispatch();
8
+ const [portalTarget, setPortalTarget] = useState();
9
+ useEffect(() => {
10
+ if (container) {
11
+ const portalContainer = document.createElement('div');
12
+ container.prepend(portalContainer);
13
+ setPortalTarget(portalContainer);
14
+ return () => {
15
+ portalContainer.remove();
16
+ };
17
+ }
18
+ }, []);
19
+ const portalElement = (_jsxs("div", { className: "qanda-portal embed-form-field__container o3-type-body-highlight", children: ["Submit your question to the Q&A", _jsxs("div", { onClick: () => dispatch(openForm()), className: "embed-form-field__text-input o3-type-body-lg", "data-testid": "embed-form-field", children: [_jsx("span", { className: "embed-form-field__icon", children: _jsx("i", { style: { maskImage: 'var(--o3-icon-user)' } }) }), "Enter your question"] })] }));
20
+ return _jsx(_Fragment, { children: createPortal(portalElement, portalTarget || document.body) });
21
+ }
22
+ export default EmbedFormField;
@@ -0,0 +1,71 @@
1
+ const baseErrorMessages = {
2
+ generic: {
3
+ title: 'An error occurred',
4
+ message: 'Please try again later.',
5
+ },
6
+ serverError: {
7
+ title: 'Server error',
8
+ message: 'There was a server error. Please try again later.',
9
+ },
10
+ connectionError: {
11
+ title: 'Connection error',
12
+ message: 'Check your internet connection or browser settings and try again.',
13
+ },
14
+ timeoutError: {
15
+ title: 'Timeout error',
16
+ message: 'The request timed out. You may be on a slow connection. Please try again later.',
17
+ },
18
+ };
19
+ export default class HumanReadableError {
20
+ constructor(error, customErrorMessages = {}) {
21
+ // Default to generic error message
22
+ this._humanReadableError = baseErrorMessages.generic;
23
+ this._unMappedError = null;
24
+ const errorMessages = {
25
+ ...baseErrorMessages,
26
+ ...customErrorMessages,
27
+ };
28
+ try {
29
+ if ('status' in error) {
30
+ if (error.status === 'FETCH_ERROR') {
31
+ this._humanReadableError = errorMessages.connectionError;
32
+ }
33
+ else if (typeof error.status === 'number' && error.status >= 500) {
34
+ this._humanReadableError = errorMessages.serverError;
35
+ }
36
+ else if (typeof error.status === 'number' && error.status === 400) {
37
+ // Assuming as an error code otherwise will throw an error and we will fallback to generic error
38
+ const typedError = error;
39
+ this._humanReadableError =
40
+ errorMessages[typedError.data.error.errorCode];
41
+ }
42
+ }
43
+ else if ('name' in error) {
44
+ if (error.name === 'AbortError') {
45
+ this._humanReadableError = errorMessages.timeoutError;
46
+ }
47
+ }
48
+ }
49
+ catch (err) {
50
+ // Fallback to generic error message if we cannot parse the error correctly
51
+ this._humanReadableError = errorMessages.generic;
52
+ this._unMappedError = {
53
+ name: err && typeof err === 'object' && 'name' in err
54
+ ? String(err.name)
55
+ : '',
56
+ message: err && typeof err === 'object' && 'message' in err
57
+ ? String(err.message)
58
+ : '',
59
+ };
60
+ }
61
+ }
62
+ get title() {
63
+ return this._humanReadableError.title;
64
+ }
65
+ get message() {
66
+ return this._humanReadableError.message;
67
+ }
68
+ get unmappedError() {
69
+ return this._unMappedError;
70
+ }
71
+ }
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx } from "preact/jsx-runtime";
2
+ import MessageBox from '../MessageBox';
3
+ import { trackEvent } from '../../utils/tracking';
4
+ import HumanReadableError from './humanReadableError';
5
+ import { useEffect } from 'react';
6
+ import { cleanString } from '../../utils/strings';
7
+ export default function ErrorMessage({ error, storyId, errorMapping = {}, operation = 'not-specified', id = '', }) {
8
+ const humanReadableError = new HumanReadableError(error, errorMapping);
9
+ let errorDetails = [];
10
+ // If the error is unmapped, we log it for debugging purposes
11
+ if (humanReadableError.unmappedError) {
12
+ errorDetails = [
13
+ {
14
+ name: 'error_name',
15
+ value: humanReadableError.unmappedError.name,
16
+ },
17
+ {
18
+ name: 'error_message',
19
+ value: humanReadableError.unmappedError.message,
20
+ },
21
+ ];
22
+ }
23
+ else {
24
+ errorDetails = [
25
+ {
26
+ name: 'error',
27
+ value: cleanString(humanReadableError.title),
28
+ },
29
+ {
30
+ name: 'error_message',
31
+ value: cleanString(humanReadableError.message),
32
+ },
33
+ ];
34
+ }
35
+ useEffect(() => {
36
+ // Critical operation are tracked by the reliability service using AWS RUM
37
+ // We still log this to monitor untracked messages to the users.
38
+ trackEvent({
39
+ category: 'component',
40
+ action: 'act',
41
+ content: {
42
+ asset_type: 'article',
43
+ uuid: storyId,
44
+ },
45
+ trigger_action: operation,
46
+ custom: [
47
+ {
48
+ name: 'state',
49
+ value: 'error',
50
+ },
51
+ ...errorDetails,
52
+ ],
53
+ });
54
+ // Ensure trackEvent is only called once
55
+ }, []);
56
+ return (_jsx(MessageBox, { color: "var(--o3-color-use-case-error)", extraClassNames: "o-message--alert o-message--error", title: humanReadableError.title, text: humanReadableError.message, id: id }));
57
+ }
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ import { useState, useRef, useEffect } from 'react';
3
+ import { Button } from '@financial-times/o3-button';
4
+ // Generic debounce function
5
+ function debounce(func, delay) {
6
+ let timerId;
7
+ return (...args) => {
8
+ if (timerId)
9
+ clearTimeout(timerId);
10
+ timerId = setTimeout(() => {
11
+ func(...args);
12
+ }, delay);
13
+ };
14
+ }
15
+ // While comments submitted by Coral UI are always wrapped in paragraphs,
16
+ // comments submitted via our `Ask a question` form are just text.
17
+ // A wrapping paragraph is always needed for the expander to work correctly.
18
+ function EnsureParagraph({ children }) {
19
+ if (typeof children === 'object' &&
20
+ children !== null &&
21
+ 'props' in children &&
22
+ children.props?.structuredContent?.tree?.children?.[0]?.type === 'text') {
23
+ return _jsx("p", { children: children });
24
+ }
25
+ return children;
26
+ }
27
+ function Expander({ className = '', children = [], expandLabel = 'Expand', collapseLabel = 'Collapse', id, }) {
28
+ const textRef = useRef(null);
29
+ const [requiresExpander, setExpander] = useState(false);
30
+ const [state, setState] = useState('collapsed');
31
+ const expanderContentID = `expander_content-${id}`;
32
+ useEffect(() => {
33
+ const checkNeedForExpanderButton = () => {
34
+ const element = textRef.current;
35
+ if (!element) {
36
+ return;
37
+ }
38
+ const numberOfCharacters = element.textContent?.length || 300;
39
+ const windowWidth = window.visualViewport?.width || window.innerWidth || 1200; // Fallback to desktop configuration.
40
+ // Check if the element's height exceeds 3 lines for mobile or 1 line for above mobile
41
+ // Note: This need to stay aligned with the CSS for the expander
42
+ const mobileNumberOfCharacters = 100;
43
+ const desktopNumberOfCharacters = 200;
44
+ const characterForExpander = windowWidth <= 490
45
+ ? mobileNumberOfCharacters
46
+ : desktopNumberOfCharacters;
47
+ setExpander(numberOfCharacters > characterForExpander);
48
+ };
49
+ const debouncedCheck = debounce(checkNeedForExpanderButton, 250);
50
+ // Use requestAnimationFrame to ensure layout is complete
51
+ requestAnimationFrame(checkNeedForExpanderButton);
52
+ window.addEventListener('resize', debouncedCheck);
53
+ return () => window.removeEventListener('resize', debouncedCheck);
54
+ }, [children]); // re-run whenever the children change, although we don't expect it to
55
+ const label = state === 'expanded' ? collapseLabel : expandLabel;
56
+ const expandCollapseClass = state === 'expanded' ? 'expander--expanded' : 'expander--collapsed';
57
+ const componentClassName = `${expandCollapseClass} ${className}`;
58
+ return (_jsxs("div", { className: `${componentClassName}`, children: [_jsx("div", { className: `expander__text ${requiresExpander ? 'expander__text--multiline' : ''}`, id: expanderContentID, ref: textRef, children: _jsx(EnsureParagraph, { children: children }) }), requiresExpander && (_jsx(Button, { label: label, type: "ghost", theme: "neutral", icon: state === 'expanded' ? 'chevron-up' : 'chevron-down', iconOnly: false, iconPosition: "end", attributes: {
59
+ className: 'expander__link',
60
+ 'data-trackable': `liveqa_expander`,
61
+ 'data-trackable-context-action': label,
62
+ 'aria-expanded': state === 'expanded' ? true : false,
63
+ 'aria-controls': expanderContentID,
64
+ 'data-action': state === 'expanded' ? 'collapse' : 'expand',
65
+ }, onClick: () => {
66
+ setState((prevState) => prevState === 'expanded' ? 'collapsed' : 'expanded');
67
+ } }))] }));
68
+ }
69
+ export default Expander;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "preact/jsx-runtime";
2
+ import OComments from '@financial-times/o-comments';
3
+ import { useEffect, useContext } from 'react';
4
+ import { useDispatch, useSelector } from 'react-redux';
5
+ import { closeExpertForm } from '../../store/expertSlice';
6
+ import { QandaContext } from '../QandaProvider';
7
+ import { qandaReliability } from '../../services/reliability';
8
+ import Overlay from '../Overlay';
9
+ function ExpertDrawer() {
10
+ const dispatch = useDispatch();
11
+ const { storyId, useStaging, title } = useContext(QandaContext);
12
+ const { isExpertFormOpen, expertView } = useSelector((state) => state.expert);
13
+ useEffect(() => {
14
+ if (isExpertFormOpen) {
15
+ try {
16
+ OComments.init();
17
+ }
18
+ catch (error) {
19
+ qandaReliability.failure('expert-load-coral-ui', error);
20
+ }
21
+ }
22
+ }, [isExpertFormOpen]);
23
+ if (!isExpertFormOpen) {
24
+ return null;
25
+ }
26
+ return (_jsx(Overlay, { title: "Live Q&A", closeCallback: () => dispatch(closeExpertForm()), options: {
27
+ shadow: true,
28
+ noBackground: true,
29
+ }, children: _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 }) }));
30
+ }
31
+ export default ExpertDrawer;
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ import { useDispatch, useSelector } from 'react-redux';
3
+ import { Button } from '@financial-times/o3-button';
4
+ import { openForm } from '../../store/questionSlice';
5
+ import { openExpertForm } from '../../store/expertSlice';
6
+ import { useIsMobile } from '../../hooks/useIsMobile';
7
+ import { useUpdatedComments } from '../../services/comments-api';
8
+ import { updateComments } from '../../store/updateComments';
9
+ import { useContext } from 'react';
10
+ import { QandaContext } from '../QandaProvider';
11
+ function LiveIndicator() {
12
+ return (_jsx("div", { className: "o-labels-indicator--live o3-type-label floating-action-bar__live-container", children: _jsx("span", { className: "o-labels-indicator__status floating-action-bar__live-label", children: "Live" }) }));
13
+ }
14
+ function FloatingActionBar({ showLiveIndicator = true, }) {
15
+ const dispatch = useDispatch();
16
+ const isMobile = useIsMobile();
17
+ const { storyId, useStaging, commentsAPIUrl } = useContext(QandaContext);
18
+ const updatedComments = useUpdatedComments({
19
+ storyId,
20
+ useStaging,
21
+ commentsAPIUrl,
22
+ });
23
+ const handleShowUpdates = () => {
24
+ updateComments(dispatch, updatedComments, storyId, useStaging, commentsAPIUrl);
25
+ // Scrolling to start of qanda blocks container
26
+ const element = document.getElementById('qanda__blocks-container');
27
+ if (element) {
28
+ element.scrollIntoView({ behavior: 'smooth', block: 'start' });
29
+ }
30
+ };
31
+ const showExpertView = useSelector((state) => state.expert.expertView);
32
+ const userConfig = {
33
+ descripton: 'Ask a question',
34
+ testIdSuffix: 'ask-a-question-button',
35
+ onClick: () => dispatch(openForm()),
36
+ dataTrackable: 'open_question_form',
37
+ };
38
+ const expertConfig = {
39
+ descripton: 'Answer Q&A',
40
+ testIdSuffix: 'answer-a-qanda-button',
41
+ onClick: () => dispatch(openExpertForm()),
42
+ dataTrackable: 'open_expert_drawer',
43
+ };
44
+ const view = showExpertView ? expertConfig : userConfig;
45
+ return (_jsxs("div", { className: "floating-action-bar__container", "data-trackable": "floating-action-bar", children: [_jsx("div", { className: "floating-action-bar__fade-overlay" }), _jsxs("div", { className: "floating-action-bar", "data-testid": "floating-action-bar", children: [showLiveIndicator && _jsx(LiveIndicator, {}), _jsxs("div", { className: "floating-action-bar__buttons", children: [_jsx(Button, { attributes: {
46
+ ariaLabel: view.descripton,
47
+ 'data-trackable': view.dataTrackable,
48
+ 'data-trackable-context-action': view.descripton,
49
+ 'data-testid': `floating-action-bar__${view.testIdSuffix}`,
50
+ }, label: view.descripton, type: "ghost", theme: "mono", icon: "edit", iconOnly: isMobile, onClick: view.onClick }), _jsx(Button, { attributes: {
51
+ ariaLabel: 'Back to top',
52
+ 'data-trackable': 'back_to_top',
53
+ 'data-trackable-context-action': 'Back to top',
54
+ 'data-testid': 'floating-action-bar__back-to-top-button',
55
+ }, label: "Back to top", type: "ghost", theme: "mono", icon: "scroll-to", iconOnly: isMobile, onClick: handleShowUpdates })] }), updatedComments?.length > 0 && (_jsx("div", { className: "floating-action-bar__updates-counter o3-type-detail", children: updatedComments.length }))] })] }));
56
+ }
57
+ export default FloatingActionBar;
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx } from "preact/jsx-runtime";
2
+ export default function Loader({ size = 'small', theme = 'light', extraClassNames = '', }) {
3
+ return (_jsx("div", { className: `o-loading o-loading--${theme} o-loading--${size} ${extraClassNames}`, role: "progressbar", "aria-label": "Loading content" }));
4
+ }
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ function MessageBox({ color, extraClassNames = '', text, title, id = '', }) {
3
+ return (_jsx("div", { class: `o-message o-message--inner message-box__container ${extraClassNames}`, "data-o-message-close": "false", "data-o-component": "o-message", id: id, children: _jsx("div", { class: "o-message__container", children: _jsxs("div", { class: "message-box__content", children: [_jsx("span", { style: { color }, class: "o3-type-body-highlight", children: title }), _jsx("span", { style: { color }, class: "o3-type-body-base", children: text })] }) }) }));
4
+ }
5
+ export default MessageBox;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ import { Button } from '@financial-times/o3-button';
3
+ import classNames from 'classnames';
4
+ function Overlay({ title, children, closeCallback, options = {}, }) {
5
+ return (_jsxs("div", { className: "overlay", children: [_jsx("div", { className: classNames([
6
+ 'overlay__outer',
7
+ { 'overlay__outer--no-background': options.noBackground },
8
+ ]) }), _jsxs("div", { className: classNames([
9
+ 'overlay__inner',
10
+ { 'overlay__inner--shadow': options.shadow },
11
+ ]), children: [_jsx("div", { className: "overlay__header", children: _jsxs("div", { className: "overlay__header-bar", children: [_jsx("span", { className: "o3-type-body-highlight overlay__header-title", children: title }), _jsx(Button, { attributes: { className: 'overlay__header-close' }, iconOnly: true, icon: "cross", label: "Close", type: "ghost", theme: "mono", onClick: () => closeCallback() })] }) }), _jsx("div", { className: "overlay__content", children: children })] })] }));
12
+ }
13
+ export default Overlay;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "preact/jsx-runtime";
2
+ import { Provider } from 'react-redux';
3
+ import { store } from '../store';
4
+ import QandaContainer from './QandaContainer';
5
+ import { QandaContext } from './QandaProvider';
6
+ function Qanda({ storyId, useStaging, title, commentsAPIHost, commentsAPIVersion, maxQuestionLength, embedFormFieldContainer, countdownTimerContainer, }) {
7
+ // check we have a valid ft.com url and remove any trailing slashes
8
+ const allowedHostRegx = /^[\w\-\.]+(\.ft\.com|\.ftweb\.tech)$/i;
9
+ const allowedVersionRegx = /^v\d+$/i;
10
+ const validateApiUrl = (url, version) => {
11
+ const baseURL = new URL(url);
12
+ if (!allowedHostRegx.test(baseURL.hostname) ||
13
+ !allowedVersionRegx.test(version)) {
14
+ console.error('Invalid comments API details provided');
15
+ return;
16
+ }
17
+ return `https://${baseURL.hostname}/qa/${version}`;
18
+ };
19
+ const cleanCommentsAPIUrl = validateApiUrl(commentsAPIHost, commentsAPIVersion);
20
+ if (!storyId || !title || !cleanCommentsAPIUrl) {
21
+ console.error('Missing required props');
22
+ return null;
23
+ }
24
+ return (_jsx(Provider, { store: store, children: _jsx(QandaContext.Provider, { value: {
25
+ storyId,
26
+ useStaging,
27
+ commentsAPIUrl: cleanCommentsAPIUrl,
28
+ title,
29
+ maxQuestionLength,
30
+ }, children: _jsx(QandaContainer, { embedFormFieldContainer: embedFormFieldContainer, countdownTimerContainer: countdownTimerContainer }) }) }));
31
+ }
32
+ export default Qanda;