diff --git a/package.json b/package.json index ec9d96dba..8c41fff95 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "lazysizes": "^5.3.2", "lodash": "^4.17.11", "moment": "^2.29.4", + "postcode": "^5.1.0", "prop-types": "^15.8.1", "pure-react-carousel": "1.30.1", "react": "^17.0.2", diff --git a/src/components/Atoms/TextInputWithDropdown/TextInputWithDropdown.style.js b/src/components/Atoms/TextInputWithDropdown/TextInputWithDropdown.style.js index 92cd0614f..fa84833bc 100644 --- a/src/components/Atoms/TextInputWithDropdown/TextInputWithDropdown.style.js +++ b/src/components/Atoms/TextInputWithDropdown/TextInputWithDropdown.style.js @@ -19,7 +19,6 @@ const Dropdown = styled.div` border: 1px solid; margin-top: -1px; width: 100%; - @media ${({ theme }) => theme.allBreakpoints('M')} { max-width: 500px; } diff --git a/src/components/Molecules/Lookup/Lookup.js b/src/components/Molecules/Lookup/Lookup.js index d39d957a2..bc2278e4c 100644 --- a/src/components/Molecules/Lookup/Lookup.js +++ b/src/components/Molecules/Lookup/Lookup.js @@ -5,41 +5,34 @@ import TextInputWithDropdown from '../../Atoms/TextInputWithDropdown/TextInputWi import spacing from '../../../theme/shared/spacing'; import ButtonWithStates from '../../Atoms/ButtonWithStates/ButtonWithStates'; -const StyledButton = styled(ButtonWithStates)`${({ theme }) => css` - color: ${theme.color('grey_dark')}; - border: 2px solid ${theme.color('grey_dark')}; - background-color: ${theme.color('white')}; - padding-left: ${spacing('lg')}; - padding-right: ${spacing('lg')}; - +const Button = styled(ButtonWithStates)`${({ + theme, + buttonTextColour, + buttonBgColour, + buttonTextHoverColour, + buttonHoverBgColour +}) => css` + margin-top: ${spacing('m')}; + color: ${buttonTextColour || theme.color('white')}; + background-color: ${buttonBgColour || theme.color('red')}; &:hover { - color: ${theme.color('grey_dark')}; - background-color: ${theme.color('white')}; + color: ${buttonTextHoverColour || theme.color('white')}; + background-color: ${buttonHoverBgColour || theme.color('red_dark')}; } + padding: 0 ${spacing('lg')}; `}`; const KEY_CODE_ENTER = 13; /** - * A simple lookup component - * - * The `lookupHandler` should be an async function which is called when a lookup is triggered - * (either by hitting enter or clicking the button) - * - * It will receive the current search term and should: - * - take care of any validation on the search term - * - perform the actual lookup request - * - return an array of options (or an empty array if none were found) - * - only throw errors with user-friendly messages - * - * Any errors thrown will be caught and the message will be displayed to the user. - * - * The `onSelect` function will receive the chosen option. - * * @param name * @param label * @param placeholder * @param buttonText + * @param buttonTextColour + * @param buttonBgColour + * @param buttonHoverBgColour + * @param buttonHoverTextColour * @param lookupHandler * @param mapOptionToString * @param onSelect @@ -54,6 +47,10 @@ const Lookup = ({ label, placeholder, buttonText, + buttonTextColour, + buttonBgColour, + buttonHoverBgColour, + buttonHoverTextColour, lookupHandler, mapOptionToString, onSelect, @@ -115,15 +112,19 @@ const Lookup = ({ errorMsg={errorMessage} dropdownInstruction={dropdownInstruction} /> - handler()} loading={isSearching} disabled={isSearching} loadingText="Searching" + buttonTextColour={buttonTextColour} + buttonBgColour={buttonBgColour} + buttonHoverBgColour={buttonHoverBgColour} + buttonHoverTextColour={buttonHoverTextColour} > {buttonText} - + ); }; @@ -133,6 +134,10 @@ Lookup.propTypes = { label: PropTypes.string.isRequired, placeholder: PropTypes.string.isRequired, buttonText: PropTypes.string.isRequired, + buttonTextColour: PropTypes.string, + buttonBgColour: PropTypes.string, + buttonHoverBgColour: PropTypes.string, + buttonHoverTextColour: PropTypes.string, lookupHandler: PropTypes.func.isRequired, mapOptionToString: PropTypes.func.isRequired, onSelect: PropTypes.func.isRequired, @@ -142,7 +147,11 @@ Lookup.propTypes = { Lookup.defaultProps = { noResultsMessage: 'Sorry, could not find any results for your search', - dropdownInstruction: '' + dropdownInstruction: '', + buttonTextColour: 'white', + buttonBgColour: '#E52630', + buttonHoverTextColour: 'white', + buttonHoverBgColour: '#890B11' }; export default Lookup; diff --git a/src/components/Molecules/Lookup/Lookup.md b/src/components/Molecules/Lookup/Lookup.md new file mode 100644 index 000000000..c66b34469 --- /dev/null +++ b/src/components/Molecules/Lookup/Lookup.md @@ -0,0 +1,64 @@ +# Lookup +This is a simple basic vanilla component and is rendered here mainly for dev purposes. +You may would be best served using one of these more substantial components in projects / production: +- PostcodeLookup +- SchoolLookup +- SimpleSchoolLookup + +The `lookupHandler` should be an async function which is called when a lookup is triggered (either by hitting enter or clicking the button). + +It will receive the current search term and should: +- take care of any validation on the search term +- perform the actual lookup request +- return an array of options (or an empty array if none were found) +- only throw errors with user-friendly messages + +Any errors thrown will be caught and the message will be displayed to the user. + +The `onSelect` function will receive the chosen option. + +```js +import Lookup from './Lookup'; + +const schoolFetcher = async query => { + if (!query || !query.trim()) { + throw new Error('Please enter a search query'); + } + + if (query.length < 2) { + throw new Error('Please enter at least two characters'); + } + + try { + const res = await axios.get( + 'https://lookups.sls.comicrelief.com/schools/lookup', + { timeout: 10000, params: { query } } + ); + return res.data.data.schools; + } catch (error) { + // if (typeof Sentry !== 'undefined') { + // Sentry.captureException(error); + // } + throw new Error('Sorry, something unexpected went wrong. Please try again or enter your school manually'); + } +}; + +const schoolToString = school => `${school.name}, ${school.post_code}`; + + alert(JSON.stringify(address, null, 2))} + // buttonTextColour='black' + // buttonBgColour='#f04257' + // buttonHoverTextColour='white' + // buttonHoverBgColour='orange' +/> +``` + diff --git a/src/components/Molecules/PostcodeLookup/PostcodeLookup.js b/src/components/Molecules/PostcodeLookup/PostcodeLookup.js new file mode 100644 index 000000000..17b8955b1 --- /dev/null +++ b/src/components/Molecules/PostcodeLookup/PostcodeLookup.js @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; +import Lookup from '../Lookup/Lookup'; +import AddressInputs from './utils/AddressInputs'; +import { addressToString, addressFetcher } from './utils/PostcodeFunctions'; +import spacing from '../../../theme/shared/spacing'; + +// ${({ theme }) => theme.linkStyles('standard')}; +const AddressManually = styled.button` + margin: ${spacing('md')} 0 0 ${spacing('sm')}; + background: inherit; + border: none; + font-size: 1rem; + &:hover, &:active { + text-decoration: underline; + } +`; + +export default function PostcodeLookup({ + label, + name, + placeholder, + buttonText, + noResultsMessage, + reportError, + dropdownInstruction, + buttonColour +}) { + const [showFields, setShowFields] = useState(false); + + // Address field state + const [addressFields, setAddressFields] = useState({ + line1: '', + line2: '', + line3: '', + posttown: '' + }); + + // Function to update address fields + const handleAddressSelect = selectedAddress => { + setShowFields(true); + setAddressFields({ + line1: selectedAddress.Line1 || '', + line2: selectedAddress.Line2 || '', + line3: selectedAddress.Line3 || '', + posttown: selectedAddress.posttown || '' + }); + }; + + const handleManualClick = event => { + event.preventDefault(); + setShowFields(true); + }; + + return ( + <> + addressFetcher(postcode, reportError)} + onSelect={handleAddressSelect} + dropdownInstruction={dropdownInstruction} + noResultsMessage={noResultsMessage} + buttonColour={buttonColour} + /> + + Or enter your address manually + + {showFields && } + + ); +} + +PostcodeLookup.propTypes = { + name: PropTypes.string, + label: PropTypes.string, + placeholder: PropTypes.string, + buttonText: PropTypes.string, + noResultsMessage: PropTypes.string, + dropdownInstruction: PropTypes.string, + reportError: PropTypes.oneOfType([ + PropTypes.func + ]), + buttonColour: PropTypes.string +}; + +PostcodeLookup.defaultProps = { + name: 'postcode_lookup', + label: 'Find address by postcode', + placeholder: 'Enter postcode...', + buttonText: 'Find address', + noResultsMessage: 'Sorry, could not find any addresses for that postcode', + dropdownInstruction: 'Please select an organisation from the list below', + reportError: undefined, + buttonColour: '#f04257' +}; diff --git a/src/components/Molecules/PostcodeLookup/PostcodeLookup.md b/src/components/Molecules/PostcodeLookup/PostcodeLookup.md new file mode 100644 index 000000000..32b276faa --- /dev/null +++ b/src/components/Molecules/PostcodeLookup/PostcodeLookup.md @@ -0,0 +1,24 @@ +```js +import PostcodeLookup from './PostcodeLookup'; + +// This is just an example of how a parent component might handle fetch errors within the component. +const [enterManually, setEnterManually] = React.useState(false); + +const fetchErrorHandler = () => { + setEnterManually(true); +} + +enterManually + ? 'Sorry, there appears to be a problem. Please enter your details manually.' + : ( + + ) +``` diff --git a/src/components/Molecules/PostcodeLookup/PostcodeLookup.test.js b/src/components/Molecules/PostcodeLookup/PostcodeLookup.test.js new file mode 100644 index 000000000..b66caed72 --- /dev/null +++ b/src/components/Molecules/PostcodeLookup/PostcodeLookup.test.js @@ -0,0 +1,12 @@ +import React from 'react'; +import 'jest-styled-components'; + +import renderWithTheme from '../../../hoc/shallowWithTheme'; +import PostcodeLookup from './PostcodeLookup'; + +it('renders correctly', () => { + const tree = renderWithTheme( + alert(JSON.stringify(address, null, 2))} /> + ).toJSON(); + expect(tree).toMatchSnapshot() +}); diff --git a/src/components/Molecules/PostcodeLookup/__snapshots__/PostcodeLookup.test.js.snap b/src/components/Molecules/PostcodeLookup/__snapshots__/PostcodeLookup.test.js.snap new file mode 100644 index 000000000..5c3a815f1 --- /dev/null +++ b/src/components/Molecules/PostcodeLookup/__snapshots__/PostcodeLookup.test.js.snap @@ -0,0 +1,231 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` +Array [ + .c3 { + font-size: 1rem; + line-height: 1rem; + text-transform: inherit; + font-weight: bold; + line-height: normal; + font-family: 'Montserrat',Helvetica,Arial,sans-serif; +} + +.c7 { + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + position: relative; + padding: 0.5rem 1.25rem; + -webkit-text-decoration: none; + text-decoration: none; + font-weight: 700; + font-size: 1rem; + border-radius: 2rem; + -webkit-transition: all 0.3s; + transition: all 0.3s; + height: 3.125rem; + width: 100%; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + border: none; + cursor: pointer; + background-color: #E52630; + color: #FFFFFF; +} + +.c7 > a { + -webkit-text-decoration: none; + text-decoration: none; +} + +.c7:hover { + background-color: #890B11; + color: #FFFFFF; +} + +.c2 { + position: relative; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + color: #5C5C5E; + width: 100%; +} + +.c4 { + margin-bottom: 0.5rem; +} + +.c6 { + position: relative; + box-sizing: border-box; + width: 100%; + height: 48px; + padding: 1rem 1.5rem; + background-color: #F4F3F5; + border: 1px solid; + border-color: #E1E2E3; + box-shadow: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + color: #000000; + border-radius: 0.5rem; + font-size: inherit; + z-index: 2; + font-family: 'Montserrat',Helvetica,Arial,sans-serif; +} + +.c6:focus { + border: 1px solid #666; +} + +.c5 { + position: relative; + font-size: 1.25rem; +} + +.c0 { + position: relative; +} + +.c8:disabled { + cursor: not-allowed; + opacity: 0.75; +} + +.c9 { + margin-top: 1.5rem; + color: white; + background-color: #E52630; + padding: 0 3rem; +} + +.c9:hover { + color: #FFFFFF; + background-color: #890B11; +} + +.c1 { + margin-bottom: 1rem; +} + +@media (min-width:740px) { + .c7 { + width: auto; + } +} + +@media (min-width:1024px) { + .c7 { + width: auto; + padding: 1rem 2rem; + margin: 0 auto 2rem; + } +} + +@media (min-width:740px) { + .c6 { + max-width: 290px; + } +} + +
+
+ +
+ +
, + .c0 { + margin: 1rem 0 0 0.5rem; + background: inherit; + border: none; + font-size: 1rem; +} + +.c0:hover, +.c0:active { + -webkit-text-decoration: underline; + text-decoration: underline; +} + +@media (min-width:740px) { + +} + +@media (min-width:1024px) { + +} + +@media (min-width:740px) { + +} + +, +] +`; diff --git a/src/components/Molecules/PostcodeLookup/utils/AddressInputs.js b/src/components/Molecules/PostcodeLookup/utils/AddressInputs.js new file mode 100644 index 000000000..7b3c35bf2 --- /dev/null +++ b/src/components/Molecules/PostcodeLookup/utils/AddressInputs.js @@ -0,0 +1,29 @@ +import styled from 'styled-components'; +import React from 'react'; +import PropTypes from 'prop-types'; +import Input from '../../../Atoms/Input/Input'; +import spacing from '../../../../theme/shared/spacing'; + +const Wrapper = styled.div` + margin-top: ${spacing('md')}; +`; + +export default function AddressInputs({ addressFields }) { + return ( + + + + + + + ); +} + +AddressInputs.propTypes = { + addressFields: PropTypes.shape({ + line1: PropTypes.string, + line2: PropTypes.string, + line3: PropTypes.string, + posttown: PropTypes.string + }).isRequired +}; diff --git a/src/components/Molecules/PostcodeLookup/utils/PostcodeFunctions.js b/src/components/Molecules/PostcodeLookup/utils/PostcodeFunctions.js new file mode 100644 index 000000000..a4f901199 --- /dev/null +++ b/src/components/Molecules/PostcodeLookup/utils/PostcodeFunctions.js @@ -0,0 +1,53 @@ +import { isValid, toNormalised } from 'postcode'; + +const validatePostcode = postcode => { + const trimmed = typeof postcode === 'string' ? postcode.trim() : ''; + return isValid(trimmed) && toNormalised(trimmed); +}; + +// report Error - Sentry function passed as a prop +const getAddresses = async (postcode, reportError) => { + const url = `https://lookups-staging.sls.comicrelief.com/postcode/lookup?query=${postcode}`; + const options = { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + timeout: 10000 + }; + + try { + const response = await fetch(url, options); + if (!response.ok) { + throw new Error('Network response was not ok'); + } + const data = await response.json(); + return data.addresses || []; + } catch (error) { + // Report the error to Sentry if available, or handle it locally + if (reportError) { + reportError(error); + } + throw new Error('Sorry, something unexpected went wrong. Please try again or enter your address manually'); + } +}; + +const addressToString = address => [address.Line1, address.Line2, address.Line3, address.posttown] + .filter(line => line) + .join(', '); + +const addressFetcher = async (postcode, reportError) => { + const valid = validatePostcode(postcode); + if (!valid) { + throw new Error('Please provide a valid postcode'); + } + try { + return await getAddresses(valid, reportError); + } catch (error) { + /* eslint-disable-next-line */ + console.error('Error fetching addresses:', error); + return []; + } +}; + +export { addressToString, addressFetcher }; diff --git a/src/index.js b/src/index.js index b9eb40aaa..dd805bcdf 100644 --- a/src/index.js +++ b/src/index.js @@ -60,6 +60,7 @@ export { default as Chip } from './components/Molecules/Chip/Chip'; export { default as Descriptor } from './components/Molecules/Descriptor/Descriptor'; export { default as Lookup } from './components/Molecules/Lookup/Lookup'; export { default as SimpleSchoolLookup } from './components/Molecules/SimpleSchoolLookup/SimpleSchoolLookup'; +export { default as PostcodeLookup } from './components/Molecules/PostcodeLookup/PostcodeLookup'; /* Organisms */ export { diff --git a/yarn.lock b/yarn.lock index 93ad96698..a70678b6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11097,6 +11097,11 @@ posix-character-classes@^0.1.0: resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg== +postcode@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/postcode/-/postcode-5.1.0.tgz#ef30a2a4028fd20255fb0aceccf4c70baab8df5b" + integrity sha512-rjwIdlQ8UvdOnUVZRCZQA54PmQJeoMBDQb4RsW5z3MVp/u7Gcet1vsjVy/p0+YX+R7cmgU9DEJf0zSx5mWqxAA== + postcss-attribute-case-insensitive@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-4.0.2.tgz#d93e46b504589e94ac7277b0463226c68041a880"