<div data-react="find-installer" data-props="{"placesEndpoint":"/mocks/api/findInstaller.json","lang":{"startTitle":"Hitta installatör","startDescription":"Behöver du hjälp att installera? Eller är du kanske osäker på vilka produkter och lösningar som är bäst för dina behov i hemmet? Tala med en erfaren auktoriserad installatör så får du hjälp att skapa inomhusklimat i världsklass i ditt eget hem.","searchLabel":"Sök på adress","geolocation":"Använd min position","filterToggle":"Visa filter","filterTitle":"Välj filter","filterPlaceholder":"Filter","filterRemove":"Ta bort"}}" class="find-installer"></div>
<div data-react="find-installer" data-props="{{jsonEncode props}}" class="find-installer"></div>
{
"props": {
"placesEndpoint": "/mocks/api/findInstaller.json",
"lang": {
"startTitle": "Hitta installatör",
"startDescription": "Behöver du hjälp att installera? Eller är du kanske osäker på vilka produkter och lösningar som är bäst för dina behov i hemmet? Tala med en erfaren auktoriserad installatör så får du hjälp att skapa inomhusklimat i världsklass i ditt eget hem.",
"searchLabel": "Sök på adress",
"geolocation": "Använd min position",
"filterToggle": "Visa filter",
"filterTitle": "Välj filter",
"filterPlaceholder": "Filter",
"filterRemove": "Ta bort"
}
},
"theme": "consumer"
}
import React from 'react';
import PropTypes from 'prop-types';
const AreaFilter = (props) => {
const [isOpen, setIsOpen] = React.useState(false);
const filterRef = React.useRef();
const togglerRef = React.useRef();
const optionsRef = React.useRef();
const focusIndexRef = React.useRef(null);
// Selected areas are shown as pills and removed from the dropdown
const unselectedAreas = props.areas.filter((area) => !props.selectedAreas.includes(area));
const onToggle = (area) => {
props.onChange(
props.selectedAreas.includes(area)
? props.selectedAreas.filter((selectedArea) => selectedArea !== area)
: [...props.selectedAreas, area]
);
};
const onSelectOption = (area, index) => {
focusIndexRef.current = index;
onToggle(area);
};
// Selected option disappears from the list, so keep focus in the dropdown
React.useEffect(() => {
if (focusIndexRef.current === null) {
return;
}
const options = optionsRef.current.querySelectorAll('.select-multiple__option');
const nextOption = options[Math.min(focusIndexRef.current, options.length - 1)];
(nextOption || togglerRef.current).focus();
focusIndexRef.current = null;
}, [props.selectedAreas]);
const close = () => {
setIsOpen(false);
togglerRef.current.focus();
};
// Close dropdown on click outside
React.useEffect(() => {
if (!isOpen) {
return;
}
const onClickOutside = (e) => {
if (!filterRef.current.contains(e.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, [isOpen]);
return (
<div
className="find-installer-filter"
id={props.id}
ref={filterRef}
onKeyDown={(e) => {
if (isOpen && (e.key === 'Escape' || e.key === 'Esc')) {
close();
}
}}
>
{props.title &&
<div className="find-installer-filter__label" id={`${props.id}-label`}>{props.title}</div>
}
<div className="select-multiple">
<div className="select-multiple__wrapper">
{props.selectedAreas.map((area) => (
<button
type="button"
className="select-multiple__pill"
key={area}
aria-label={`${props.removeLabel} ${area}`}
onClick={() => onToggle(area)}
>
{area}
</button>
))}
<button
type="button"
className="select-multiple__toggler"
ref={togglerRef}
aria-expanded={isOpen}
aria-controls={`${props.id}-options`}
aria-labelledby={props.title ? `${props.id}-label` : undefined}
onClick={() => setIsOpen((open) => !open)}
>
{props.placeholder}
</button>
</div>
<ul
className={`select-multiple__options ${isOpen ? 'select-multiple__options--open' : ''}`}
id={`${props.id}-options`}
ref={optionsRef}
hidden={!isOpen || !unselectedAreas.length}
>
{unselectedAreas.map((area, index) => (
<li key={area}>
<button
type="button"
className="select-multiple__option"
onClick={() => onSelectOption(area, index)}
>
{area}
</button>
</li>
))}
</ul>
</div>
</div>
);
};
AreaFilter.propTypes = {
id: PropTypes.string,
title: PropTypes.string,
placeholder: PropTypes.string,
removeLabel: PropTypes.string,
areas: PropTypes.arrayOf(PropTypes.string).isRequired,
selectedAreas: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired
};
AreaFilter.defaultProps = {
id: 'find-installer-filter',
placeholder: 'Filter',
removeLabel: 'Remove'
};
export default AreaFilter;
import React from 'react';
import PropTypes from 'prop-types';
import StartView from './StartView';
import MapView from './MapView';
const FindInstaller = (props) => {
const [selectedLocationCords, setSelectedLocationCords] = React.useState();
const onSelect = (cords) => setSelectedLocationCords(() => cords);
return (
<>
{selectedLocationCords
? <MapView
selectedLocationCords={selectedLocationCords}
placesEndpoint={props.placesEndpoint}
lang={props.lang}
/>
: <StartView
lang={props.lang}
onSelect={onSelect}
/>
}
</>
);
};
FindInstaller.propTypes = {
placesEndpoint: PropTypes.string.isRequired
};
export default FindInstaller;
import React from 'react';
import PropTypes from 'prop-types';
import GoogleMap from '../google-map/GoogleMap';
import SearchFieldPlaces from './SearchFieldPlaces';
import MapContactCard from '../map-contact-card/MapContactCard';
import AreaFilter from './AreaFilter';
import { installersToContactInfoProps } from './utils/parsers';
import { getAreas, filterInstallersByAreas, getAreasFromUrl, setAreasInUrl } from './utils/areaFilter';
const MapView = (props) => {
const [selectedLocationCords, setSelectedLocationCords] = React.useState(props.selectedLocationCords);
const [places, setPlaces] = React.useState();
const [visiblePlaces, setVisiblePlaces] = React.useState([]);
const [selectedPlace, setSelectedPlace] = React.useState('');
const [selectedAreas, setSelectedAreas] = React.useState(getAreasFromUrl);
const [isFilterOpen, setIsFilterOpen] = React.useState(selectedAreas.length > 0);
const mapRef = React.useRef();
const areas = React.useMemo(() => (places ? getAreas(places) : []), [places]);
const filteredPlaces = React.useMemo(() => (
places ? filterInstallersByAreas(places, selectedAreas) : places
), [places, selectedAreas]);
const onAreasChange = (newAreas) => {
setSelectedAreas(newAreas);
setAreasInUrl(newAreas);
};
const openInfoWindow = (id) => {
mapRef.current.openInfoWindow(id);
};
React.useEffect(() => {
if (mapRef.current) {
openInfoWindow(selectedPlace);
}
}, [selectedPlace, mapRef]);
React.useEffect(() => {
fetch(props.placesEndpoint)
.then((res) => res.json())
.then((data) => setPlaces(data.Result));
}, []);
return (
<div className="find-installer-map">
<aside className="find-installer-map__result">
<div className="row">
<div className="col col--span-12">
<div className="find-installer-map__search">
<SearchFieldPlaces
onSelect={(cords) => setSelectedLocationCords(cords)}
label={props.lang.searchLabel}
geolocationLabel={props.lang.geolocation}
>
{areas.length > 0 &&
<button
type="button"
className={`button button--ghost search-field__icon-button ${isFilterOpen ? 'search-field__icon-button--active' : ''}`}
aria-label={props.lang.filterToggle}
aria-expanded={isFilterOpen}
aria-controls="find-installer-filter"
onClick={() => setIsFilterOpen((open) => !open)}
>
<span className="search-field__icon search-field__icon--filter" aria-hidden="true" />
</button>
}
</SearchFieldPlaces>
</div>
{areas.length > 0 && isFilterOpen &&
<AreaFilter
id="find-installer-filter"
title={props.lang.filterTitle}
placeholder={props.lang.filterPlaceholder}
removeLabel={props.lang.filterRemove}
areas={areas}
selectedAreas={selectedAreas}
onChange={onAreasChange}
/>
}
</div>
</div>
<div className="find-installer-map__cards">
<div className="row">
{filteredPlaces
? installersToContactInfoProps(filteredPlaces, selectedLocationCords).map(({ contactInfo, key, title }) => (
visiblePlaces.includes(key) &&
<div className="col col--span-12 col--span-m-6 col--span-xl-12" key={key}>
<MapContactCard
key={key}
id={String(key)}
title={title}
contactInfo={contactInfo}
active={selectedPlace === key}
onClick={() => setSelectedPlace(key)}
/>
</div>
))
// ToDo: Add loading state
: null}
</div>
</div>
</aside>
<div className="find-installer-map__map">
{filteredPlaces
? <GoogleMap
ref={mapRef}
places={filteredPlaces}
targetPlaceCords={selectedLocationCords}
onVisibleMarkersUpdate={setVisiblePlaces}
onInfoWindowClose={() => setSelectedPlace(() => '')}
onMarkerClick={(marker) => setSelectedPlace(() => marker.Id)}
/>
// ToDo: Add loading state
: null}
</div>
</div>
);
};
MapView.propTypes = {
selectedLocationCords: PropTypes.object.isRequired,
setSearchLatLon: PropTypes.shape({
lat: PropTypes.number,
lon: PropTypes.number
}),
placesEndpoint: PropTypes.string.isRequired,
lang: PropTypes.shape({
searchLabel: PropTypes.string,
geolocation: PropTypes.string,
filterToggle: PropTypes.string,
filterTitle: PropTypes.string,
filterPlaceholder: PropTypes.string,
filterRemove: PropTypes.string
})
};
MapView.defaultProps = {
lang: {}
};
export default MapView;
import React from 'react';
import PropTypes from 'prop-types';
import { GoogleMapsContext } from '../../context/GoogleMapsProvider';
import SearchField, { Geolocation } from '../search-field';
const ItemComponent = (props) => (
<>
<svg className="search-field-places__icon-wrapper" focusable="false">
<use xlinkHref="#icon-map-pin"/>
</svg>
{props.description}
</>
);
const SearchFieldPlaces = (props) => {
const { autocompleteService, googleMapsApi } = React.useContext(GoogleMapsContext);
const [searchResult, setSearchResult] = React.useState([]);
const updateSearchResult = (e) => {
const input = e.target.value || '';
if (input.length > 0) {
autocompleteService.getPlacePredictions({ input }, (places) => {
setSearchResult(() => (
places
? places.map((place) => ({
...place,
value: place.description,
id: place.place_id
}))
: []
));
});
} else {
// Reset statae when there's no search result
setSearchResult(() => []);
}
};
const getCords = async (e) => {
const service = new googleMapsApi.places.PlacesService(document.createElement('div'));
const cords = await new Promise((resolve, reject) => {
try {
service.getDetails({ placeId: e.place_id }, (data) => {
resolve(data.geometry.location);
});
} catch (e) {
reject(e);
}
});
props.onSelect(cords);
};
const onGeolocation = (latLng) => {
props.onSelect(new googleMapsApi.LatLng(...latLng));
};
return (
<>
{googleMapsApi && props.label &&
<label id={`${props.inputId}-label`} htmlFor={props.inputId} className="form-item__label">
{props.label}
</label>
}
{googleMapsApi &&
<SearchField
inputId={props.inputId}
labelId={props.label ? `${props.inputId}-label` : undefined}
onChange={getCords}
items={searchResult}
onInputChange={updateSearchResult}
itemStyle="search-field-places__result"
className={props.className}
itemComponent={ItemComponent}
>
<Geolocation onGeolocation={onGeolocation} label={props.geolocationLabel} />
{props.children}
</SearchField>
}
</>
);
};
SearchFieldPlaces.propTypes = {
onChange: PropTypes.func,
label: PropTypes.string,
inputId: PropTypes.string,
geolocationLabel: PropTypes.string
};
SearchFieldPlaces.defaultProps = {
onChange: () => {},
inputId: 'search-field-places-input'
};
export default SearchFieldPlaces;
import React from 'react';
import PropTypes from 'prop-types';
import SearchFieldPlaces from './SearchFieldPlaces';
const StartView = (props) => {
return (
<div className="find-installer-start">
<div className="find-installer-start__panel">
<h1>{props.lang.startTitle}</h1>
<p className="preamble">
{props.lang.startDescription}
</p>
<SearchFieldPlaces
onSelect={props.onSelect}
geolocationLabel={props.lang.geolocation}
className="search-field--big-m h-no-margin-bottom"
/>
</div>
</div>
);
};
StartView.propTypes = {
onSelect: PropTypes.func
};
StartView.defaultProps = {
onSelect: () => {}
};
export default StartView;
.find-installer {
display: flex;
width: 100%;
align-items: center;
justify-content: space-around;
@include breakpoint($xl) {
height: 70vh;
min-height: size(60);
}
}
// start view
.find-installer-start {
display: flex;
align-items: center;
justify-content: space-around;
width: 100%;
height: 100%;
background: url("./img/start-background.png") center no-repeat;
background-size: cover;
}
.find-installer-start__panel {
width: 100%;
max-width: size(116);
background: $color-white;
padding: size(4) size(2);
margin: size(5) size(1);
@include breakpoint($m) {
padding: size(10) size(15);
margin: size(6) size(2);
}
}
// map view
.find-installer-map {
display: flex;
flex: 1;
height: 100%;
flex-direction: column;
flex-direction: column-reverse;
@include breakpoint($xl) {
flex-direction: row;
flex-direction: initial;
}
}
.find-installer-map__map {
flex: 1 0 auto;
height: 50vh;
@include breakpoint($xl) {
display: flex;
flex-direction: column;
height: 100%;
width: size(52);
padding: 0;
}
}
// sidebar
.find-installer-map__result {
background: $color-gray-1;
width: 100%;
padding-top: size(3);
@include breakpoint($xl) {
display: flex;
flex-direction: column;
width: size(60);
padding: size(3) size(2) 0;
border-right: 1px solid $color-gray-2;
}
}
.find-installer-map__search {
// Offsets the search field's own margin, leaving 24px (20px on desktop) below it
margin-bottom: size(-1);
@include breakpoint($xl) {
margin-bottom: size(-1.5);
}
}
.find-installer-map__cards {
flex: 1;
overflow: auto;
}
// area filter
.find-installer-filter {
position: relative;
.select-multiple {
margin-bottom: size(3);
@include breakpoint($xl) {
margin-bottom: size(2.5);
}
}
.select-multiple__wrapper {
min-height: size(6);
padding: size(1) size(6) 0 size(2);
border-color: $color-gray-3;
}
.select-multiple__toggler {
padding: 0 size(2);
background-image: url('./img/icon-chevron-down.svg');
background-size: size(2) size(2);
font-family: inherit;
font-size: size(2);
line-height: size(3);
font-weight: normal;
color: $color-black;
}
.select-multiple__pill {
margin: 0 size(1) size(1) 0;
padding: size(1) size(3) size(1) size(1);
border: 0;
font-family: inherit;
font-size: size(1.5);
line-height: size(2);
text-align: left;
&:after {
right: size(1);
width: size(1);
height: size(1);
margin-top: size(-0.5);
background-image: url('./img/icon-close.svg');
background-size: contain;
}
}
.select-multiple__options {
margin: 0;
padding: 0;
list-style: none;
max-height: size(40);
border-color: $color-gray-3;
}
.select-multiple__option {
width: 100%;
padding: size(1) size(2);
border: 0;
background: none;
font-family: inherit;
font-size: size(1.75);
line-height: size(2);
text-align: left;
color: $color-green-dark;
&:hover,
&:focus-visible {
background: $color-gray-1;
color: $color-green-dark;
}
@include color-theme {
&,
&:hover,
&:focus-visible {
color: $arg-theme-color;
}
}
}
}
.find-installer-filter__label {
margin-bottom: size(1);
font-size: size(2);
line-height: size(3);
font-weight: $font-weight-bold;
color: $color-black;
}
.search-field-places__result {
min-height: size(8);
padding: size(2);
}
.search-field-places__icon-wrapper {
width: size(4);
height: size(4);
margin-right: size(2);
color: $color-green;
flex-shrink: 0;
@include color-theme {
color: $arg-theme-color;
}
}
No notes defined.