Skip to content

Commit

Permalink
Feat jalezi/sozial marie banner 466 (#474)
Browse files Browse the repository at this point in the history
* feat: ✨ adds useTimer custom hook for timer countdown

* feat: ✨ adds getTimeDifference function

Calculate time difference in days, hours, minutes, and seconds.

* feat: ✨ adds useLocalStorage hook

* feat: ✨ adds SozialMarie alert

* fix: 🚑️ timer does not restart on date change

* fix: 🚑️ timer resets to soon

* refactor: ♻️ creates VotingButton component

Add translations for "votingHasEnded" in en.json and sl.json

* refactor: 🌐 Capitalize start and end strings for SozialMarie

* refactor: ♻️ Add AlertCountDown component

* refactor: ♻️ adds SozialMarieLink component

* refactor: ♻️ adds AlertFooterContent component

* refactor: ♻️ replace days, hours, min and sec prop with time prop

* refactor: 🚚 rename file due to typo

* fix: 🐛 not perfect handling of negative time left value

* perf: ⚡️ use useCallback hook in SozialMarie component

* refactor: ♻️ no need to hold initial time in ref

* feat: ✨ show voting date range

* refactor: ♻️ adds AlertHeaderContent component

* perf: ⚡️ memoize AlertFooterContent

* refactor: ♻️ extract date range to separate file

* feat: ✨ adds variant prop to AlertCountDown component

* refactor: ♻️ handle voting expiration

* refactor: ♻️ SozialMarie component to use configurable delay values

* fix: 🐛 initial load does not respect localStorage

* refactor: 🚸 better remind-me/no-show label

* refactor: 🎨 extract "localStorage" related vars and func to separate file

* fix: 🚑️ safari & iOS does not support date format and breaks the page

new Date("YYYY-MM-DD HH:MM GMT+0200") is "Invalid Date"

* refactor: ⚰️ remove unused CountDown component

* fix: ♿️ wrong datetime attr value

use valid time duration format

* refactor: ♻️ adds time constants and import them in relevant files

* feat: ✨ adds FullCountDown component

* test: 🔧 adds mobile browser tests

* test: ✅ adds e2e test for Sozial Marie voting button

* refactor: ♻️ duplicate vars

* chore: 🧑‍💻 allow unused vars starting with "_"

* feat: ✨ trigger button for SozialMarie will render after specific date

The date is set to "Tue Apr 02 2024 00:00:00 GMT+0200"
For developing all the dates are configured based on now + delays.
env var (.env.development) REACT_APP_SM_SHOW_TRIGGER_BUTTON_IMMEDIATELY=false respects delays.
For testing set to true.
Don't have better solutions ATM.

* feat: ⚗️ show SozialMarie trigger immediately, not on/after specific date

* fix: 🚑️ translations prersist on locale change

if SM popup is open and user change locale, some translations are not applied due to memoization.

* fix: 🚑️ translations prersist on locale change

if SM popup is open and user change locale, some translations are not applied due to memoization.

* fix: 🐛 new voting date range and voting link

* fix: ⚰️ remove about SM text

* fix: 🐛 link always points to sl lang

---------

Co-authored-by: Štefan Baebler <[email protected]>
  • Loading branch information
jalezi and stefanb authored Apr 8, 2024
1 parent 92fccca commit 0d008e5
Show file tree
Hide file tree
Showing 24 changed files with 844 additions and 15 deletions.
2 changes: 2 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .env.production
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down
16 changes: 8 additions & 8 deletions playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
// {
Expand Down
2 changes: 2 additions & 0 deletions src/components/Header/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -68,6 +69,7 @@ const Header = function Header() {
>
<Icons.Icon name="Logo" style={{ height: '40px' }} />
</NavLink>
<SozialMarie />
<Styled.StackLarge ref={ref} id="nav-links" onClick={eventHandler}>
<NavLinks containerId="nav-links" />
</Styled.StackLarge>
Expand Down
65 changes: 65 additions & 0 deletions src/components/Shared/CountDown/index.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<time dateTime={timeDuration} aria-live="polite">
{d.padStart(2, '0')}:{h.padStart(2, '0')}:{m.padStart(2, '0')}:{s.padStart(2, '0')}
</time>
);
};

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 (
<time dateTime={timeDuration} aria-live="polite">
{value}
</time>
);
};

