· 5 years ago · Nov 24, 2020, 09:40 AM
1import React, { Fragment, useState, useRef, useMemo, useEffect } from 'react';
2import { injectIntl, FormattedMessage } from 'react-intl';
3import { makeStyles } from '@material-ui/styles';
4import { useTheme } from '@material-ui/core/styles';
5import useMediaQuery from '@material-ui/core/useMediaQuery';
6import Grid from '@material-ui/core/Grid';
7import Button from '@material-ui/core/Button';
8import RootRef from '@material-ui/core/RootRef';
9import MyLocation from '@material-ui/icons/MyLocation';
10import classNames from 'classnames';
11import LazyLoading from '../components/LazyLoading/LazyLoading';
12import { MainHeadingSmaller, SectionHeading } from '../components/typography';
13import PropTypes, { intlPropType } from '../helpers/propTypes';
14import { submitMetric } from '../helpers/metrics';
15import useListEntires from '../hooks/useListEntries';
16import { useGlobalScope } from '../hooks/useGlobalState';
17import useId from '../hooks/useId';
18import usePageAria from '../hooks/usePageAria';
19import { useCloseConfirmation } from '../hooks/useCloseConfirmation';
20import ClickableListItem from '../components/ClickableListItem/ClickableListItem';
21import Footer from '../components/Footer/Footer';
22import { countryMessages } from '../helpers/countries';
23import { getClientIpLocation } from '../helpers/ipDetect';
24
25const Map = React.lazy(() => import('../components/Map/Map'));
26
27const rad = x => (x * Math.PI) / 180;
28
29const useStyles = makeStyles(theme => ({
30 header: {
31 minHeight: 72,
32 display: 'flex',
33 alignItems: 'center',
34 justifyContent: 'center',
35 textAlign: 'center',
36 padding: 10,
37 borderBottom: '1px solid #ccc',
38 },
39
40 scroll: {
41 overflowY: 'scroll',
42 display: 'flex',
43 flexGrow: 1,
44 flexDirection: 'column',
45 },
46
47 withMap: {
48 height: '100%',
49 overflow: 'hidden',
50 display: 'flex',
51 flexDirection: 'column',
52 flexGrow: 1,
53 },
54
55 mainContent: {
56 flexGrow: 1,
57 position: 'relative',
58 },
59
60 mainContentWithMap: {
61 height: '100%',
62 overflow: 'hidden',
63 },
64
65 mainContentLoading: {
66 opacity: 0.5,
67 pointerEvents: 'none',
68 },
69
70 mapContainer: {
71 display: 'flex',
72 position: 'relative',
73
74 [theme.breakpoints.down('xs')]: {
75 height: 150,
76 },
77 },
78
79 mapControl: {
80 zIndex: 9,
81 position: 'absolute',
82 top: 10,
83 right: 10,
84 color: '#6b6b6b',
85 },
86
87 findNearestIcon: {
88 marginRight: 4,
89 },
90
91 list: {
92 position: 'relative',
93 zIndex: 1,
94 boxShadow: '-2px 0px 4px 0px rgba(0, 0, 0, 0.16)',
95 overflow: 'auto',
96 scrollBehavior: 'smooth',
97 },
98
99 countryTitle: {
100 borderBottom: '1px solid #ccc',
101 backgroundColor: 'rgba(247, 248, 248, 1)',
102 padding: '13px 0',
103 boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.16)',
104 zIndex: 1,
105 position: 'relative',
106 },
107
108 countryTitleFlag: {
109 height: 22,
110 },
111
112 opaque: {
113 opacity: 0.5,
114 },
115
116 btnContainer: {
117 minHeight: 72,
118 display: 'flex',
119 alignItems: 'center',
120 justifyContent: 'center',
121 textAlign: 'center',
122 padding: 10,
123 border: '1px solid #ccc',
124 },
125
126 btnNearLocationFind: {
127 cursor: 'pointer',
128 height: 35,
129 display: 'absolute',
130 lineHeight: 35,
131 padding: '0 1.5rem',
132 color: '#FFFFFF',
133 backgroundColor: '#005B86',
134 fontWeight: 600,
135 letterSpacing: 0.8,
136 textAlign: 'center',
137 textDecoration: 'none',
138 textTransform: 'uppercase',
139 verticalAlign: 'middle',
140 whiteSpace: 'nowrap',
141 outline: 'none',
142 border: 'none',
143 borderRadius: 2,
144 transition: 'all .3s ease-out',
145 boxShadow: '0 2 5 0 rgba(0,0,0,0.225)',
146
147 '&:hover': {
148 color: '#000',
149 },
150 },
151}));
152
153function calculateNearestSite(userPosition, sites) {
154 const lat = userPosition.lat || 0;
155 const lng = userPosition.lng || 0;
156 const R = 6371; // earth's radius
157
158 return sites.reduce(
159 (acc, _, i) => {
160 const mlat = sites[i].geopos.latitude;
161 const mlng = sites[i].geopos.longitude;
162 const dLat = rad(mlat - lat);
163 const dLong = rad(mlng - lng);
164 const a =
165 Math.sin(dLat / 2) * Math.sin(dLat / 2) +
166 Math.cos(rad(lat)) *
167 Math.cos(rad(lat)) *
168 Math.sin(dLong / 2) *
169 Math.sin(dLong / 2);
170 const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
171 const d = R * c;
172 if (d < acc.distance) {
173 return {
174 closestSiteId: sites[i].id,
175 distance: d,
176 };
177 }
178 return acc;
179 },
180 { closestSiteId: null, distance: Infinity },
181 ).closestSiteId;
182}
183
184function ClinicForm({
185 sites,
186 navigateModal,
187 api: { submitSite },
188 googleApiKey,
189 country,
190 intl: { formatMessage },
191 pageName,
192 nextPageName,
193 prevPageName,
194 contentId,
195}) {
196 const aria = usePageAria();
197 const descriptionId = useId();
198
199 const theme = useTheme();
200 const showMap = useMediaQuery(theme.breakpoints.up('sm'));
201
202 useCloseConfirmation();
203
204 const useGlobal = useGlobalScope(pageName);
205
206 const [selectedSiteId, setSelectedSiteId] = useGlobal('selectedSiteId');
207 const [_, setSelectedSite] = useGlobal('selectedSite');
208
209 const [listHoverId, setListHoverId] = useState(null);
210 const [mapHoverId, setMapHoverId] = useState(null);
211
212 const [loading, setLoading] = useState(showMap);
213
214 const listRef = useRef(null);
215 const listItemRefs = useListEntires(
216 sites,
217 () => React.createRef(),
218 i => sites[i].id,
219 );
220
221 const sortSites = (first, second) => {
222 try {
223 const sortBy = (a, b, key) => (a[key] < b[key] ? -1 : +1);
224 const getKey = (a, b) => (a.city === b.city ? 'name' : 'city');
225 return sortBy(first, second, getKey(first, second));
226 } catch (e) {
227 return 1;
228 }
229 };
230
231 const sitesByCountry = useMemo(
232 () =>
233 sites.sort(sortSites).reduce(
234 (acc, site) => ({
235 ...acc,
236 [site.countryCode]: [...(acc[site.countryCode] || []), site],
237 }),
238 {},
239 ),
240 [sites],
241 );
242
243 const sortedCountries = useMemo(() => {
244 const primaryLocation = [];
245 if (sitesByCountry[country]) primaryLocation.push(country);
246 return [
247 ...primaryLocation,
248 ...Object.keys(sitesByCountry).filter(l => l !== country),
249 ];
250 }, [sitesByCountry, country]);
251
252 const setSiteId = id => {
253 setSelectedSiteId(id);
254 setSelectedSite(sites.find(site => site.id === id));
255 };
256
257 const scrollToSite = id => {
258 const scrollToEl = listItemRefs[id].current;
259 listRef.current.scrollTop =
260 scrollToEl.offsetTop -
261 (listRef.current.offsetHeight - scrollToEl.offsetHeight) / 2;
262 };
263
264 const handleItemClick = siteId => {
265 setSiteId(siteId === selectedSiteId ? null : siteId);
266 submitMetric('siteSelected');
267 };
268
269 const handleMapClick = siteId => {
270 scrollToSite(siteId);
271 handleItemClick(siteId);
272 };
273
274 const handleItemHover = (isHovering, itemId) => {
275 if (!isHovering && listHoverId === itemId) {
276 setListHoverId(null);
277 } else {
278 setListHoverId(itemId);
279 }
280 };
281
282 const handleMapLoad = () => {
283 setLoading(false);
284 };
285
286 const handleFindNearestSite = () => {
287 navigator.geolocation.getCurrentPosition(position => {
288 const coords = {
289 lat: position.coords.latitude,
290 lng: position.coords.longitude,
291 };
292 const nearestSiteId = calculateNearestSite(coords, sites);
293 scrollToSite(nearestSiteId);
294 setSiteId(nearestSiteId);
295 });
296 };
297
298 const handleMapHover = (isHovering, siteId) => {
299 if (!isHovering && mapHoverId === siteId) {
300 setMapHoverId(null);
301 } else {
302 setMapHoverId(siteId);
303 }
304 };
305
306 const handleBackClick = () => {
307 navigateModal({ forward: false });
308 };
309
310 const handleContinueClick = async () => {
311 setLoading(true);
312
313 await submitSite(selectedSiteId);
314
315 setLoading(false);
316 navigateModal({ forward: true });
317 };
318
319 const siteIsChosen = selectedSiteId !== null;
320
321 const classes = useStyles();
322
323 useEffect(() => {
324 handleFindNearestSite();
325 }, []);
326
327 const renderSiteList = () =>
328 sortedCountries.map(countryCode => (
329 <Fragment key={countryCode}>
330 <Grid
331 alignItems="center"
332 aria-hidden="true"
333 className={classes.countryTitle}
334 container
335 direction="row"
336 wrap="nowrap"
337 >
338 <Grid alignItems="center" container item justify="center" xs={3}>
339 <img
340 alt=""
341 aria-hidden="true"
342 className={classes.countryTitleFlag}
343 src={`https://public.trialbee.com/flags/${countryCode.toLowerCase()}.svg`}
344 />
345 </Grid>
346 <Grid item xs={12}>
347 <SectionHeading>
348 {formatMessage(countryMessages[countryCode.toUpperCase()])}
349 </SectionHeading>
350 </Grid>
351 </Grid>
352 {sitesByCountry[countryCode].map(site => (
353 <RootRef key={site.id} rootRef={listItemRefs[site.id]}>
354 <ClickableListItem
355 desribedBy={descriptionId}
356 disabled={loading}
357 highlight={site.id === mapHoverId}
358 onClick={handleItemClick}
359 onHover={handleItemHover}
360 selected={site.id === selectedSiteId}
361 showLookupLink={!showMap}
362 site={site}
363 />
364 </RootRef>
365 ))}
366 </Fragment>
367 ));
368
369 return (
370 <>
371 <div
372 className={classNames({
373 [classes.scroll]: !showMap,
374 [classes.withMap]: showMap,
375 })}
376 {...aria}
377 >
378 <div className={classes.header}>
379 <MainHeadingSmaller align="center" id={descriptionId}>
380 <b>
381 <FormattedMessage
382 defaultMessage="Select a preferred clinic you would like to visit if you were to take part in the study"
383 id="ClinicForm.SelectClinic"
384 />
385 </b>
386 </MainHeadingSmaller>
387 </div>
388 <div className={classes.btnContainer}>
389 {!showMap && (
390 <Button
391 className={classes.btnNearLocationFind}
392 onClick={handleFindNearestSite}
393 size="medium"
394 variant="contained"
395 >
396 <MyLocation className={classes.findNearestIcon} />
397 <FormattedMessage
398 defaultMessage="Find nearest clinic"
399 id="ClinicForm.FindNearestClinic"
400 />
401 </Button>
402 )}
403 </div>
404
405 <Grid
406 className={classNames(classes.mainContent, {
407 [classes.mainContentWithMap]: showMap,
408 [classes.mainContentLoading]: loading,
409 })}
410 container
411 spacing={0}
412 wrap="nowrap"
413 >
414 {showMap && (
415 <Grid
416 aria-hidden="true"
417 className={classes.mapContainer}
418 item
419 sm={8}
420 tabIndex="-1"
421 xs={12}
422 >
423 <LazyLoading>
424 <Map
425 country={country}
426 googleApiKey={googleApiKey}
427 hoveredSiteId={listHoverId}
428 onMapLoad={handleMapLoad}
429 onSiteHover={handleMapHover}
430 onSiteSelect={handleMapClick}
431 selectedSiteId={selectedSiteId}
432 sites={sites}
433 />
434 </LazyLoading>
435
436 <div className={classes.mapControl}>
437 <Button
438 onClick={handleFindNearestSite}
439 size="small"
440 variant="contained"
441 >
442 <MyLocation className={classes.findNearestIcon} />
443 <FormattedMessage
444 defaultMessage="Find nearest clinic"
445 id="ClinicForm.FindNearestClinic"
446 />
447 </Button>
448 </div>
449 </Grid>
450 )}
451 <RootRef rootRef={listRef}>
452 <Grid
453 aria-labelledby={descriptionId}
454 className={classNames({
455 [classes.list]: showMap,
456 [classes.opaque]: loading,
457 })}
458 item
459 role="radiogroup"
460 sm={4}
461 xs={12}
462 >
463 {renderSiteList()}
464 </Grid>
465 </RootRef>
466 </Grid>
467 </div>
468 <Footer
469 footerText={
470 !siteIsChosen && (
471 <FormattedMessage
472 defaultMessage="No clinic selected"
473 id="Footer.FooterNoClinicSelected"
474 />
475 )
476 }
477 formIsComplete={siteIsChosen && !loading}
478 liveRegionId={contentId}
479 loading={siteIsChosen && loading}
480 nextPageName={nextPageName}
481 onBackClick={handleBackClick}
482 onContinueClick={handleContinueClick}
483 prevPageName={prevPageName}
484 />
485 </>
486 );
487}
488
489ClinicForm.propTypes = {
490 api: PropTypes.shape({ submitSite: PropTypes.func.isRequired }).isRequired,
491 contentId: PropTypes.string.isRequired,
492 country: PropTypes.string.isRequired,
493 googleApiKey: PropTypes.string.isRequired,
494 intl: intlPropType.isRequired,
495 navigateModal: PropTypes.func.isRequired,
496 nextPageName: PropTypes.string,
497 pageName: PropTypes.string.isRequired,
498 prevPageName: PropTypes.string,
499 sites: PropTypes.arrayOf(
500 PropTypes.shape({
501 city: PropTypes.string,
502 countryCode: PropTypes.string,
503 geopos: PropTypes.shape({}),
504 id: PropTypes.string.isRequired,
505 name: PropTypes.string,
506 street: PropTypes.string,
507 zipCode: PropTypes.string,
508 }),
509 ).isRequired,
510};
511
512ClinicForm.defaultProps = {
513 nextPageName: undefined,
514 prevPageName: undefined,
515};
516
517export default injectIntl(ClinicForm);
518