diff --git a/.env.development b/.env.development index 487b30f8..2e1a73bf 100644 --- a/.env.development +++ b/.env.development @@ -18,3 +18,5 @@ REACT_APP_GOOGLE_FORM_INPUT_PHONE=1724437941 REACT_APP_GOOGLE_FORM_INPUT_EMAIL= REACT_APP_GOOGLE_FORM_INPUT_ORDERFORM= REACT_APP_GOOGLE_FORM_INPUT_NOTE=1714314704 +# SozialMarie set to true when running tests +REACT_APP_SM_SHOW_TRIGGER_BUTTON_IMMEDIATELY=true diff --git a/.env.production b/.env.production index fcebe8ea..0533a795 100644 --- a/.env.production +++ b/.env.production @@ -17,3 +17,5 @@ REACT_APP_GOOGLE_FORM_INPUT_PHONE=1724437941 REACT_APP_GOOGLE_FORM_INPUT_EMAIL=1408261095 REACT_APP_GOOGLE_FORM_INPUT_ORDERFORM=1910910180 REACT_APP_GOOGLE_FORM_INPUT_NOTE=1714314704 +# SozialMarie, when going live (before 2024-4-2), change to false, otherwise it doesn't matter +REACT_APP_SM_SHOW_TRIGGER_BUTTON_IMMEDIATELY=true diff --git a/.eslintrc.js b/.eslintrc.js index caa9c85e..1db9ead2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -26,6 +26,14 @@ module.exports = { // @TODO: These should be turned "ON" one by one 'react/jsx-props-no-spreading': 'warn', + 'no-unused-vars': [ + 'error', // or "error" + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, settings: { 'import/resolver': { diff --git a/playwright.config.js b/playwright.config.js index bfb679e8..23ec9338 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -50,14 +50,14 @@ module.exports = defineConfig({ }, /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, /* Test against branded browsers. */ // { diff --git a/src/components/Header/index.js b/src/components/Header/index.js index 7ed76391..85fd5d72 100644 --- a/src/components/Header/index.js +++ b/src/components/Header/index.js @@ -4,6 +4,7 @@ import { IconButton, TextField, Toolbar } from '@mui/material'; import * as Icons from 'components/Shared/Icons'; import i18next, { languages } from 'i18n'; import { useFilter } from 'context/filterContext'; +import SozialMarie from 'components/SozialMarie'; import TemporaryDrawer from './Drawer'; import NavLinks from './NavLinks'; import SocialLinks from './SocialLinks'; @@ -68,6 +69,7 @@ const Header = function Header() { > + diff --git a/src/components/Shared/CountDown/index.jsx b/src/components/Shared/CountDown/index.jsx new file mode 100644 index 00000000..afe92ce2 --- /dev/null +++ b/src/components/Shared/CountDown/index.jsx @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import i18n from 'i18next'; +import { getTimeDurationAttrValue } from 'utils'; + +const INTL_LANGS = { + en: 'en-GB', + de: 'de-DE', + sl: 'sl-SI', + hr: 'hr-HR', + it: 'it-IT', + hu: 'hu-HU', +}; + +export const SimpleCountDown = function SimpleCountDown({ days, hours, minutes, seconds }) { + const d = days.toString(); + const h = hours.toString(); + const m = minutes.toString(); + const s = seconds.toString(); + + const timeDuration = getTimeDurationAttrValue({ days, hours, minutes, seconds }); + + return ( + + ); +}; + +SimpleCountDown.propTypes = { + days: PropTypes.number.isRequired, + hours: PropTypes.number.isRequired, + minutes: PropTypes.number.isRequired, + seconds: PropTypes.number.isRequired, +}; + +export const FullCountDown = function FullCountDown({ days, hours, minutes, seconds }) { + const rtf = new Intl.RelativeTimeFormat(INTL_LANGS[i18n.language], { + numeric: 'always', + style: 'narrow', + }); + + const daysParts = rtf.formatToParts(days, 'day'); + const hoursParts = rtf.formatToParts(hours, 'hour'); + const minutesParts = rtf.formatToParts(minutes, 'minute'); + const secondsParts = rtf.formatToParts(seconds, 'second'); + + const value = [daysParts, hoursParts, minutesParts, secondsParts] + .map(part => `${part[1].value} ${part[2].value}`) + .join(', '); + + const timeDuration = getTimeDurationAttrValue({ days, hours, minutes, seconds }); + + return ( + + ); +}; + +FullCountDown.propTypes = { + days: PropTypes.number.isRequired, + hours: PropTypes.number.isRequired, + minutes: PropTypes.number.isRequired, + seconds: PropTypes.number.isRequired, +}; diff --git a/src/components/Shared/ExpandMore.js b/src/components/Shared/ExpandMore.js index 3d5ea9a6..d3dd4b3a 100644 --- a/src/components/Shared/ExpandMore.js +++ b/src/components/Shared/ExpandMore.js @@ -3,7 +3,7 @@ import IconButton from '@mui/material/IconButton'; import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material'; import PropTypes from 'prop-types'; -const ExpandMoreButton = styled(({ expand, ...other }) => )( +const ExpandMoreButton = styled(({ _expand, ...other }) => )( ({ theme, expand }) => ({ transform: !expand ? 'rotate(0deg)' : 'rotate(180deg)', marginLeft: 'auto', diff --git a/src/components/SozialMarie/AlertCountDown.jsx b/src/components/SozialMarie/AlertCountDown.jsx new file mode 100644 index 00000000..417abda5 --- /dev/null +++ b/src/components/SozialMarie/AlertCountDown.jsx @@ -0,0 +1,23 @@ +import { FullCountDown, SimpleCountDown } from 'components/Shared/CountDown'; +import PropTypes from 'prop-types'; +import { getTimeDifference } from 'utils'; + +const AlertCountDown = function AlertCountDown({ time, variant = 'simple' }) { + const { days, hours, minutes, seconds } = getTimeDifference(time); + if (variant === 'simple') { + return ; + } + + return ; +}; + +AlertCountDown.defaultProps = { + variant: 'simple', +}; + +AlertCountDown.propTypes = { + time: PropTypes.number.isRequired, + variant: PropTypes.oneOf(['simple', 'full']), +}; + +export default AlertCountDown; diff --git a/src/components/SozialMarie/AlertFooterContent.jsx b/src/components/SozialMarie/AlertFooterContent.jsx new file mode 100644 index 00000000..78926177 --- /dev/null +++ b/src/components/SozialMarie/AlertFooterContent.jsx @@ -0,0 +1,43 @@ +import { FormControlLabel, Checkbox } from '@mui/material'; +import { t } from 'i18next'; +import PropTypes from 'prop-types'; +import { memo } from 'react'; + +const AlertFooterContent = function AlertFooter({ checked, handleChecked, isBefore, lang }) { + const sozialMarieTranslations = t('sozialMarie', { returnObjects: true }); + const label = isBefore + ? sozialMarieTranslations.noShowBefore + : sozialMarieTranslations.noShowDuring; + + return ( + <> + + } + label={label} + sx={{ + marginInline: 0, + '& .MuiFormControlLabel-label': { fontSize: '0.875rem' }, + }} + /> +