FullCountDown.propTypes = {
days: PropTypes.number.isRequired,
hours: PropTypes.number.isRequired,
minutes: PropTypes.number.isRequired,
seconds: PropTypes.number.isRequired,
};
2 changes: 1 addition & 1 deletion src/components/Shared/ExpandMore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => <IconButton {...other} />)(
const ExpandMoreButton = styled(({ _expand, ...other }) => <IconButton {...other} />)(
({ theme, expand }) => ({
transform: !expand ? 'rotate(0deg)' : 'rotate(180deg)',
marginLeft: 'auto',
Expand Down
23 changes: 23 additions & 0 deletions src/components/SozialMarie/AlertCountDown.jsx
Original file line number Diff line number Diff line change
@@ -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 <SimpleCountDown days={days} hours={hours} minutes={minutes} seconds={seconds} />;
}

return <FullCountDown days={days} hours={hours} minutes={minutes} seconds={seconds} />;
};

AlertCountDown.defaultProps = {
variant: 'simple',
};

AlertCountDown.propTypes = {
time: PropTypes.number.isRequired,
variant: PropTypes.oneOf(['simple', 'full']),
};

export default AlertCountDown;
43 changes: 43 additions & 0 deletions src/components/SozialMarie/AlertFooterContent.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<FormControlLabel
key={lang}
labelPlacement="start"
control={
<Checkbox name="no-show" checked={checked} onChange={handleChecked} size="small" />
}
label={label}
sx={{
marginInline: 0,
'& .MuiFormControlLabel-label': { fontSize: '0.875rem' },
}}
/>
<p>{sozialMarieTranslations.seeAlert}</p>
</>
);
};

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);
69 changes: 69 additions & 0 deletions src/components/SozialMarie/AlertHeaderContent.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Typography component="h2" fontWeight={600}>
{sozialMarieTranslations.title}
</Typography>

<Typography
component="time"
dateTime={`${intlDate.format(startDate)}-${intlDate.format(endDate)}`}
fontSize="0.875rem"
>
{dateRange}
</Typography>
</>
);
};

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);
26 changes: 26 additions & 0 deletions src/components/SozialMarie/SozialMarieLink.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<p>
{sozialMarieTranslations.clicking}{' '}
<a href={href} target="_blank" rel="noopener noreferrer">
{sozialMarieTranslations.thisLink}
</a>{' '}
{sozialMarieTranslations.inNewTab}
</p>
);
};

SozialMarieLink.defaultProps = {
href: '#',
};

SozialMarieLink.propTypes = {
href: PropTypes.string,
};

export default SozialMarieLink;
57 changes: 57 additions & 0 deletions src/components/SozialMarie/VotingButton.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip
title={
<Box display="flex" flexDirection="column" justifyContent="center" alignItems="center">
<span>
{isBeforeVoting ? `${sozialMarieTranslations.untilVotingStarts}:` : null}
{isVoting ? `${sozialMarieTranslations.untilVotingEnds}:` : null}
{isAfterVoting ? `${sozialMarieTranslations.votingHasEnded}!` : null}
</span>

{isAfterVoting ? null : (
<SimpleCountDown
date={date}
days={days}
hours={hours}
minutes={minutes}
seconds={seconds}
/>
)}
</Box>
}
>
<Button type="button" aria-label="vote" onClick={handleClick} color="inherit">
{sozialMarieTranslations.vote}!
</Button>
</Tooltip>
);
};

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;
24 changes: 24 additions & 0 deletions src/components/SozialMarie/date-range.js
Original file line number Diff line number Diff line change
@@ -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];
Loading

0 comments on commit 0d008e5

Please sign in to comment.