import React, { useState, useEffect, useCallback, type ChangeEvent } from 'react' import styled from 'styled-components' import { animated, config as springConfig, Transition } from '@react-spring/web' import de from './locales/de.yml' import fr from './locales/fr.yml' type MessageListContent = string | MessageList /** Recursive type for storing translated strings in objects */ interface MessageList { [key: string]: MessageListContent } interface LocalisedMessagesList { [key: string]: MessageList } export interface Municipality { readonly GDENR: number readonly ORTNAME?: string readonly PLZ4?: number readonly PLZ6?: number readonly GDENAMK?: string readonly KTKZ: string readonly NORMORTSNAME?: string readonly NORMGEMEINDE: string readonly URL?: string [x: string]: any } export interface Props { /** Which year to use for the municipality data. For the 2019 data, this * would be `2019`. */ municipalityData?: string /** Deduplicate list of municipality names */ dedupe?: boolean /** Reset the component when the user selects a municipality */ resetOnSelect?: boolean /** Handler for when a municipality is selected by the user */ onSelectionHandler: (municipality: Municipality) => void /** Locale to use */ locale?: 'de' | 'fr' /** Placeholder text */ placeholder?: string /** Show the search icon on the right side */ iconOnRightSide?: boolean /** Max number of shown results */ maxResults?: number /** Custom list of municipalities */ customMunicipalities?: Municipality[] /** Currently selected municipality displayed inside the input field */ selectedMunicipality?: Municipality /** The id of currently selected municipality displayed inside the input field */ selectedMunicipalityId?: number /** Handler for when close button pressed */ onCloseHandler?: () => void /** Input background color */ inputBackgroundColor?: string /** Result background color */ resultBackgroundColor?: string /** Property on your municipality you want to search for. The value of this key MUST be a string */ propertyToSearch?: keyof Municipality } /** All localised strings for the component */ const MESSAGES: LocalisedMessagesList = { de, fr } const STARTS_WITH_NUMBER = /^\d/ const CONTAINS_ONLY_LETTERS = /^\D+$/ const CONTAINS_FOUR_DIGITS = /\d{4}/ const API_URL = 'https://interaktiv-mf.tagesanzeiger.ch/municipality-data/' const LEGACY_API_URL = 'https://interaktiv.tagesanzeiger.ch/static/gemeindesuche/' class MunicipalityDownloadError extends Error {} const translate = ( messageList: LocalisedMessagesList, locale: string, key: string | null, ): string => { if (key == null) return '' return ( key .split('.') .reduce( (messages: MessageListContent, prop: string) => messages[prop], messageList[locale], ) || 'TRANSLATED STRING NOT FOUND' ) as string } const muniToString = (muni: Municipality) => `${muni.GDENR}${muni.ORTNAME ?? ''}${muni.PLZ4 ?? ''}` export const MunicipalitySearch = ({ municipalityData = '2021v3', locale = 'de', resultBackgroundColor = 'var(--site-background)', inputBackgroundColor = 'var(--site-background)', maxResults = 10, propertyToSearch = 'NORMGEMEINDE', dedupe = false, resetOnSelect = false, onSelectionHandler, placeholder, iconOnRightSide, customMunicipalities, selectedMunicipality, selectedMunicipalityId, onCloseHandler, }: Props) => { const [results, setResults] = useState([]) const [value, setValue] = useState('') const [error, setError] = useState(null) const [municipalities, setMunicipalities] = useState([]) const t = useCallback( (key: string | null) => translate(MESSAGES, locale, key), [locale], ) useEffect(() => { setResults([]) setValue('') setError(null) if (customMunicipalities) { setMunicipalities(customMunicipalities) return } let cancelled = false const fetchData = async (useLegacy = false) => { const baseUrl = useLegacy ? LEGACY_API_URL : API_URL try { const res = await fetch(`${baseUrl}${municipalityData}.json`) if (!res.ok) { throw new MunicipalityDownloadError( `Download error: ${res.status}: ${res.statusText}.`, ) } const data = await res.json() if (!cancelled) setMunicipalities(data) } catch (err) { if (cancelled) return if (!useLegacy) return fetchData(true) if (err instanceof MunicipalityDownloadError) { console.error(err) console.info( 'Make sure that you have the municipality and zip code data for the required year uploaded to the Interaktiv server.', ) setError('error.municipalitiesNotDownloaded') } else { throw err } } } fetchData() return () => { cancelled = true } }, [municipalityData, customMunicipalities]) const removeDuplicates = (arr: Municipality[], searchTerm: string) => { const map = new Map() for (const v of arr) { const key = dedupe ? `${v.ORTNAME ?? ''}${v.GDENR}` : `${v.PLZ4 ?? ''}${v.ORTNAME ?? ''}${v.GDENR}` map.set(key, v) } const exact: Municipality[] = [] const rest: Municipality[] = [] const lower = searchTerm.toLowerCase() for (const m of map.values()) { if ( m.GDENAMK?.toLowerCase() === lower || m.ORTNAME?.toLowerCase() === lower ) { exact.push(m) } else { rest.push(m) } } return [...exact, ...rest] } const filterList = (searchTerm: string) => { searchTerm = searchTerm.trim() if (searchTerm.length <= 1 || municipalities.length === 0) { return [] } let filtered: Municipality[] = [] const fourDigitMatch = CONTAINS_FOUR_DIGITS.exec(searchTerm) if (fourDigitMatch) { filtered = municipalities.filter( (m) => (m.PLZ4 ?? '').toString() === fourDigitMatch[0], ) } else if (STARTS_WITH_NUMBER.test(searchTerm)) { filtered = municipalities.filter((m) => RegExp(`^${searchTerm}`).test((m.PLZ4 ?? '').toString()), ) } else if (CONTAINS_ONLY_LETTERS.test(searchTerm)) { const sanitized = searchTerm.replace('.', '\\.') const regex = RegExp(sanitized, 'i') const exactWordRegex = RegExp(`\\b${sanitized}\\b`, 'i') const scoreResult = (term: string, compare: string) => { if (compare === term) return 2 if (exactWordRegex.test(compare)) return 1 return 0 } filtered = municipalities .filter((m) => regex.test(m[propertyToSearch])) .sort((a, b) => { const aScore = scoreResult( searchTerm.toLowerCase(), a[propertyToSearch].toLowerCase(), ) const bScore = scoreResult( searchTerm.toLowerCase(), b[propertyToSearch].toLowerCase(), ) return bScore - aScore }) .slice(0, maxResults) } window.dataLayer = window.dataLayer || [] window.dataLayer.push({ event: 'Interactions', event_action: 'input', event_label: `search:for_${searchTerm}:results_${filtered.length}`, }) return removeDuplicates(filtered, searchTerm) } const resetComponent = () => { setResults([]) setValue('') } const handleSearchChange = (e: ChangeEvent) => { const inputValue = e.currentTarget.value setValue(inputValue) if (inputValue.length < 1) { return resetComponent() } setResults(filterList(inputValue)) } const handleSelect = (municipality: Municipality) => { onSelectionHandler(municipality) if (resetOnSelect) resetComponent() } const preSelectedMunicipality = selectedMunicipality ?? municipalities.find((m) => m.GDENR === selectedMunicipalityId) const hasResults = results.length > 0 return ( {preSelectedMunicipality ? ( onCloseHandler?.()} $isClickable id='search-closing-icon' $isRight > ) : ( )} {hasResults && ( {(style, result) => ( handleSelect(result)} onKeyUp={(e) => { if (e.key === 'Enter' || e.key === 'Space') { handleSelect(result) } }} className='result' style={style} tabIndex={0} > {result.PLZ4}{' '} {result.ORTNAME} {result.ORTNAME ? t('list.municipalityPrefix') : ''}{' '} {result.GDENAMK ?? result.NORMGEMEINDE + (results.find( (m) => m !== result && m.NORMGEMEINDE === result.NORMGEMEINDE, ) && result.KTKZ ? ` (${result.KTKZ})` : '')} )} )} ) } const Icon = styled.i<{ $isRight?: boolean; $isClickable?: boolean }>` cursor: ${(props) => (props.$isClickable ? 'pointer' : 'default')}; position: absolute; top: 50%; ${(props) => (props.$isRight ? 'right: 0; padding-right: 0.8em;' : '')} padding-left: 0.8em; transform: translateY(-50%); display: flex; opacity: 0.5; transition: opacity 0.5s ease-in-out; animation: fromLeft 0.3s ease-in-out; @keyframes fromLeft { 0% { transform: translateY(-50%) translateX(-10px); opacity: 0; } 100% { transform: translateY(-50%) translateX(0); } } &#search-closing-icon { &:hover { opacity: 1; } animation: fromRight 0.3s ease-in-out; @keyframes fromRight { 0% { transform: translateY(-50%) translateX(10px); opacity: 0; } 100% { transform: translateY(-50%) translateX(0); } } } ` const MunicipalitySearchContainer = styled.div` --border-radius: 0.3em; font-size: 18px; font-family: var(--font-plex); max-width: 300px; width: 100%; color: inherit; position: relative; @media screen and (max-width: 599px) { max-width: 100%; } ` const InputRow = styled.div` position: relative; display: flex; flex-direction: row; align-items: center; input:focus ~ i { opacity: 1; } ` const FlexInput = styled.input<{ $lessPaddingLeft?: boolean $backgroundColor?: string }>` color: inherit; background-color: ${({ $backgroundColor }) => $backgroundColor}; border: solid 1px #7e7e7e7e; border-radius: var(--border-radius); box-shadow: none; font-size: 1em; outline: none; transition: all 0.3s ease-in-out; padding: 0.5em; padding-left: ${({ $lessPaddingLeft }) => $lessPaddingLeft ? '1em' : '2.4em'}; margin: 0; width: 100%; display: flex; &:disabled { opacity: 0.5; } &::placeholder { color: inherit; opacity: 0.5; } ` const Results = styled.div<{ $backgroundColor?: string }>` margin-top: 0.25em; font-family: var(--font-plex); overflow: hidden; display: flex; flex-direction: column; pointer-events: auto; position: absolute; background-color: ${({ $backgroundColor }) => $backgroundColor}; width: 100%; border-radius: var(--border-radius); @media screen and (min-width: 599px) { max-width: 300px; } .result { max-width: 100%; color: inherit; padding: calc(11 / 16 * 1em) calc(14 / 16 * 1em); border-radius: var(--border-radius); box-shadow: 0 0 0 rgba(0, 0, 0, 0.3); line-height: 1.1em; cursor: pointer; transition: box-shadow 200ms ease-in-out; &:hover, &:focus { box-shadow: 0 0 0.5ex rgba(0, 0, 0, 0.2), 0 0 0 1px #007abf inset; } } ` const ResultHeader = styled.div`` const ResultPlz = styled.span` font-weight: 400; ` const ResultMeta = styled.div` font-size: 0.9em; `