{sozialMarieTranslations.seeAlert}

+ + ); +}; + +AlertFooterContent.propTypes = { + checked: PropTypes.bool.isRequired, + handleChecked: PropTypes.func.isRequired, + isBefore: PropTypes.bool.isRequired, + lang: PropTypes.string.isRequired, +}; + +const areEqual = (prevProps, nextProps) => + prevProps.checked === nextProps.checked && + prevProps.isBefore === nextProps.isBefore && + prevProps.lang === nextProps.lang; + +export default memo(AlertFooterContent, areEqual); diff --git a/src/components/SozialMarie/AlertHeaderContent.jsx b/src/components/SozialMarie/AlertHeaderContent.jsx new file mode 100644 index 00000000..bf00ae87 --- /dev/null +++ b/src/components/SozialMarie/AlertHeaderContent.jsx @@ -0,0 +1,69 @@ +import { Typography } from '@mui/material'; +import { t } from 'i18next'; +import PropTypes from 'prop-types'; +import { memo } from 'react'; + +import { ONE_DAY_IN_MILLISECONDS } from 'const/time'; + +const INTL_LANGS = { + en: 'en-GB', + de: 'de-DE', + sl: 'sl-SI', + hr: 'hr-HR', + it: 'it-IT', + hu: 'hu-HU', +}; + +function getIntlFormatOptions(dateRangeInMilliseconds) { + if (dateRangeInMilliseconds > ONE_DAY_IN_MILLISECONDS) { + return { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }; + } + + // for dev purposes + return { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }; +} + +const AlertContentHeader = function AlertContentHeader({ endDate, startDate, lang }) { + const sozialMarieTranslations = t('sozialMarie', { returnObjects: true }); + const intlDate = Intl.DateTimeFormat(INTL_LANGS[lang], getIntlFormatOptions(endDate - startDate)); + + const dateRange = intlDate.formatRange(startDate, endDate); + + return ( + <> + + {sozialMarieTranslations.title} + + + + {dateRange} + + + ); +}; + +AlertContentHeader.propTypes = { + endDate: PropTypes.instanceOf(Date).isRequired, + startDate: PropTypes.instanceOf(Date).isRequired, + lang: PropTypes.string.isRequired, +}; + +const areEqual = (prev, next) => + prev.endDate === next.endDate && prev.startDate === next.startDate && prev.lang === next.lang; +export default memo(AlertContentHeader, areEqual); diff --git a/src/components/SozialMarie/SozialMarieLink.jsx b/src/components/SozialMarie/SozialMarieLink.jsx new file mode 100644 index 00000000..938b4120 --- /dev/null +++ b/src/components/SozialMarie/SozialMarieLink.jsx @@ -0,0 +1,26 @@ +import { t } from 'i18next'; +import PropTypes from 'prop-types'; + +const SozialMarieLink = function SozialMarieLink({ href }) { + const sozialMarieTranslations = t('sozialMarie', { returnObjects: true }); + + return ( +

+ {sozialMarieTranslations.clicking}{' '} + + {sozialMarieTranslations.thisLink} + {' '} + {sozialMarieTranslations.inNewTab} +

+ ); +}; + +SozialMarieLink.defaultProps = { + href: '#', +}; + +SozialMarieLink.propTypes = { + href: PropTypes.string, +}; + +export default SozialMarieLink; diff --git a/src/components/SozialMarie/VotingButton.jsx b/src/components/SozialMarie/VotingButton.jsx new file mode 100644 index 00000000..26be1a59 --- /dev/null +++ b/src/components/SozialMarie/VotingButton.jsx @@ -0,0 +1,57 @@ +import { Box, Button, Tooltip } from '@mui/material'; +import { SimpleCountDown } from 'components/Shared/CountDown'; +import { t } from 'i18next'; +import PropTypes from 'prop-types'; +import { getTimeDifference } from 'utils'; + +const VotingButton = function VotingButton({ + date, + handleClick, + isBeforeVoting, + isVoting, + isAfterVoting, + time, +}) { + const { days, hours, minutes, seconds } = getTimeDifference(time); + + const sozialMarieTranslations = t('sozialMarie', { returnObjects: true }); + + return ( + + + {isBeforeVoting ? `${sozialMarieTranslations.untilVotingStarts}:` : null} + {isVoting ? `${sozialMarieTranslations.untilVotingEnds}:` : null} + {isAfterVoting ? `${sozialMarieTranslations.votingHasEnded}!` : null} + + + {isAfterVoting ? null : ( + + )} + + } + > + + + ); +}; + +VotingButton.propTypes = { + date: PropTypes.instanceOf(Date).isRequired, + handleClick: PropTypes.func.isRequired, + isBeforeVoting: PropTypes.bool.isRequired, + isVoting: PropTypes.bool.isRequired, + isAfterVoting: PropTypes.bool.isRequired, + time: PropTypes.number.isRequired, +}; + +export default VotingButton; diff --git a/src/components/SozialMarie/date-range.js b/src/components/SozialMarie/date-range.js new file mode 100644 index 00000000..afdf6c40 --- /dev/null +++ b/src/components/SozialMarie/date-range.js @@ -0,0 +1,24 @@ +import { ONE_SECOND_MILLISECONDS } from '../../const/time'; +import { getDevVotingDateRange } from './getDevVotingDateRange'; + +// Safari and iOS don't support the date format 'YYYY-MM-DD HH:MM GMT+0200' https://www.coditty.com/code/javascript-new-date-not-working-on-ie-and-safari +const SM_VOTING_STARTS = 'Tue Apr 08 2024 08:00:00 GMT+0200'; +const SM_VOTING_ENDS = 'Wed Apr 15 2024 23:55:00 GMT+0200'; +const SM_DO_NOT_SHOW_BEFORE = 'Tue Apr 02 2024 00:00:00 GMT+0200'; + +const delayToVotingStart = ONE_SECOND_MILLISECONDS * 5; +const votingTime = ONE_SECOND_MILLISECONDS * 30; +// Test for SozialMarie will fail if this delay is too big. +// For testing purposes set env variable REACT_APP_SM_SHOW_TRIGGER_BUTTON_IMMEDIATELY to true +// It will show the button immediately and you can test the button functionality. +const delayToNotShowBefore = ONE_SECOND_MILLISECONDS * 10; + +const now = new Date(new Date().setMilliseconds(0)); +const dateRange = + process.env.NODE_ENV === 'development' + ? getDevVotingDateRange(now, delayToVotingStart, votingTime, delayToNotShowBefore) + : [new Date(SM_VOTING_STARTS), new Date(SM_VOTING_ENDS), new Date(SM_DO_NOT_SHOW_BEFORE)]; + +export const startDate = dateRange[0]; +export const endDate = dateRange[1]; +export const doNotShowBefore = dateRange[2]; diff --git a/src/components/SozialMarie/getDevVotingDateRange.js b/src/components/SozialMarie/getDevVotingDateRange.js new file mode 100644 index 00000000..46d930d1 --- /dev/null +++ b/src/components/SozialMarie/getDevVotingDateRange.js @@ -0,0 +1,25 @@ +import { addMilliseconds } from 'utils'; + +export function getDevVotingDateRange( + now = new Date(), + startDelay = 5000, + addToEndDelay = 5000, + doNotShowBeforeDelay = 5000, +) { + if (!(now instanceof Date)) { + throw new TypeError('The now parameter must be a Date object.'); + } + + if (typeof startDelay !== 'number' || startDelay < 0) { + throw new TypeError('The startDelay parameter must be a non-negative number.'); + } + + if (typeof addToEndDelay !== 'number' || addToEndDelay < 0) { + throw new TypeError('The endDelay parameter must be a non-negative number.'); + } + const noShow = addMilliseconds(now, doNotShowBeforeDelay); + const starts = addMilliseconds(noShow, startDelay); + const ends = addMilliseconds(starts, addToEndDelay); + + return [starts, ends, noShow]; +} diff --git a/src/components/SozialMarie/index.jsx b/src/components/SozialMarie/index.jsx new file mode 100644 index 00000000..8fd939da --- /dev/null +++ b/src/components/SozialMarie/index.jsx @@ -0,0 +1,207 @@ +import useTimer from 'hooks/useTimer'; +import { Alert, Box, Divider, Snackbar, Stack } from '@mui/material'; +import { useLocalStorage } from 'hooks'; +import { useEffect, useState, useCallback, useRef } from 'react'; +import i18n, { t } from 'i18next'; +import VotingButton from './VotingButton'; +import AlertCountDown from './AlertCountDown'; +import SozialMarieLink from './SozialMarieLink'; +import AlertFooterContent from './AlertFooterContent'; +import AlertContentHeader from './AlertHeaderContent'; +import { startDate, endDate, doNotShowBefore } from './date-range'; +import { LOCAL_STORAGE_KEY, LOCAL_STORAGE_VALUES, getInitialIsShow } from './localStorage'; + +const DELAY_TO_HIDE_ALERT = 5000; +const DELAY_TO_HIDE_TRIGGER = 6000; + +if (DELAY_TO_HIDE_ALERT > DELAY_TO_HIDE_TRIGGER) { + throw new Error('DELAY_TO_HIDE_ALERT should be less than DELAY_TO_HIDE_TRIGGER'); +} + +const SozialMarieBase = function SozialMarieBase() { + const SOZIAL_MARIE_LINK = `https://www.sozialmarie.org/${i18n.language === 'it' ? 'en' : i18n.language}/projects/9280/`; + const currentDate = new Date(); + const countDownDate = currentDate < startDate ? startDate : endDate; + const isVoting = currentDate >= startDate && currentDate < endDate; + const isBefore = currentDate < startDate; + const isAfter = currentDate >= endDate; + const roundedInitialTime = Math.floor((countDownDate - currentDate) / 1000) * 1000; + const [timeLeft, setTimeLeft] = useTimer(roundedInitialTime); + + const [localStorageVal, updateLocalstorageVal] = useLocalStorage(LOCAL_STORAGE_KEY, 'first'); + const isShow = getInitialIsShow(localStorageVal, { isBefore, isVoting }); + const [open, setOpen] = useState(isShow); + const [noShowChecked, setNoShowChecked] = useState(!isShow); + + const [votingExpired, setVotingExpired] = useState(false); + + const sozialMarieTranslations = t('sozialMarie', { returnObjects: true }); + + if (isVoting && localStorageVal === LOCAL_STORAGE_VALUES.remindMe) { + updateLocalstorageVal(LOCAL_STORAGE_VALUES.noShow); + } + + useEffect(() => { + if (isVoting) { + setTimeLeft(Math.floor((endDate - new Date()) / 1000) * 1000); + } + }, [isVoting, timeLeft, setTimeLeft]); + + useEffect(() => { + let timeoutId; + if (isAfter) { + timeoutId = setTimeout(() => { + const hasItem = !!localStorage.getItem(LOCAL_STORAGE_KEY); + if (hasItem) { + localStorage.removeItem(LOCAL_STORAGE_KEY); + } + setOpen(false); + }, DELAY_TO_HIDE_ALERT); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [isAfter]); + + useEffect(() => { + let timeoutId; + if (isAfter) { + timeoutId = setTimeout(() => { + setVotingExpired(true); + }, DELAY_TO_HIDE_TRIGGER); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [isAfter]); + + const handleClick = useCallback(() => { + setOpen(true); + }, []); + + const handleClose = (_event, reason) => { + if (reason === 'clickaway') { + return; + } + + setOpen(false); + }; + + const handleChecked = useCallback( + e => { + const localstorageCheckedValue = isBefore + ? LOCAL_STORAGE_VALUES.remindMe + : LOCAL_STORAGE_VALUES.noShow; + updateLocalstorageVal( + e.target.checked ? localstorageCheckedValue : LOCAL_STORAGE_VALUES.show, + ); + setNoShowChecked(e.target.checked); + }, + [updateLocalstorageVal, isBefore], + ); + + if (votingExpired) { + return null; + } + + return ( + + + + + + + + + + + {sozialMarieTranslations.votingFor} + {isBefore ? ` ${sozialMarieTranslations.start}` : null} + {isVoting ? ` ${sozialMarieTranslations.end}` : null}: + + + {isAfter ? ( + `${sozialMarieTranslations.votingHasEnded}!` + ) : ( + + )} + + {isAfter ? null : ( + <> + + + + + + + )} + + + + ); +}; + +const envRespectDates = process.env?.REACT_APP_SM_SHOW_TRIGGER_BUTTON_IMMEDIATELY; +const donNotRespectDates = envRespectDates ? JSON.parse(envRespectDates) : false; + +const SozialMarie = function SozialMarie() { + const now = new Date(); + + const [showSozialMarie, setShowSozialMarie] = useState( + (doNotShowBefore < now && now < endDate) || Boolean(donNotRespectDates), + ); + + const timeoutIdRef = useRef(); + + useEffect(() => { + const timeoutId = timeoutIdRef?.current; + if (timeoutId) { + clearTimeout(timeoutId); + } + if (!showSozialMarie) { + timeoutIdRef.current = setTimeout(() => { + setShowSozialMarie(true); + }, doNotShowBefore - new Date()); + } + return () => { + clearTimeout(timeoutId); + }; + }, [showSozialMarie]); + return showSozialMarie ? : null; +}; + +export default SozialMarie; diff --git a/src/components/SozialMarie/localStorage.js b/src/components/SozialMarie/localStorage.js new file mode 100644 index 00000000..120a0f58 --- /dev/null +++ b/src/components/SozialMarie/localStorage.js @@ -0,0 +1,58 @@ +export const LOCAL_STORAGE_KEY = 'showSozialMarie'; + +/** + * @typedef {Object} LocalStorageValues + * @property {"first"} first - The value for the 'first' key in local storage. + * @property {"show"} show - The value for the 'show' key in local storage. + * @property {"remind-me"} remindMe - The value for the 'remind-me' key in local storage. + * @property {"no-show"} noShow - The value for the 'no-show' key in local storage. + */ + +/** @type {LocalStorageValues} */ +export const LOCAL_STORAGE_VALUES = { + first: 'first', + show: 'show', + remindMe: 'remind-me', + noShow: 'no-show', +}; + +/** + * @typedef {LocalStorageValues[keyof LocalStorageValues]} LocalStorageValue + */ + +/** + * Determines the initial value of the "isShow" flag based on the provided value and options. + * + * Always returns true if the value is "first" or "show". + * + * Before voting starts, returns false if the value is "remind-me" or "no-show". + * + * During voting, returns false if the value is "no-show" and true if the value is "remind-me". + * + * Othervise returns false. + * + * @param {LocalStorageValue} value - The value to check against. + * @param {Object} options - The options object. + * @param {boolean} options.isBefore - Indicates if it is before a certain event. + * @param {boolean} options.isVoting - Indicates if it is during a voting period. + * @returns {boolean} - The initial value of the "isShow" flag. + */ +export function getInitialIsShow(value, { isBefore, isVoting }) { + if ([LOCAL_STORAGE_VALUES.first, LOCAL_STORAGE_VALUES.show].includes(value)) { + return true; + } + + if (isBefore && [LOCAL_STORAGE_VALUES.remindMe, LOCAL_STORAGE_VALUES.noShow].includes(value)) { + return false; + } + + if (isVoting && value === LOCAL_STORAGE_VALUES.noShow) { + return false; + } + + if (isVoting && value === LOCAL_STORAGE_VALUES.remindMe) { + return true; + } + + return false; +} diff --git a/src/const/time.js b/src/const/time.js new file mode 100644 index 00000000..dfbb5907 --- /dev/null +++ b/src/const/time.js @@ -0,0 +1,4 @@ +export const ONE_DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000; +export const ONE_HOUR_IN_MILLISECONDS = 60 * 60 * 1000; +export const ONE_MINUTE_IN_MILLISECONDS = 60 * 1000; +export const ONE_SECOND_MILLISECONDS = 1000; diff --git a/src/hooks/index.js b/src/hooks/index.js index 6359d5ce..a6472a16 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -3,3 +3,5 @@ export { default as useTimeout } from './useTimeout'; export { default as useDebounce } from './useDebounce'; export { default as useGeoLocation } from './useGeoLocation'; export { default as useEventListener } from './useEventListener'; +export { default as useTimer } from './useTimer'; +export { default as useLocalStorage } from './useLocalStorage'; diff --git a/src/hooks/useLocalStorage.js b/src/hooks/useLocalStorage.js new file mode 100644 index 00000000..2ca33d7a --- /dev/null +++ b/src/hooks/useLocalStorage.js @@ -0,0 +1,26 @@ +import { useState } from 'react'; + +/** + * Custom hook to store and retrieve values in local storage. + * + * @param {string} key - The key to use for storing the value in local storage. + * @param {*} initialValue - The initial value to use if no value is found in local storage. + * @returns {Array} - An array containing the current value and a function to update the value. + */ +function useLocalStorage(key, initialValue) { + // Get stored value from local storage or use initial value + const storedValue = JSON.parse(localStorage.getItem(key)) || initialValue; + + // State to hold the current value + const [value, setValue] = useState(storedValue); + + // Update local storage and state when the value changes + const updateValue = newValue => { + setValue(newValue); + localStorage.setItem(key, JSON.stringify(newValue)); + }; + + return [value, updateValue]; +} + +export default useLocalStorage; diff --git a/src/hooks/useTimer.js b/src/hooks/useTimer.js new file mode 100644 index 00000000..b8121f0a --- /dev/null +++ b/src/hooks/useTimer.js @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Custom hook for a timer. + * + * @param {number} initialTime - The initial time for the timer in milliseconds. + * @returns {number} - The time left in milliseconds. + */ +export default function useTimer(initialTime) { + const [timeLeft, setTimeLeft] = useState(initialTime); + + const intervalIdRef = useRef(null); + + const isTimeLeftValid = timeLeft >= 0; + + useEffect(() => { + let intervalId = intervalIdRef.current; + const handleTimer = () => { + setTimeLeft(prevTimeLeft => prevTimeLeft - 1000); + }; + + intervalId = isTimeLeftValid ? setInterval(handleTimer, 1000) : null; + + return () => { + if (intervalId) { + clearInterval(intervalId); + intervalIdRef.current = null; + } + }; + }, [isTimeLeftValid]); + + if (timeLeft < 0) { + clearInterval(intervalIdRef.current); + } + + return [timeLeft, setTimeLeft]; +} diff --git a/src/locales/en.json b/src/locales/en.json index 64d5719a..be7291ce 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -99,7 +99,37 @@ "datetime": "{{val, datetime}}", "at": "at" }, - "sozialmarie": { - "description": "The Doctors Tracker portal was created to bring the healthcare system closer to the patient. In Slovenia, we are facing a shortage of doctors at primary level. This leaves many patients without the basic right of access to a personal GP. In the Scientific association Tracker, we imported the data that was publicly available every 15 days in a spreadsheet that was not user friendly into our portal and allowed users to suggest corrections for faulty data in real time. This gave patients seeking primary care user-friendly and up-to-date information." + "sozialMarie": { + "title": "SozialMarie Award", + "description": "The Doctors Tracker portal was created to bring the healthcare system closer to the patient. In Slovenia, we are facing a shortage of doctors at primary level. This leaves many patients without the basic right of access to a personal GP. In the Scientific association Tracker, we imported the data that was publicly available every 15 days in a spreadsheet that was not user friendly into our portal and allowed users to suggest corrections for faulty data in real time. This gave patients seeking primary care user-friendly and up-to-date information.", + "start": "STARTS in", + "end": "ENDS in", + "votingFor": "Voting for the SozialMarie award", + "and": "and", + "noShowBefore": "Don't show this again until the voting starts", + "noShowDuring": "Don't show this message again", + "clicking": "By clicking on", + "thisLink": "this link", + "inNewTab": "you will be redirected to voting page. Page will open in a new tab.", + "seeAlert": "Clicking on button \"Vote!\" you can see this message.", + "vote": "Vote", + "untilVotingStarts": "until voting starts", + "untilVotingEnds": "until voting ends", + "aboutSozialMarie": "some info about SozialMarie", + "votingHasEnded": "Voting has ended" + }, + "time": { + "day_one": "day", + "day_two": "days", + "day_other": "days", + "hour_one": "hour", + "hour_two": "hours", + "hour_other": "hours", + "minute_one": "minute", + "minute_two": "minutes", + "minute_other": "minutes", + "second_one": "second", + "second_two": "seconds", + "second_other": "seconds" } -} +} \ No newline at end of file diff --git a/src/locales/sl.json b/src/locales/sl.json index c472a4b0..54a5127b 100644 --- a/src/locales/sl.json +++ b/src/locales/sl.json @@ -101,7 +101,41 @@ "datetime": "{{val, datetime}}", "at": "ob" }, - "sozialmarie": { - "description": "Portal Zdravniki Sledilnik je bil ustvarjen z namenom približevanja osrkbe k pacientu. V Sloveniji se soočamo s pomankanjem zdravnikov na primarnem nivoju. Veliko pacientov je zaradi tega brez osnovne pravice dostopa do opredelitve osebnega zdravnika. V Znanstvenem društvu Sledilnik smo podatke, ki so se javno objavljali na 15 dni v nepregledni razpredelnici uvozili v naš portal ter omogočali uporabnikom, da podatke posodabljajo v realnem času. Tako smo pacientom, ki so iskali oskrbo na primarnem nivoju omogočili prijazno uporabniško informacijo ter ažurne informacije." + "sozialMarie": { + "title": "SozialMarie nagrada", + "description": "Portal Zdravniki Sledilnik je bil ustvarjen z namenom približevanja oskrbe k pacientu. V Sloveniji se soočamo s pomanjkanjem zdravnikov na primarnem nivoju. Veliko pacientov je zaradi tega brez osnovne pravice dostopa do opredelitve osebnega zdravnika. V Znanstvenem društvu Sledilnik smo podatke, ki so se javno objavljali na 15 dni v nepregledni razpredelnici uvozili v naš portal ter omogočali uporabnikom, da podatke posodabljajo v realnem času. Tako smo pacientom, ki so iskali oskrbo na primarnem nivoju omogočili prijazno uporabniško informacijo ter ažurne informacije.", + "start": "PRIČNE čez", + "end": "KONČA čez", + "votingFor": "Glasovanje za SozialMarie nagrado se", + "and": "in", + "noShowBefore": "Ne pokaži tega obvestila do začetka glasovanje", + "noShowDuring": "Ne pokaži več tega obvestila", + "clicking": "S klikom na", + "thisLink": "to povezavo", + "inNewTab": "boste preusmerjeni na stran za glasovanje. Stran se bo odprla v novem zavihku.", + "seeAlert": "S klikom na na gumb \"Glasuj!\" lahko vedno vidite to obvestilo.", + "vote": "Glasuj", + "untilVotingStarts": "do začetka glasovanja", + "untilVotingEnds": "do konca glasovanja", + "aboutSozialMarie": "info o nagradi SozialMarie", + "votingHasEnded": "Glasovanje je končano!" + }, + "time": { + "day_one": "dan", + "day_two": "dneva", + "day_few": "dni", + "day_other": "dni", + "hour_one": "uro", + "hour_two": "uri", + "hour_few": "ure", + "hour_other": "ur", + "minute_one": "minuta", + "minute_two": "minuti", + "minute_few": "minute", + "minute_other": "minut", + "second_one": "sekunda", + "second_two": "sekundi", + "second_few": "sekunde", + "second_other": "sekund" } -} +} \ No newline at end of file diff --git a/src/tests/e2e/sozial-marie.spec.js b/src/tests/e2e/sozial-marie.spec.js new file mode 100644 index 00000000..6e3a9489 --- /dev/null +++ b/src/tests/e2e/sozial-marie.spec.js @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; + +// This date and delay are hardcoded in the app and should be updated manually +// from src/components/SozialMarie/date-range.js +const SM_VOTING_ENDS = 'Wed Apr 17 2024 00:00:00 GMT+0200'; +const delayToNotShowBefore = 0; + +test.describe('Sozial Marie', () => { + const votingEnds = new Date(SM_VOTING_ENDS); + const doNotRunAnyTests = new Date() > votingEnds; + test.skip(doNotRunAnyTests, 'Voting is expired'); + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test.describe('Voting', () => { + // https://playwright.dev/docs/test-annotations#conditionally-skip-a-test + test.skip(new Date() > votingEnds, 'Voting is expired'); + test('has voting button', async ({ page }) => { + await page.reload(); + await page.waitForTimeout(delayToNotShowBefore); + await expect(page.getByLabel('vote')).toBeVisible(); + }); + }); +}); diff --git a/src/utils/index.js b/src/utils/index.js index 05021f83..279ca8b7 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,6 +1,13 @@ import L from 'leaflet'; import { v4 as uuidv4 } from 'uuid'; +import { + ONE_DAY_IN_MILLISECONDS, + ONE_HOUR_IN_MILLISECONDS, + ONE_MINUTE_IN_MILLISECONDS, + ONE_SECOND_MILLISECONDS, +} from 'const/time'; + function normalize(value) { // Replace all non ASCII chars and replace them with closest equivalent (č => c) return value @@ -74,3 +81,56 @@ export function filterBySearchValueInMapBounds({ searchValue = '', filtered = [] ); }); } + +/** + * @typedef {Object} TimeDifference + * @property {number} days - The time difference in days. + * @property {number} hours - The time difference in hours. + * @property {number} minutes - The time difference in minutes. + * @property {number} seconds - The time difference in seconds. + */ + +/** + * Calculates the time difference in days, hours, minutes, and seconds. + * + * @param {number} diff - The time difference in milliseconds. + * @returns {TimeDifference} - An object containing the time difference in days, hours, minutes, and seconds. + */ +export function getTimeDifference(diff) { + const diffAbs = Math.abs(diff); + + const days = Math.floor(diffAbs / ONE_DAY_IN_MILLISECONDS); + const hours = Math.floor((diffAbs % ONE_DAY_IN_MILLISECONDS) / ONE_HOUR_IN_MILLISECONDS); + const minutes = Math.floor((diffAbs % ONE_HOUR_IN_MILLISECONDS) / ONE_MINUTE_IN_MILLISECONDS); + const seconds = Math.floor((diffAbs % ONE_MINUTE_IN_MILLISECONDS) / ONE_SECOND_MILLISECONDS); + return { days, hours, minutes, seconds }; +} + +/** + * Returns a string representing the time duration for datetime attr in time html tag. + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/time + * @see https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-duration-string + * + * @param {Object} duration - The duration object. + * @param {number} duration.days - The number of days. + * @param {number} duration.hours - The number of hours. + * @param {number} duration.minutes - The number of minutes. + * @param {number} duration.seconds - The number of seconds. + * @returns {string} The time duration in ISO 8601 format. + */ +export function getTimeDurationAttrValue({ days, hours, minutes, seconds }) { + if (days > 0) { + return `P${days}DT${hours}H${minutes}M${seconds}S`; + } + if (hours > 0) { + return `PT${hours}H${minutes}M${seconds}S`; + } + if (minutes > 0) { + return `PT${minutes}M${seconds}S`; + } + return `PT${seconds}S`; +} + +export function addMilliseconds(date, ms) { + return new Date(date.getTime() + ms); +}