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);
+}