· 5 years ago · Nov 22, 2020, 04:52 PM
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 { geolocated } from 'react-geolocated';
12import LazyLoading from '../components/LazyLoading/LazyLoading';
13import { MainHeadingSmaller, SectionHeading } from '../components/typography';
14import PropTypes, { intlPropType } from '../helpers/propTypes';
15import { submitMetric } from '../helpers/metrics';
16import useListEntires from '../hooks/useListEntries';
17import { useGlobalScope } from '../hooks/useGlobalState';
18import useId from '../hooks/useId';
19import usePageAria from '../hooks/usePageAria';
20import { useCloseConfirmation } from '../hooks/useCloseConfirmation';
21import ClickableListItem from '../components/ClickableListItem/ClickableListItem';
22import Footer from '../components/Footer/Footer';
23import { countryMessages } from '../helpers/countries';
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 locationRef = useRef();
215
216 const listRef = useRef(null);
217 const listItemRefs = useListEntires(
218 sites,
219 () => React.createRef(),
220 i => sites[i].id,
221 );
222
223 const sortSites = (first, second) => {
224 try {
225 const sortBy = (a, b, key) => (a[key] < b[key] ? -1 : +1);
226 const getKey = (a, b) => (a.city === b.city ? 'name' : 'city');
227 return sortBy(first, second, getKey(first, second));
228 } catch (e) {
229 return 1;
230 }
231 };
232
233 const sitesByCountry = useMemo(
234 () =>
235 sites.sort(sortSites).reduce(
236 (acc, site) => ({
237 ...acc,
238 [site.countryCode]: [...(acc[site.countryCode] || []), site],
239 }),
240 {},
241 ),
242 [sites],
243 );
244
245 const sortedCountries = useMemo(() => {
246 const primaryLocation = [];
247 if (sitesByCountry[country]) primaryLocation.push(country);
248 return [
249 ...primaryLocation,
250 ...Object.keys(sitesByCountry).filter(l => l !== country),
251 ];
252 }, [sitesByCountry, country]);
253
254 const setSiteId = id => {
255 setSelectedSiteId(id);
256 setSelectedSite(sites.find(site => site.id === id));
257 };
258
259 const scrollToSite = id => {
260 const scrollToEl = listItemRefs[id].current;
261 listRef.current.scrollTop =
262 scrollToEl.offsetTop -
263 (listRef.current.offsetHeight - scrollToEl.offsetHeight) / 2;
264 };
265
266 const handleItemClick = siteId => {
267 setSiteId(siteId === selectedSiteId ? null : siteId);
268 submitMetric('siteSelected');
269 };
270
271 const handleMapClick = siteId => {
272 scrollToSite(siteId);
273 handleItemClick(siteId);
274 };
275
276 const handleItemHover = (isHovering, itemId) => {
277 if (!isHovering && listHoverId === itemId) {
278 setListHoverId(null);
279 } else {
280 setListHoverId(itemId);
281 }
282 };
283
284 const handleMapLoad = () => {
285 setLoading(false);
286 };
287
288 const handleFindNearestSite = () => {
289 navigator.geolocation.getCurrentPosition(position => {
290 const coords = {
291 lat: position.coords.latitude,
292 lng: position.coords.longitude,
293 };
294 const nearestSiteId = calculateNearestSite(coords, sites);
295 scrollToSite(nearestSiteId);
296 setSiteId(nearestSiteId);
297 });
298 locationRef.current.handleFindNearestSite();
299 console.log('REF LOCATION', locationRef);
300 };
301
302 const handleMapHover = (isHovering, siteId) => {
303 if (!isHovering && mapHoverId === siteId) {
304 setMapHoverId(null);
305 } else {
306 setMapHoverId(siteId);
307 }
308 };
309
310 const handleBackClick = () => {
311 navigateModal({ forward: false });
312 };
313
314 const handleContinueClick = async () => {
315 setLoading(true);
316
317 await submitSite(selectedSiteId);
318
319 setLoading(false);
320 navigateModal({ forward: true });
321 };
322
323 const siteIsChosen = selectedSiteId !== null;
324
325 const classes = useStyles();
326
327 useEffect(() => {
328 handleFindNearestSite();
329 }, []);
330
331 const renderSiteList = () =>
332 sortedCountries.map(countryCode => (
333 <Fragment key={countryCode}>
334 <Grid
335 alignItems="center"
336 aria-hidden="true"
337 className={classes.countryTitle}
338 container
339 direction="row"
340 wrap="nowrap"
341 >
342 <Grid alignItems="center" container item justify="center" xs={3}>
343 <img
344 alt=""
345 aria-hidden="true"
346 className={classes.countryTitleFlag}
347 src={`https://public.trialbee.com/flags/${countryCode.toLowerCase()}.svg`}
348 />
349 </Grid>
350 <Grid item xs={12}>
351 <SectionHeading>
352 {formatMessage(countryMessages[countryCode.toUpperCase()])}
353 </SectionHeading>
354 </Grid>
355 </Grid>
356 {sitesByCountry[countryCode].map(site => (
357 <RootRef key={site.id} rootRef={listItemRefs[site.id]}>
358 <ClickableListItem
359 desribedBy={descriptionId}
360 disabled={loading}
361 highlight={site.id === mapHoverId}
362 onClick={handleItemClick}
363 onHover={handleItemHover}
364 selected={site.id === selectedSiteId}
365 showLookupLink={!showMap}
366 site={site}
367 />
368 </RootRef>
369 ))}
370 </Fragment>
371 ));
372
373 return (
374 <>
375 <div
376 className={classNames({
377 [classes.scroll]: !showMap,
378 [classes.withMap]: showMap,
379 })}
380 {...aria}
381 >
382 <div className={classes.header}>
383 <MainHeadingSmaller align="center" id={descriptionId}>
384 <b>
385 <FormattedMessage
386 defaultMessage="Select a preferred clinic you would like to visit if you were to take part in the study"
387 id="ClinicForm.SelectClinic"
388 />
389 </b>
390 </MainHeadingSmaller>
391 </div>
392 <div className={classes.btnContainer}>
393 {!showMap && (
394 <Button
395 ref={locationRef}
396 className={classes.btnNearLocationFind}
397 onClick={handleFindNearestSite}
398 size="medium"
399 variant="contained"
400 >
401 <MyLocation className={classes.findNearestIcon} />
402 <FormattedMessage
403 defaultMessage="Find nearest clinic"
404 id="ClinicForm.FindNearestClinic"
405 />
406 </Button>
407 )}
408 </div>
409
410 <Grid
411 className={classNames(classes.mainContent, {
412 [classes.mainContentWithMap]: showMap,
413 [classes.mainContentLoading]: loading,
414 })}
415 container
416 spacing={0}
417 wrap="nowrap"
418 >
419 {showMap && (
420 <Grid
421 aria-hidden="true"
422 className={classes.mapContainer}
423 item
424 sm={8}
425 tabIndex="-1"
426 xs={12}
427 >
428 <LazyLoading>
429 <Map
430 country={country}
431 googleApiKey={googleApiKey}
432 hoveredSiteId={listHoverId}
433 onMapLoad={handleMapLoad}
434 onSiteHover={handleMapHover}
435 onSiteSelect={handleMapClick}
436 selectedSiteId={selectedSiteId}
437 sites={sites}
438 />
439 </LazyLoading>
440
441 <div className={classes.mapControl}>
442 <Button
443 onClick={handleFindNearestSite}
444 size="small"
445 variant="contained"
446 >
447 <MyLocation className={classes.findNearestIcon} />
448 <FormattedMessage
449 defaultMessage="Find nearest clinic"
450 id="ClinicForm.FindNearestClinic"
451 />
452 </Button>
453 </div>
454 </Grid>
455 )}
456 <RootRef rootRef={listRef}>
457 <Grid
458 aria-labelledby={descriptionId}
459 className={classNames({
460 [classes.list]: showMap,
461 [classes.opaque]: loading,
462 })}
463 item
464 role="radiogroup"
465 sm={4}
466 xs={12}
467 >
468 {renderSiteList()}
469 </Grid>
470 </RootRef>
471 </Grid>
472 </div>
473 <Footer
474 footerText={
475 !siteIsChosen && (
476 <FormattedMessage
477 defaultMessage="No clinic selected"
478 id="Footer.FooterNoClinicSelected"
479 />
480 )
481 }
482 formIsComplete={siteIsChosen && !loading}
483 liveRegionId={contentId}
484 loading={siteIsChosen && loading}
485 nextPageName={nextPageName}
486 onBackClick={handleBackClick}
487 onContinueClick={handleContinueClick}
488 prevPageName={prevPageName}
489 />
490 </>
491 );
492}
493
494ClinicForm.propTypes = {
495 api: PropTypes.shape({ submitSite: PropTypes.func.isRequired }).isRequired,
496 contentId: PropTypes.string.isRequired,
497 country: PropTypes.string.isRequired,
498 googleApiKey: PropTypes.string.isRequired,
499 intl: intlPropType.isRequired,
500 navigateModal: PropTypes.func.isRequired,
501 nextPageName: PropTypes.string,
502 pageName: PropTypes.string.isRequired,
503 prevPageName: PropTypes.string,
504 sites: PropTypes.arrayOf(
505 PropTypes.shape({
506 city: PropTypes.string,
507 countryCode: PropTypes.string,
508 geopos: PropTypes.shape({}),
509 id: PropTypes.string.isRequired,
510 name: PropTypes.string,
511 street: PropTypes.string,
512 zipCode: PropTypes.string,
513 }),
514 ).isRequired,
515};
516
517ClinicForm.defaultProps = {
518 nextPageName: undefined,
519 prevPageName: undefined,
520};
521
522export default injectIntl(
523 geolocated({
524 positionOptions: {
525 enableHighAccuracy: false,
526 },
527 userDecisionTimeout: 5000,
528 })(ClinicForm),
529);
530