@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.
- package/README.md +38 -6
- package/dist/cjs/components/Author/index.js +19 -0
- package/dist/cjs/components/Calendar/Apps.js +48 -0
- package/dist/cjs/components/Calendar/CalendarList.js +12 -0
- package/dist/cjs/components/Calendar/CalendarListItem.js +24 -0
- package/dist/cjs/components/Calendar/index.js +47 -0
- package/dist/cjs/components/CountdownTimer/index.js +91 -0
- package/dist/cjs/components/EmbedFormField/index.js +24 -0
- package/dist/cjs/components/ErrorMessage/humanReadableError.js +74 -0
- package/dist/cjs/components/ErrorMessage/index.js +63 -0
- package/dist/cjs/components/Expander/index.js +71 -0
- package/dist/cjs/components/ExpertDrawer/index.js +36 -0
- package/dist/cjs/components/FloatingActionBar/index.js +59 -0
- package/dist/cjs/components/Loader/index.js +7 -0
- package/dist/cjs/components/MessageBox/index.js +7 -0
- package/dist/cjs/components/Overlay/index.js +18 -0
- package/dist/cjs/components/Qanda.js +37 -0
- package/dist/cjs/components/QandaBlock/index.js +78 -0
- package/dist/cjs/components/QandaContainer.js +99 -0
- package/dist/cjs/components/QandaProvider.js +11 -0
- package/dist/cjs/components/QuestionForm/index.js +165 -0
- package/dist/cjs/components/QuestionForm/messagesByUserType.js +33 -0
- package/dist/cjs/components/Stream/index.js +37 -0
- package/dist/cjs/components/Survey/index.js +36 -0
- package/dist/cjs/hooks/useIsMobile.js +15 -0
- package/dist/cjs/index.js +53 -0
- package/dist/cjs/services/comments-api.js +152 -0
- package/dist/cjs/services/error-middleware.js +47 -0
- package/dist/cjs/services/reliability.js +79 -0
- package/dist/cjs/store/expertSlice.js +26 -0
- package/dist/cjs/store/index.js +30 -0
- package/dist/cjs/store/questionSlice.js +32 -0
- package/dist/cjs/store/stream.js +17 -0
- package/dist/cjs/store/updateComments.js +40 -0
- package/dist/cjs/store/user.js +51 -0
- package/dist/cjs/utils/auth.js +26 -0
- package/dist/cjs/utils/qandas.js +20 -0
- package/dist/cjs/utils/strings.js +16 -0
- package/dist/cjs/utils/tracking.js +11 -0
- package/dist/cjs/utils/validate-display-name.js +22 -0
- package/dist/esm/components/Author/index.js +14 -0
- package/dist/esm/components/Calendar/Apps.js +46 -0
- package/dist/esm/components/Calendar/CalendarList.js +7 -0
- package/dist/esm/components/Calendar/CalendarListItem.js +22 -0
- package/dist/esm/components/Calendar/index.js +42 -0
- package/dist/esm/components/CountdownTimer/index.js +88 -0
- package/dist/esm/components/EmbedFormField/index.js +22 -0
- package/dist/esm/components/ErrorMessage/humanReadableError.js +71 -0
- package/dist/esm/components/ErrorMessage/index.js +57 -0
- package/dist/esm/components/Expander/index.js +69 -0
- package/dist/esm/components/ExpertDrawer/index.js +31 -0
- package/dist/esm/components/FloatingActionBar/index.js +57 -0
- package/dist/esm/components/Loader/index.js +4 -0
- package/dist/esm/components/MessageBox/index.js +5 -0
- package/dist/esm/components/Overlay/index.js +13 -0
- package/dist/esm/components/Qanda.js +32 -0
- package/dist/esm/components/QandaBlock/index.js +73 -0
- package/dist/esm/components/QandaContainer.js +94 -0
- package/dist/esm/components/QandaProvider.js +8 -0
- package/dist/esm/components/QuestionForm/index.js +159 -0
- package/dist/esm/components/QuestionForm/messagesByUserType.js +28 -0
- package/dist/esm/components/Stream/index.js +32 -0
- package/dist/esm/components/Survey/index.js +34 -0
- package/dist/esm/hooks/useIsMobile.js +12 -0
- package/dist/esm/index.js +46 -0
- package/dist/esm/services/comments-api.js +147 -0
- package/dist/esm/services/error-middleware.js +43 -0
- package/dist/esm/services/reliability.js +75 -0
- package/dist/esm/store/expertSlice.js +22 -0
- package/dist/esm/store/index.js +24 -0
- package/dist/esm/store/questionSlice.js +28 -0
- package/dist/esm/store/stream.js +14 -0
- package/dist/esm/store/updateComments.js +36 -0
- package/dist/esm/store/user.js +45 -0
- package/dist/esm/utils/auth.js +24 -0
- package/dist/esm/utils/qandas.js +16 -0
- package/dist/esm/utils/strings.js +13 -0
- package/dist/esm/utils/tracking.js +7 -0
- package/dist/esm/utils/validate-display-name.js +19 -0
- package/dist/qanda.scss +802 -0
- package/dist/types/components/Author/index.d.ts +18 -0
- package/dist/types/components/Calendar/Apps.d.ts +14 -0
- package/dist/types/components/Calendar/CalendarList.d.ts +2 -0
- package/dist/types/components/Calendar/CalendarListItem.d.ts +8 -0
- package/dist/types/components/Calendar/index.d.ts +2 -0
- package/dist/types/components/CountdownTimer/index.d.ts +6 -0
- package/dist/types/components/EmbedFormField/index.d.ts +5 -0
- package/dist/types/components/ErrorMessage/humanReadableError.d.ts +29 -0
- package/dist/types/components/ErrorMessage/index.d.ts +8 -0
- package/dist/types/components/Expander/index.d.ts +8 -0
- package/dist/types/components/ExpertDrawer/index.d.ts +2 -0
- package/dist/types/components/FloatingActionBar/index.d.ts +4 -0
- package/dist/types/components/Loader/index.d.ts +14 -0
- package/dist/types/components/MessageBox/index.d.ts +10 -0
- package/dist/types/components/Overlay/index.d.ts +12 -0
- package/dist/types/components/Qanda.d.ts +3 -0
- package/dist/types/components/QandaBlock/index.d.ts +7 -0
- package/dist/types/components/QandaContainer.d.ts +6 -0
- package/dist/types/components/QandaProvider.d.ts +9 -0
- package/dist/types/components/QuestionForm/index.d.ts +1 -0
- package/dist/types/components/QuestionForm/messagesByUserType.d.ts +7 -0
- package/dist/types/components/Stream/index.d.ts +5 -0
- package/dist/types/components/Survey/index.d.ts +6 -0
- package/dist/types/hooks/useIsMobile.d.ts +1 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/services/comments-api.d.ts +52 -0
- package/dist/types/services/error-middleware.d.ts +2 -0
- package/dist/types/services/reliability.d.ts +19 -0
- package/dist/types/store/expertSlice.d.ts +14 -0
- package/dist/types/store/index.d.ts +218 -0
- package/dist/types/store/questionSlice.d.ts +16 -0
- package/dist/types/store/stream.d.ts +7 -0
- package/dist/types/store/updateComments.d.ts +3 -0
- package/dist/types/store/user.d.ts +26 -0
- package/dist/types/utils/auth.d.ts +6 -0
- package/dist/types/utils/qandas.d.ts +2 -0
- package/dist/types/utils/strings.d.ts +1 -0
- package/dist/types/utils/tracking.d.ts +2 -0
- package/dist/types/utils/validate-display-name.d.ts +1 -0
- package/package.json +35 -29
- package/dist/index.html +0 -19
- package/dist/index.js +0 -2
- package/dist/index.js.LICENSE.txt +0 -15
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
import Byline from '@financial-times/cp-content-pipeline-ui/lib/components/Byline';
|
|
3
|
+
import RichText from '@financial-times/cp-content-pipeline-ui/lib/components/RichText';
|
|
4
|
+
import '../Expander/index';
|
|
5
|
+
import Expander from '../Expander/index';
|
|
6
|
+
import Author from '../Author';
|
|
7
|
+
import { useEffect } from 'react';
|
|
8
|
+
function renderTime(publishedDate) {
|
|
9
|
+
const date = new Date(publishedDate);
|
|
10
|
+
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
|
11
|
+
}
|
|
12
|
+
// Get shadow color based on factor
|
|
13
|
+
function interpolateColor(factor, colorStart = 'ccc1b7', colorEnd = '4d4845', alphaStart = 0.5, alphaEnd = 0.85) {
|
|
14
|
+
const hexToRgb = (hex) => hex.match(/[0-9a-f]{2}/g).map((x) => parseInt(x, 16));
|
|
15
|
+
const startRgb = hexToRgb(colorStart);
|
|
16
|
+
const endRgb = hexToRgb(colorEnd);
|
|
17
|
+
const rgb = startRgb.map((val, i) => Math.round(val + (endRgb[i] - val) * factor));
|
|
18
|
+
const alpha = alphaStart + factor * (alphaEnd - alphaStart);
|
|
19
|
+
return `rgba(${rgb.join(', ')}, ${alpha})`;
|
|
20
|
+
}
|
|
21
|
+
function QandaBlock({ id, body, children, byline, subtitle, showAnswerTime = false, }) {
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let currentBubble = null;
|
|
24
|
+
const factorRef = { current: 0 };
|
|
25
|
+
let animationFrameId;
|
|
26
|
+
const observer = new IntersectionObserver((entries) => {
|
|
27
|
+
entries.forEach((entry) => {
|
|
28
|
+
if (entry.isIntersecting) {
|
|
29
|
+
if (currentBubble !== entry.target) {
|
|
30
|
+
factorRef.current = 0;
|
|
31
|
+
}
|
|
32
|
+
currentBubble = entry.target;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}, {
|
|
36
|
+
root: null,
|
|
37
|
+
rootMargin: '-50% 0px -50% 0px',
|
|
38
|
+
threshold: 0,
|
|
39
|
+
});
|
|
40
|
+
const comments = document.querySelectorAll('.qanda-block__comment');
|
|
41
|
+
comments.forEach((comment) => observer.observe(comment));
|
|
42
|
+
const animate = () => {
|
|
43
|
+
const centerY = window.innerHeight / 2;
|
|
44
|
+
if (currentBubble) {
|
|
45
|
+
const rect = currentBubble.getBoundingClientRect();
|
|
46
|
+
const commentCenterY = rect.top + rect.height / 2;
|
|
47
|
+
const distanceRatio = Math.min(Math.abs(centerY - commentCenterY) / centerY, 1);
|
|
48
|
+
const targetFactor = (1 - distanceRatio) ** 3;
|
|
49
|
+
factorRef.current += (targetFactor - factorRef.current) * 0.1;
|
|
50
|
+
const scale = 1 + 0.04 * factorRef.current;
|
|
51
|
+
const shadow = interpolateColor(factorRef.current);
|
|
52
|
+
currentBubble.style.transform = `scale(${scale})`;
|
|
53
|
+
// NB: drop-shadow blur radius is not calculated the same way as box-shadow blur radius, halving it matches
|
|
54
|
+
currentBubble.style.filter = `drop-shadow(0px 2px 4px ${shadow})`;
|
|
55
|
+
if (factorRef.current < 0.001 && distanceRatio > 0.95) {
|
|
56
|
+
currentBubble.removeAttribute('style');
|
|
57
|
+
currentBubble = null;
|
|
58
|
+
factorRef.current = 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
animationFrameId = requestAnimationFrame(animate);
|
|
62
|
+
};
|
|
63
|
+
animationFrameId = requestAnimationFrame(animate);
|
|
64
|
+
window.addEventListener('resize', animate);
|
|
65
|
+
return () => {
|
|
66
|
+
cancelAnimationFrame(animationFrameId);
|
|
67
|
+
window.removeEventListener('resize', animate);
|
|
68
|
+
observer.disconnect();
|
|
69
|
+
};
|
|
70
|
+
}, []);
|
|
71
|
+
return (_jsxs("div", { className: "qanda-block", children: [_jsxs("div", { className: "qanda-block__comment qanda-block__question", id: id, "data-testid": `qanda-block__question-${id}`, "data-trackable": "liveqa_question", children: [subtitle && (_jsx("div", { className: "o3-type-headline-sm qanda-block__question-subtitle", children: subtitle })), _jsx("div", { className: "o3-type-body-highlight qanda-block__comment-byline", children: _jsx(Byline, { structuredContent: byline, showEditedBy: false }) }), _jsx("div", { className: "o3-type-body-base qanda-block__comment-text", children: _jsx(Expander, { className: "qanda-block__text", expandLabel: "Expand question", collapseLabel: "Collapse question", id: id, children: _jsx(RichText, { structuredContent: body.structured }) }) })] }, id), children?.map((child) => child.body && (_jsxs("div", { className: "qanda-block__comment qanda-block__answer", "data-testid": `qanda-block__answer-${child.id}`, "data-trackable": "liveqa_answer", children: [_jsxs("div", { className: "qanda-block__answer-header", children: [_jsx("div", { className: "o3-type-body-highlight qanda-block__comment-byline", children: child.author ? (_jsx(Author, { byline: child.byline, headshot: child.author.headshot, prefLabel: child.author.prefLabel, role: child.author.role, streamPage: child.author.streamPage })) : (_jsx(Byline, { structuredContent: child.byline, showEditedBy: false })) }), showAnswerTime && (_jsx("span", { className: "o3-type-detail", children: renderTime(child.publishedDate) }))] }), _jsx("div", { className: "o3-type-body-base qanda-block__comment-text", children: _jsx(Expander, { className: "qanda-block__text", expandLabel: "Expand answer", collapseLabel: "Collapse answer", id: id, children: _jsx(RichText, { structuredContent: child.body.structured }) }) })] }, child.id)))] }));
|
|
72
|
+
}
|
|
73
|
+
export default QandaBlock;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "preact/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useContext } from 'react';
|
|
3
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
4
|
+
import { getUserProfile } from '../store/user';
|
|
5
|
+
import { setExpertView } from '../store/expertSlice';
|
|
6
|
+
import QuestionForm from './QuestionForm';
|
|
7
|
+
import Survey from './Survey';
|
|
8
|
+
import ExpertDrawer from './ExpertDrawer';
|
|
9
|
+
import Stream from './Stream';
|
|
10
|
+
import Calendar from './Calendar';
|
|
11
|
+
import CountdownTimer from './CountdownTimer';
|
|
12
|
+
import FloatingActionBar from './FloatingActionBar';
|
|
13
|
+
import EmbedFormField from './EmbedFormField';
|
|
14
|
+
import { QandaContext } from './QandaProvider';
|
|
15
|
+
import oTracking from '@financial-times/o-tracking';
|
|
16
|
+
import { useGetQandAStreamQuery, useUpdatedQA, useGetUserRolesQuery, nextCommentsApi, } from '../services/comments-api';
|
|
17
|
+
function QandaContainer({ embedFormFieldContainer, countdownTimerContainer, }) {
|
|
18
|
+
const { storyId, useStaging, commentsAPIUrl } = useContext(QandaContext);
|
|
19
|
+
const dispatch = useDispatch();
|
|
20
|
+
const [liveMessage, setLiveMessage] = useState('');
|
|
21
|
+
const { data, error, isLoading } = useGetQandAStreamQuery({
|
|
22
|
+
storyId,
|
|
23
|
+
useStaging,
|
|
24
|
+
commentsAPIUrl,
|
|
25
|
+
status: 'pre-live',
|
|
26
|
+
});
|
|
27
|
+
const [ariaMessage, setAriaMessage] = useState('');
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (isLoading) {
|
|
30
|
+
setAriaMessage('Loading Q&A');
|
|
31
|
+
}
|
|
32
|
+
else if (error) {
|
|
33
|
+
setAriaMessage('Error loading Q&A');
|
|
34
|
+
}
|
|
35
|
+
else if (data) {
|
|
36
|
+
setAriaMessage('Q&A stream has been updated');
|
|
37
|
+
}
|
|
38
|
+
}, [isLoading, error, data]);
|
|
39
|
+
const updatedQA = useUpdatedQA({
|
|
40
|
+
storyId,
|
|
41
|
+
useStaging,
|
|
42
|
+
commentsAPIUrl,
|
|
43
|
+
});
|
|
44
|
+
const status = updatedQA?.status || data?.status || 'pre-live';
|
|
45
|
+
const profile = useSelector((state) => state.user.profile);
|
|
46
|
+
const { data: roleData } = useGetUserRolesQuery({
|
|
47
|
+
storyId,
|
|
48
|
+
useStaging,
|
|
49
|
+
commentsAPIUrl,
|
|
50
|
+
token: profile.token,
|
|
51
|
+
}, { skip: !profile.token });
|
|
52
|
+
const expertView = roleData?.expertView;
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (expertView !== undefined) {
|
|
55
|
+
dispatch(setExpertView(expertView));
|
|
56
|
+
}
|
|
57
|
+
}, [expertView, dispatch]);
|
|
58
|
+
const showExpertView = useSelector((state) => state.expert.expertView);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
dispatch(getUserProfile({ useStaging, commentsAPIUrl }));
|
|
61
|
+
return () => {
|
|
62
|
+
// ensure we reset the api state when the component is unmounted
|
|
63
|
+
dispatch(nextCommentsApi.util.resetApiState());
|
|
64
|
+
};
|
|
65
|
+
}, [dispatch, storyId]);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!data || !status)
|
|
68
|
+
return;
|
|
69
|
+
oTracking.view.init({
|
|
70
|
+
selector: '.qanda-container',
|
|
71
|
+
getContextData: () => {
|
|
72
|
+
return {
|
|
73
|
+
component: {
|
|
74
|
+
name: 'liveqa',
|
|
75
|
+
type: 'container',
|
|
76
|
+
id: '663ab2ca-77ca-4b50-873d-1fd2ff21ff08',
|
|
77
|
+
},
|
|
78
|
+
content: {
|
|
79
|
+
asset_type: 'article',
|
|
80
|
+
uuid: storyId,
|
|
81
|
+
},
|
|
82
|
+
custom: [
|
|
83
|
+
{
|
|
84
|
+
name: 'status',
|
|
85
|
+
value: status,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}, [data, status]);
|
|
92
|
+
return (_jsxs("div", { className: "qanda-container", "data-trackable": "liveqa", children: [error && (_jsxs("p", { children: ["We are not able to load the Q&A at the moment. Please try again later. Error:", error] })), !isLoading && !error && (_jsxs(_Fragment, { children: [status !== 'close' && (_jsxs(_Fragment, { children: [showExpertView ? _jsx(ExpertDrawer, {}) : _jsx(QuestionForm, {}), _jsx(CountdownTimer, { container: countdownTimerContainer })] })), status === 'pre-live' && _jsx(Calendar, {}), _jsx(Stream, { status: status }), _jsx(Survey, { storyId: storyId, status: status }), (status === 'live' || (status === 'pre-live' && showExpertView)) && (_jsx(FloatingActionBar, { showLiveIndicator: status === 'live' })), status !== 'close' && !showExpertView && (_jsx(EmbedFormField, { container: embedFormFieldContainer }))] })), _jsx("div", { "aria-live": "polite", "aria-atomic": "true", className: "o3-visually-hidden", children: ariaMessage })] }));
|
|
93
|
+
}
|
|
94
|
+
export default QandaContainer;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
|
|
2
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
3
|
+
import classNames from 'classnames';
|
|
4
|
+
import { useEffect, useState, useContext } from 'react';
|
|
5
|
+
import { openForm, closeForm, updateQuestion, updateDisplayNameError, } from '../../store/questionSlice';
|
|
6
|
+
import { QandaContext } from '../QandaProvider';
|
|
7
|
+
import { usePostQuestionMutation } from '../../services/comments-api';
|
|
8
|
+
import Overlay from '../Overlay';
|
|
9
|
+
import Loader from '../Loader';
|
|
10
|
+
import { Button } from '@financial-times/o3-button';
|
|
11
|
+
import MessageBox from '../MessageBox';
|
|
12
|
+
import ErrorMessage from '../ErrorMessage';
|
|
13
|
+
import { getUserProfile } from '../../store/user';
|
|
14
|
+
import validateDisplayName from '../../utils/validate-display-name';
|
|
15
|
+
import { getTitleByUser, getTextByUser } from './messagesByUserType';
|
|
16
|
+
import { trackEvent } from '../../utils/tracking';
|
|
17
|
+
export default function QuestionForm() {
|
|
18
|
+
const dispatch = useDispatch();
|
|
19
|
+
const { storyId, useStaging, commentsAPIUrl, maxQuestionLength } = useContext(QandaContext);
|
|
20
|
+
const [postAnonymously, setPostAnonymously] = useState(false);
|
|
21
|
+
const [newDisplayName, setNewDisplayName] = useState(null);
|
|
22
|
+
const [submitEnabled, setSubmitEnabled] = useState(false);
|
|
23
|
+
const { isFormOpen, question, displayNameError } = useSelector((state) => state.question);
|
|
24
|
+
const { profile, error: userDisplayNameError } = useSelector((state) => state.user);
|
|
25
|
+
// NB: reset will have the effect of setting things like error to null and isSuccess to empty
|
|
26
|
+
// you use this when you want to clear the state of the usePostQuestionMutation hook as if you had just started on the page
|
|
27
|
+
// 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
|
|
28
|
+
const [postQuestion, { isLoading, isSuccess, error, reset }] = usePostQuestionMutation();
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (userDisplayNameError) {
|
|
31
|
+
dispatch(updateDisplayNameError('Sorry, we are unable to update display names. Please try again.'));
|
|
32
|
+
}
|
|
33
|
+
}, [userDisplayNameError]);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
setSubmitEnabled(Boolean((profile?.token || (newDisplayName && newDisplayName.length > 0)) &&
|
|
36
|
+
question.length >= 20));
|
|
37
|
+
}, [profile?.token, newDisplayName, question]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
// we want to clear the display name error unless the person has attempted
|
|
40
|
+
// to post anonymously with no display name
|
|
41
|
+
if (!postAnonymously && newDisplayName && newDisplayName?.length > 0) {
|
|
42
|
+
dispatch(updateDisplayNameError(null));
|
|
43
|
+
}
|
|
44
|
+
}, [newDisplayName]);
|
|
45
|
+
const handleHideDisplayName = (e) => {
|
|
46
|
+
if (!profile?.token && !newDisplayName) {
|
|
47
|
+
dispatch(updateDisplayNameError('You need a display name to ask a question, but you can still post it anonymously.'));
|
|
48
|
+
}
|
|
49
|
+
setPostAnonymously(e.target instanceof HTMLInputElement && e.target.checked);
|
|
50
|
+
};
|
|
51
|
+
const handleSubmit = async (e) => {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
reset();
|
|
54
|
+
let updatedProfile;
|
|
55
|
+
if (newDisplayName) {
|
|
56
|
+
const invalidCharacters = validateDisplayName(newDisplayName);
|
|
57
|
+
if (invalidCharacters) {
|
|
58
|
+
dispatch(updateDisplayNameError(`The display name contains invalid characters: ${invalidCharacters}`));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
updatedProfile = await dispatch(getUserProfile({
|
|
62
|
+
useStaging,
|
|
63
|
+
displayName: newDisplayName,
|
|
64
|
+
commentsAPIUrl,
|
|
65
|
+
})).unwrap();
|
|
66
|
+
// The user token is empty when there is no display name set
|
|
67
|
+
// if there are also no errors, we can assume the display name is taken
|
|
68
|
+
if (!updatedProfile?.token && !userDisplayNameError) {
|
|
69
|
+
dispatch(updateDisplayNameError('Unfortunately that display name is taken'));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (updatedProfile?.displayName) {
|
|
73
|
+
// NB: we are using a component act event as analytics team can only support existing categories and actions at this time
|
|
74
|
+
trackEvent({
|
|
75
|
+
category: 'component',
|
|
76
|
+
action: 'act',
|
|
77
|
+
trigger_action: 'success_set_displayname',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await postQuestion({
|
|
83
|
+
token: updatedProfile?.token || profile?.token || '',
|
|
84
|
+
storyId,
|
|
85
|
+
useStaging,
|
|
86
|
+
commentsAPIUrl,
|
|
87
|
+
question: question,
|
|
88
|
+
postAnonymously,
|
|
89
|
+
}).unwrap();
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
dispatch(openForm());
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (isSuccess) {
|
|
97
|
+
dispatch(updateQuestion(''));
|
|
98
|
+
}
|
|
99
|
+
}, [isSuccess]);
|
|
100
|
+
if (!isFormOpen) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
return (
|
|
104
|
+
// This form will use o-forms fields with o3 styling until o3 components
|
|
105
|
+
// are implemented by Origami team.
|
|
106
|
+
// Ticket CI-2770 was created to address this in the future.
|
|
107
|
+
_jsxs(Overlay, { title: "Ask a question", closeCallback: () => {
|
|
108
|
+
setNewDisplayName(null);
|
|
109
|
+
dispatch(closeForm());
|
|
110
|
+
}, children: [profile.isSubscribed && (_jsx("div", { className: "overlay__content-box overlay__form-box", "data-testid": "question-form-overlay", children: _jsxs("form", { onSubmit: handleSubmit, "data-trackable": `liveqa_submit_question_form_${postAnonymously ? 'anonymous' : 'displayname'}`, children: [_jsxs("div", { className: "overlay__user-container", children: [!postAnonymously && profile?.displayName && (_jsx(_Fragment, { children: _jsxs("span", { className: "overlay__user-container-display-name o3-type-body-highlight", children: [_jsx("i", { className: "overlay__message-icon", style: { maskImage: 'var(--o3-icon-user)' } }), profile.displayName] }) })), !profile?.displayName && (_jsxs("label", { for: "display-name", className: "question-form__displayname-input o-forms-field", children: [_jsx("span", { className: "o-forms-title", children: _jsx("span", { className: "o3-type-body-highlight", children: "Choose a display name" }) }), _jsxs("span", { className: classNames([
|
|
111
|
+
'question-form__displayname__input',
|
|
112
|
+
'o-forms-input',
|
|
113
|
+
'o-forms-input--text',
|
|
114
|
+
{ 'o-forms-input--invalid': !!displayNameError },
|
|
115
|
+
]), children: [_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 ??
|
|
116
|
+
'question_form__displayname-error-message' }), displayNameError && (_jsx("span", { className: "question-form__displayname__input__error o3-type-detail", id: "question_form__displayname-error-message", children: _jsx("span", { children: displayNameError }) }))] })] }))] }), _jsx("div", { className: "o-forms-field", children: _jsxs("label", { className: "o-forms-input o-forms-input--checkbox", htmlFor: "post-anonymously", children: [_jsx("input", { type: "checkbox", name: "post-anonymously", id: "post-anonymously", value: "true", onChange: handleHideDisplayName, "data-trackable-context-action": `Post anonymously ${postAnonymously ? 'off' : 'on'}`, "data-trackable": `question-form__post_anonymously-${postAnonymously ? 'off' : 'on'}` }), _jsx("span", { className: "o-forms-input__label", children: "Post anonymously (optional)" })] }) }), _jsxs("label", { for: "question-text", class: "o-forms-field question-form__question__input", children: [_jsx("span", { class: "o-forms-title", children: _jsx("span", { class: "o3-type-body-highlight", children: "Enter your question" }) }), _jsx("span", { class: "o-forms-input o-forms-input--textarea", children: _jsx("textarea", { id: "question-text", name: "question", value: question, onChange: (e) => dispatch(updateQuestion(e.currentTarget.value)), "aria-label": "Your question", placeholder: "Type your question", maxLength: maxQuestionLength, "aria-invalid": error ? 'true' : undefined, "aria-errormessage": "question-form-error-message" }) })] }), _jsx("span", { className: "o3-type-detail", children: "Questions must be at least 20 characters long." }), _jsx(Button, { attributes: {
|
|
117
|
+
className: 'overlay__submit-button',
|
|
118
|
+
type: 'submit',
|
|
119
|
+
disabled: !submitEnabled,
|
|
120
|
+
ariaLabel: question.length < 20
|
|
121
|
+
? 'Submit button (disabled until question is at least 20 characters)'
|
|
122
|
+
: isLoading
|
|
123
|
+
? 'Submitting...'
|
|
124
|
+
: 'Submit your question',
|
|
125
|
+
},
|
|
126
|
+
// @ts-expect-error - o3 Button is currently accepting a JSX element as label
|
|
127
|
+
// but the type definition needs to be updated by Origami team
|
|
128
|
+
label: isLoading ? _jsx(Loader, {}) : 'Submit your question', type: "primary", theme: "mono" }), !profile?.token && (_jsx("span", { className: "o3-type-detail", children: "Submitting a question will create a community profile with FT." }))] }) })), isSuccess && (_jsx(MessageBox, { color: "#00572C", extraClassNames: "o-message--alert o-message--success", title: "Question sent", text: "Your question is in the queue and will be reviewed by our editors." })), error && (_jsx(ErrorMessage, { error: error, storyId: storyId, errorMapping: {
|
|
129
|
+
// Messages for specific error codes
|
|
130
|
+
REPEAT_POST: {
|
|
131
|
+
title: 'Question already submitted',
|
|
132
|
+
message: "You've already submitted this question. Try asking something different and submit again.",
|
|
133
|
+
},
|
|
134
|
+
REJECTED: {
|
|
135
|
+
title: 'Question rejected',
|
|
136
|
+
message: 'This question has been rejected for language that violates our guidelines.',
|
|
137
|
+
},
|
|
138
|
+
RATE_LIMIT_EXCEEDED: {
|
|
139
|
+
title: 'Too many questions',
|
|
140
|
+
message: 'You are submitting too many questions in a short amount of time. Please wait sometime before submitting another question.',
|
|
141
|
+
},
|
|
142
|
+
TOXIC_COMMENT: {
|
|
143
|
+
title: 'Question blocked',
|
|
144
|
+
message: 'Are you sure? The language in your question might violate our community guidelines. You can edit it or submit it for moderator review.',
|
|
145
|
+
},
|
|
146
|
+
serverError: {
|
|
147
|
+
title: 'Server error',
|
|
148
|
+
message: 'There was a server error. Please try submitting your question again.',
|
|
149
|
+
},
|
|
150
|
+
connectionError: {
|
|
151
|
+
title: 'Connection error',
|
|
152
|
+
message: "You can't submit your question. Check your internet connection or browser settings and try again.",
|
|
153
|
+
},
|
|
154
|
+
timeoutError: {
|
|
155
|
+
title: 'Timeout error',
|
|
156
|
+
message: 'The request timed out. Please try submitting your question again.',
|
|
157
|
+
},
|
|
158
|
+
}, operation: "submit-question", id: "question-form-error-message" })), _jsx(MessageBox, { extraClassNames: "o-message--notice o-message--inform", title: getTitleByUser(profile), text: getTextByUser(profile) })] }));
|
|
159
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
export const SIGN_IN_URL = 'https://www.ft.com/login';
|
|
3
|
+
export const SUBSCRIBE_URL = 'https://subs.ft.com/products';
|
|
4
|
+
export const COMMUNITY_GUIDELINES_URL = 'https://help.ft.com/help/article-queries/commenting-on-an-article/commenting-guidelines';
|
|
5
|
+
export const TERMS_AND_CONDITIONS_URL = 'https://help.ft.com/help/legal-privacy/terms-conditions';
|
|
6
|
+
export function getTitleByUser(profile) {
|
|
7
|
+
if (profile.isSubscribed) {
|
|
8
|
+
return 'Q&A guidelines';
|
|
9
|
+
}
|
|
10
|
+
if (profile.isTrialist) {
|
|
11
|
+
return 'You are still on a trial period';
|
|
12
|
+
}
|
|
13
|
+
// Same title for anonymous and registered users
|
|
14
|
+
return 'Asking a question is only available to readers with an FT subscription';
|
|
15
|
+
}
|
|
16
|
+
export function getTextByUser(profile) {
|
|
17
|
+
if (profile.isSubscribed) {
|
|
18
|
+
return (_jsxs(_Fragment, { children: ["Keep your comments respectful and easy to understand for our global audience. By submitting, you agree to follow our", ' ', _jsx("a", { className: "message-box__content-link", "data-trackable": "question-form__community-guidelines", "data-trackable-context-action": "Community guidelines", href: COMMUNITY_GUIDELINES_URL, target: "_parent", children: "community guidelines" }), ' ', "and", ' ', _jsx("a", { className: "message-box__content-link", "data-trackable": "question-form__terms-and-conditions", "data-trackable-context-action": "Terms and conditions", href: TERMS_AND_CONDITIONS_URL, target: "_parent", children: "terms" }), "."] }));
|
|
19
|
+
}
|
|
20
|
+
if (profile.isTrialist) {
|
|
21
|
+
return (_jsxs(_Fragment, { children: ["View our full", ' ', _jsx("a", { className: "message-box__content-link", "data-trackable": "question-form__subscribe-trialist", "data-trackable-context-action": "Subscribe", href: SUBSCRIBE_URL, target: "_parent", children: "subscription packages" }), ' ', "to join the conversation."] }));
|
|
22
|
+
}
|
|
23
|
+
if (profile.isRegistered) {
|
|
24
|
+
return (_jsxs(_Fragment, { children: [_jsx("a", { className: "message-box__content-link", "data-trackable": "question-form__subscribe-registered", "data-trackable-context-action": "Subscribe", href: SUBSCRIBE_URL, target: "_parent", children: "Subscribe" }), ' ', "to join the conversation."] }));
|
|
25
|
+
}
|
|
26
|
+
// Anonymous users
|
|
27
|
+
return (_jsxs(_Fragment, { children: ["Please", ' ', _jsx("a", { className: "message-box__content-link", "data-trackable": "question-form__sign-in-anonymous", "data-trackable-context-action": "Sign in", href: SIGN_IN_URL, target: "_parent", children: "sign in" }), ' ', "or", ' ', _jsx("a", { className: "message-box__content-link", "data-trackable": "question-form__subscribe-anonymous", "data-trackable-context-action": "Subscribe", href: SUBSCRIBE_URL, target: "_parent", children: "subscribe" }), ' ', "to join the conversation."] }));
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
import { useEffect, useContext } from 'react';
|
|
3
|
+
import { useSelector } from 'react-redux';
|
|
4
|
+
import QandaBlock from '../QandaBlock/index';
|
|
5
|
+
import { QandaContext } from '../QandaProvider';
|
|
6
|
+
import Loader from '../Loader';
|
|
7
|
+
import { useGetQandAStreamQuery } from '../../services/comments-api';
|
|
8
|
+
import ErrorMessage from '../ErrorMessage';
|
|
9
|
+
function Stream({ status = 'pre-live' }) {
|
|
10
|
+
const latestAnsweredQuestionId = useSelector((state) => state.stream.latestAnsweredQuestionId);
|
|
11
|
+
const { storyId, useStaging, commentsAPIUrl } = useContext(QandaContext);
|
|
12
|
+
const { data, error, isLoading } = useGetQandAStreamQuery({
|
|
13
|
+
storyId,
|
|
14
|
+
useStaging,
|
|
15
|
+
commentsAPIUrl,
|
|
16
|
+
status,
|
|
17
|
+
},
|
|
18
|
+
// In comments api, the stream endpoint uses different queries based on the Q&A status (live stream vs pre-live stream),
|
|
19
|
+
// If the status changes and Qanda receives an update,
|
|
20
|
+
// we need to force RTK to re-fetch the data and show the appropriate stream.
|
|
21
|
+
{ refetchOnMountOrArgChange: true });
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const element = document.getElementById(latestAnsweredQuestionId);
|
|
24
|
+
element?.scrollIntoView({ behavior: 'smooth' });
|
|
25
|
+
}, [latestAnsweredQuestionId]);
|
|
26
|
+
return (_jsxs("section", { className: "qanda__stream", children: [_jsx("h2", { className: "o3-type-title-md qanda__stream-title", children: data && data.status && data.status === 'pre-live'
|
|
27
|
+
? 'Top questions so far'
|
|
28
|
+
: 'What our readers are asking' }), _jsxs("div", { className: "qanda__blocks-container", "data-testid": "qanda__blocks-container", id: "qanda__blocks-container", "aria-busy": isLoading, children: [isLoading && (_jsx(Loader, { theme: "dark", extraClassNames: "qanda__stream-loader" })), error && (_jsx(ErrorMessage, { error: error, storyId: storyId, operation: "loading-stream" })), data &&
|
|
29
|
+
data.qandas &&
|
|
30
|
+
data.qandas.map((qandaBlock) => (_jsx(QandaBlock, { 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)))] })] }));
|
|
31
|
+
}
|
|
32
|
+
export default Stream;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
function Survey({ storyId, status }) {
|
|
3
|
+
const trackEvent = (trackingData) => {
|
|
4
|
+
const event = new CustomEvent('oTracking.event', {
|
|
5
|
+
detail: trackingData,
|
|
6
|
+
bubbles: true,
|
|
7
|
+
});
|
|
8
|
+
document.body.dispatchEvent(event);
|
|
9
|
+
};
|
|
10
|
+
const handleClick = () => {
|
|
11
|
+
trackEvent({
|
|
12
|
+
category: 'component',
|
|
13
|
+
component: {
|
|
14
|
+
name: 'liveqa',
|
|
15
|
+
type: 'container',
|
|
16
|
+
id: '663ab2ca-77ca-4b50-873d-1fd2ff21ff08',
|
|
17
|
+
},
|
|
18
|
+
action: 'act',
|
|
19
|
+
trigger_action: 'liveqa_survey_open',
|
|
20
|
+
content: {
|
|
21
|
+
asset_type: 'article',
|
|
22
|
+
uuid: storyId,
|
|
23
|
+
},
|
|
24
|
+
custom: [
|
|
25
|
+
{
|
|
26
|
+
name: 'status',
|
|
27
|
+
value: status,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
return (_jsx("div", { className: "o-message o-message--action o-message--inform o-message--survey", style: { marginBottom: 'var(--o3-spacing-l)' }, "data-o-message-close": "false", "data-o-component": "o-message", children: _jsx("div", { className: "o-message__container", children: _jsxs("div", { className: "o-message__content", children: [_jsx("p", { className: "o-message__content-main", style: { fontSize: 'var(--o3-type-body-base-font-size)' }, children: "How was your experience?" }), _jsx("div", { className: "o-message__actions", children: _jsx("a", { href: `https://www.feedback.ft.com/c/a/6HTDVvrHSyaKLZ5281McAo?content_id=${storyId}&qa_status=${status}`, onClick: handleClick, className: "o-message__actions__primary", "data-trackable": "liveqa_survey_link", children: "Share your feedback" }) })] }) }) }));
|
|
33
|
+
}
|
|
34
|
+
export default Survey;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
export function useIsMobile(breakpoint = 740) {
|
|
3
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
const mediaQuery = window.matchMedia(`(max-width: ${breakpoint}px)`);
|
|
6
|
+
const update = () => setIsMobile(mediaQuery.matches);
|
|
7
|
+
update();
|
|
8
|
+
mediaQuery.addEventListener('change', update);
|
|
9
|
+
return () => mediaQuery.removeEventListener('change', update);
|
|
10
|
+
}, [breakpoint]);
|
|
11
|
+
return isMobile;
|
|
12
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx } from "preact/jsx-runtime";
|
|
2
|
+
import { render } from 'preact';
|
|
3
|
+
import Qanda from './components/Qanda';
|
|
4
|
+
import QandaBlock from './components/QandaBlock';
|
|
5
|
+
class QandaUI {
|
|
6
|
+
constructor(containerElement, options) {
|
|
7
|
+
this.containerElement = containerElement;
|
|
8
|
+
this.embedFormFieldContainer =
|
|
9
|
+
options?.embedFormFieldContainer || document.body;
|
|
10
|
+
this.countdownTimerContainer =
|
|
11
|
+
options?.countdownTimerContainer || document.body;
|
|
12
|
+
if (!options && !containerElement) {
|
|
13
|
+
throw new Error('Required properties are missing');
|
|
14
|
+
}
|
|
15
|
+
const storyId = options?.storyId ?? containerElement.dataset.qandaStoryid;
|
|
16
|
+
const useStaging = options?.useStaging ??
|
|
17
|
+
containerElement.dataset.qandaUsestaging === 'true' ??
|
|
18
|
+
false;
|
|
19
|
+
const title = options?.title ?? containerElement.dataset.qandaTitle;
|
|
20
|
+
const commentsAPIHost = options?.commentsAPIHost ?? containerElement.dataset.qandaCommentsapihost;
|
|
21
|
+
const commentsAPIVersion = options?.commentsAPIVersion ??
|
|
22
|
+
containerElement.dataset.qandaCommentsapiversion;
|
|
23
|
+
const maxQuestionLength = options?.maxQuestionLength ??
|
|
24
|
+
(containerElement.dataset.qandaMaxquestionlength
|
|
25
|
+
? parseInt(containerElement.dataset.qandaMaxquestionlength, 10)
|
|
26
|
+
: undefined) ??
|
|
27
|
+
2000;
|
|
28
|
+
if (!storyId || !title || !commentsAPIHost || !commentsAPIVersion) {
|
|
29
|
+
throw new Error('Required properties are missing');
|
|
30
|
+
}
|
|
31
|
+
//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
|
|
32
|
+
this.storyId = storyId;
|
|
33
|
+
this.useStaging = useStaging;
|
|
34
|
+
this.title = title;
|
|
35
|
+
this.commentsAPIHost = commentsAPIHost;
|
|
36
|
+
this.commentsAPIVersion = commentsAPIVersion;
|
|
37
|
+
this.maxQuestionLength = maxQuestionLength;
|
|
38
|
+
}
|
|
39
|
+
init() {
|
|
40
|
+
render(_jsx(Qanda, { 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);
|
|
41
|
+
}
|
|
42
|
+
destroy() {
|
|
43
|
+
render(null, this.containerElement);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export { QandaUI, QandaBlock };
|