Merge pull request #76 from GRA0007/dev

Logo update, small qol fixes
This commit is contained in:
Benjamin Grant 2021-06-19 13:18:57 +10:00 committed by GitHub
commit 872ee1a4b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 2638 additions and 2516 deletions

View file

@ -7,3 +7,7 @@ cron:
url: /tasks/legacyCleanup url: /tasks/legacyCleanup
schedule: every tuesday 09:00 schedule: every tuesday 09:00
target: api target: api
- description: "remove people with an event id that no longer exists"
url: /tasks/removeOrphans
schedule: 1st wednesday of month 09:00
target: api

View file

@ -16,6 +16,7 @@ const updatePerson = require('./routes/updatePerson');
const taskCleanup = require('./routes/taskCleanup'); const taskCleanup = require('./routes/taskCleanup');
const taskLegacyCleanup = require('./routes/taskLegacyCleanup'); const taskLegacyCleanup = require('./routes/taskLegacyCleanup');
const taskRemoveOrphans = require('./routes/taskRemoveOrphans');
const app = express(); const app = express();
const port = 8080; const port = 8080;
@ -53,6 +54,7 @@ app.patch('/event/:eventId/people/:personName', updatePerson);
// Tasks // Tasks
app.get('/tasks/cleanup', taskCleanup); app.get('/tasks/cleanup', taskCleanup);
app.get('/tasks/legacyCleanup', taskLegacyCleanup); app.get('/tasks/legacyCleanup', taskLegacyCleanup);
app.get('/tasks/removeOrphans', taskRemoveOrphans);
app.listen(port, () => { app.listen(port, () => {
console.log(`Crabfit API listening at http://localhost:${port} in ${process.env.NODE_ENV === 'production' ? 'prod' : 'dev'} mode`) console.log(`Crabfit API listening at http://localhost:${port} in ${process.env.NODE_ENV === 'production' ? 'prod' : 'dev'} mode`)

View file

@ -1,6 +1,10 @@
const dayjs = require('dayjs'); const dayjs = require('dayjs');
module.exports = async (req, res) => { module.exports = async (req, res) => {
if (req.header('X-Appengine-Cron') === undefined) {
return res.status(400).send('This task can only be run from a cron job');
}
const threeMonthsAgo = dayjs().subtract(3, 'month').unix(); const threeMonthsAgo = dayjs().subtract(3, 'month').unix();
console.log(`Running cleanup task at ${dayjs().format('h:mma D MMM YYYY')}`); console.log(`Running cleanup task at ${dayjs().format('h:mma D MMM YYYY')}`);

View file

@ -1,6 +1,10 @@
const dayjs = require('dayjs'); const dayjs = require('dayjs');
module.exports = async (req, res) => { module.exports = async (req, res) => {
if (req.header('X-Appengine-Cron') === undefined) {
return res.status(400).send('This task can only be run from a cron job');
}
const threeMonthsAgo = dayjs().subtract(3, 'month').unix(); const threeMonthsAgo = dayjs().subtract(3, 'month').unix();
console.log(`Running LEGACY cleanup task at ${dayjs().format('h:mma D MMM YYYY')}`); console.log(`Running LEGACY cleanup task at ${dayjs().format('h:mma D MMM YYYY')}`);

View file

@ -0,0 +1,46 @@
const dayjs = require('dayjs');
module.exports = async (req, res) => {
if (req.header('X-Appengine-Cron') === undefined) {
return res.status(400).send('This task can only be run from a cron job');
}
const threeMonthsAgo = dayjs().subtract(3, 'month').unix();
console.log(`Running orphan removal task at ${dayjs().format('h:mma D MMM YYYY')}`);
try {
// Fetch people that are older than 3 months
const peopleQuery = req.datastore.createQuery(req.types.person).filter('created', '<', threeMonthsAgo);
let oldPeople = (await req.datastore.runQuery(peopleQuery))[0];
if (oldPeople && oldPeople.length > 0) {
console.log(`Found ${oldPeople.length} people older than 3 months, checking for events`);
// Fetch events linked to the people discovered
let peopleWithoutEvents = 0;
await Promise.all(oldPeople.map(async (person) => {
let event = (await req.datastore.get(req.datastore.key([req.types.event, person.eventId])))[0];
if (!event) {
peopleWithoutEvents++;
await req.datastore.delete(person[req.datastore.KEY]);
}
}));
if (peopleWithoutEvents > 0) {
console.log(`Orphan removal successful: ${oldEventIds.length} events and ${peopleDiscovered} people removed`);
res.sendStatus(200);
} else {
console.log(`Found 0 people without events, ending orphan removal`);
res.sendStatus(404);
}
} else {
console.log(`Found 0 people older than 3 months, ending orphan removal`);
res.sendStatus(404);
}
} catch (e) {
console.error(e);
res.sendStatus(404);
}
};

View file

@ -243,3 +243,16 @@ paths:
description: "Not found" description: "Not found"
400: 400:
description: "Not called from a cron job" description: "Not called from a cron job"
"/tasks/removeOrphans":
get:
summary: "Deletes people if the event they were created under no longer exists"
operationId: "taskRemoveOrphans"
tags:
- tasks
responses:
200:
description: "OK"
404:
description: "Not found"
400:
description: "Not called from a cron job"

View file

@ -1,7 +1,7 @@
{ {
"name": "Crab Fit", "name": "Crab Fit",
"description": "Enter your availability to find a time that works for everyone!", "description": "Enter your availability to find a time that works for everyone!",
"version": "1.1", "version": "1.2",
"manifest_version": 2, "manifest_version": 2,
"author": "Ben Grant", "author": "Ben Grant",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 B

After

Width:  |  Height:  |  Size: 416 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 841 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View file

@ -1,7 +1,7 @@
@font-face { @font-face {
font-family: Karla; font-family: Karla;
src: url('fonts/karla-variable.ttf') format('truetype'); src: url('fonts/karla-variable.ttf') format('truetype');
font-weight: 1 999; font-weight: 1 999;
} }
@font-face { @font-face {

View file

@ -5,10 +5,10 @@
<link rel="icon" href="%PUBLIC_URL%/favicon.ico"> <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="theme-color" content="#F79E00"> <meta name="theme-color" content="#F79E00">
<meta <meta
name="keywords" name="keywords"
content="crab, fit, crabfit, schedule, availability, availabilities, when2meet, doodle, meet, plan, time, timezone" content="crab, fit, crabfit, schedule, availability, availabilities, when2meet, doodle, meet, plan, time, timezone"
> >
<meta <meta
name="description" name="description"
content="Enter your availability to find a time that works for everyone!" content="Enter your availability to find a time that works for everyone!"
@ -17,11 +17,11 @@
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png"> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json"> <link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<meta property="og:title" content="Crab Fit"> <meta property="og:title" content="Crab Fit">
<meta property="og:description" content="Enter your availability to find a time that works for everyone!"> <meta property="og:description" content="Enter your availability to find a time that works for everyone!">
<meta property="og:url" content="https://crab.fit"> <meta property="og:url" content="https://crab.fit">
<link rel="stylesheet" href="%PUBLIC_URL%/index.css"> <link rel="stylesheet" href="%PUBLIC_URL%/index.css">
<title>Crab Fit</title> <title>Crab Fit</title>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -20,8 +20,8 @@ const wb = new Workbox('sw.js');
const App = () => { const App = () => {
const colortheme = useSettingsStore(state => state.theme); const colortheme = useSettingsStore(state => state.theme);
const darkQuery = window.matchMedia('(prefers-color-scheme: dark)'); const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
const [isDark, setIsDark] = useState(darkQuery.matches); const [isDark, setIsDark] = useState(darkQuery.matches);
const [offline, setOffline] = useState(!window.navigator.onLine); const [offline, setOffline] = useState(!window.navigator.onLine);
const [eggCount, setEggCount] = useState(0); const [eggCount, setEggCount] = useState(0);
@ -46,7 +46,7 @@ const App = () => {
[eggCount, eggKey] [eggCount, eggKey]
); );
darkQuery.addListener(e => colortheme === 'System' && setIsDark(e.matches)); darkQuery.addListener(e => colortheme === 'System' && setIsDark(e.matches));
useEffect(() => { useEffect(() => {
const onOffline = () => setOffline(true); const onOffline = () => setOffline(true);
@ -87,56 +87,57 @@ const App = () => {
}, [colortheme, darkQuery.matches]); }, [colortheme, darkQuery.matches]);
return ( return (
<BrowserRouter> <BrowserRouter>
<ThemeProvider theme={theme[isDark ? 'dark' : 'light']}> <ThemeProvider theme={theme[isDark ? 'dark' : 'light']}>
<Global <Global
styles={theme => ({ styles={theme => ({
html: { html: {
scrollBehavior: 'smooth', scrollBehavior: 'smooth',
}, '-webkit-print-color-adjust': 'exact',
body: { },
backgroundColor: theme.background, body: {
color: theme.text, backgroundColor: theme.background,
fontFamily: `'Karla', sans-serif`, color: theme.text,
fontWeight: theme.mode === 'dark' ? 500 : 600, fontFamily: `'Karla', sans-serif`,
margin: 0, fontWeight: theme.mode === 'dark' ? 500 : 600,
}, margin: 0,
a: { },
color: theme.primary, a: {
}, color: theme.primary,
'*::-webkit-scrollbar': { },
width: 16, '*::-webkit-scrollbar': {
height: 16, width: 16,
}, height: 16,
'*::-webkit-scrollbar-track': { },
background: `${theme.primaryBackground}`, '*::-webkit-scrollbar-track': {
}, background: `${theme.primaryBackground}`,
'*::-webkit-scrollbar-thumb': { },
borderRadius: 100, '*::-webkit-scrollbar-thumb': {
border: `4px solid ${theme.primaryBackground}`, borderRadius: 100,
width: 12, border: `4px solid ${theme.primaryBackground}`,
background: `${theme.mode === 'light' ? theme.primaryLight : theme.primaryDark}AA`, width: 12,
}, background: `${theme.mode === 'light' ? theme.primaryLight : theme.primaryDark}AA`,
'*::-webkit-scrollbar-thumb:hover': { },
background: `${theme.mode === 'light' ? theme.primaryLight : theme.primaryDark}CC`, '*::-webkit-scrollbar-thumb:hover': {
}, background: `${theme.mode === 'light' ? theme.primaryLight : theme.primaryDark}CC`,
'*::-webkit-scrollbar-thumb:active': { },
background: `${theme.mode === 'light' ? theme.primaryLight : theme.primaryDark}`, '*::-webkit-scrollbar-thumb:active': {
}, background: `${theme.mode === 'light' ? theme.primaryLight : theme.primaryDark}`,
})} },
/> })}
/>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<Settings /> <Settings />
</Suspense> </Suspense>
<Switch> <Switch>
<Route path="/" exact render={props => ( <Route path="/" exact render={props => (
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<Home offline={offline} {...props} /> <Home offline={offline} {...props} />
</Suspense> </Suspense>
)} /> )} />
<Route path="/how-to" exact render={props => ( <Route path="/how-to" exact render={props => (
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<Help {...props} /> <Help {...props} />
</Suspense> </Suspense>
@ -146,17 +147,17 @@ const App = () => {
<Privacy {...props} /> <Privacy {...props} />
</Suspense> </Suspense>
)} /> )} />
<Route path="/create" exact render={props => ( <Route path="/create" exact render={props => (
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<Create offline={offline} {...props} /> <Create offline={offline} {...props} />
</Suspense> </Suspense>
)} /> )} />
<Route path="/:id" exact render={props => ( <Route path="/:id" exact render={props => (
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<Event offline={offline} {...props} /> <Event offline={offline} {...props} />
</Suspense> </Suspense>
)} /> )} />
</Switch> </Switch>
{updateAvailable && ( {updateAvailable && (
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
@ -165,8 +166,8 @@ const App = () => {
)} )}
{eggVisible && <Egg eggKey={eggKey} onClose={() => setEggVisible(false)} />} {eggVisible && <Egg eggKey={eggKey} onClose={() => setEggVisible(false)} />}
</ThemeProvider> </ThemeProvider>
</BrowserRouter> </BrowserRouter>
); );
} }

View file

@ -9,17 +9,17 @@ import dayjs_timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import { import {
Wrapper, Wrapper,
ScrollWrapper, ScrollWrapper,
Container, Container,
Date, Date,
Times, Times,
DateLabel, DateLabel,
DayLabel, DayLabel,
Spacer, Spacer,
TimeLabels, TimeLabels,
TimeLabel, TimeLabel,
TimeSpace, TimeSpace,
StyledMain, StyledMain,
} from 'components/AvailabilityViewer/availabilityViewerStyle'; } from 'components/AvailabilityViewer/availabilityViewerStyle';
import { Time } from './availabilityEditorStyle'; import { Time } from './availabilityEditorStyle';
@ -37,34 +37,34 @@ dayjs.extend(utc);
dayjs.extend(dayjs_timezone); dayjs.extend(dayjs_timezone);
const AvailabilityEditor = ({ const AvailabilityEditor = ({
times, times,
timeLabels, timeLabels,
dates, dates,
timezone, timezone,
isSpecificDates, isSpecificDates,
value = [], value = [],
onChange, onChange,
...props ...props
}) => { }) => {
const { t } = useTranslation('event'); const { t } = useTranslation('event');
const locale = useLocaleUpdateStore(state => state.locale); const locale = useLocaleUpdateStore(state => state.locale);
const [selectingTimes, _setSelectingTimes] = useState([]); const [selectingTimes, _setSelectingTimes] = useState([]);
const staticSelectingTimes = useRef([]); const staticSelectingTimes = useRef([]);
const setSelectingTimes = newTimes => { const setSelectingTimes = newTimes => {
staticSelectingTimes.current = newTimes; staticSelectingTimes.current = newTimes;
_setSelectingTimes(newTimes); _setSelectingTimes(newTimes);
}; };
const startPos = useRef({}); const startPos = useRef({});
const staticMode = useRef(null); const staticMode = useRef(null);
const [mode, _setMode] = useState(staticMode.current); const [mode, _setMode] = useState(staticMode.current);
const setMode = newMode => { const setMode = newMode => {
staticMode.current = newMode; staticMode.current = newMode;
_setMode(newMode); _setMode(newMode);
}; };
return ( return (
<> <>
<StyledMain> <StyledMain>
<Center style={{textAlign: 'center'}}>{t('event:you.info')}</Center> <Center style={{textAlign: 'center'}}>{t('event:you.info')}</Center>
@ -98,89 +98,89 @@ const AvailabilityEditor = ({
</StyledMain> </StyledMain>
)} )}
<Wrapper locale={locale}> <Wrapper locale={locale}>
<ScrollWrapper> <ScrollWrapper>
<Container> <Container>
<TimeLabels> <TimeLabels>
{!!timeLabels.length && timeLabels.map((label, i) => {!!timeLabels.length && timeLabels.map((label, i) =>
<TimeSpace key={i}> <TimeSpace key={i}>
{label.label?.length !== '' && <TimeLabel>{label.label}</TimeLabel>} {label.label?.length !== '' && <TimeLabel>{label.label}</TimeLabel>}
</TimeSpace> </TimeSpace>
)} )}
</TimeLabels> </TimeLabels>
{dates.map((date, x) => { {dates.map((date, x) => {
const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date); const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date);
const last = dates.length === x+1 || (isSpecificDates ? dayjs(dates[x+1], 'DDMMYYYY') : dayjs().day(dates[x+1])).diff(parsedDate, 'day') > 1; const last = dates.length === x+1 || (isSpecificDates ? dayjs(dates[x+1], 'DDMMYYYY') : dayjs().day(dates[x+1])).diff(parsedDate, 'day') > 1;
return ( return (
<Fragment key={x}> <Fragment key={x}>
<Date> <Date>
{isSpecificDates && <DateLabel>{parsedDate.format('MMM D')}</DateLabel>} {isSpecificDates && <DateLabel>{parsedDate.format('MMM D')}</DateLabel>}
<DayLabel>{parsedDate.format('ddd')}</DayLabel> <DayLabel>{parsedDate.format('ddd')}</DayLabel>
<Times <Times
borderRight={last} borderRight={last}
borderLeft={x === 0 || (parsedDate).diff(isSpecificDates ? dayjs(dates[x-1], 'DDMMYYYY') : dayjs().day(dates[x-1]), 'day') > 1} borderLeft={x === 0 || (parsedDate).diff(isSpecificDates ? dayjs(dates[x-1], 'DDMMYYYY') : dayjs().day(dates[x-1]), 'day') > 1}
> >
{timeLabels.map((timeLabel, y) => { {timeLabels.map((timeLabel, y) => {
if (!timeLabel.time) return null; if (!timeLabel.time) return null;
if (!times.includes(`${timeLabel.time}-${date}`)) { if (!times.includes(`${timeLabel.time}-${date}`)) {
return ( return (
<TimeSpace key={x+y} className='timespace' title={t('event:greyed_times')} /> <TimeSpace key={x+y} className='timespace' title={t('event:greyed_times')} />
); );
} }
const time = `${timeLabel.time}-${date}`; const time = `${timeLabel.time}-${date}`;
return ( return (
<Time <Time
key={x+y} key={x+y}
time={time} time={time}
className="time" className="time"
selected={value.includes(time)} selected={value.includes(time)}
selecting={selectingTimes.includes(time)} selecting={selectingTimes.includes(time)}
mode={mode} mode={mode}
onPointerDown={(e) => { onPointerDown={(e) => {
e.preventDefault(); e.preventDefault();
startPos.current = {x, y}; startPos.current = {x, y};
setMode(value.includes(time) ? 'remove' : 'add'); setMode(value.includes(time) ? 'remove' : 'add');
setSelectingTimes([time]); setSelectingTimes([time]);
e.currentTarget.releasePointerCapture(e.pointerId); e.currentTarget.releasePointerCapture(e.pointerId);
document.addEventListener('pointerup', () => { document.addEventListener('pointerup', () => {
if (staticMode.current === 'add') { if (staticMode.current === 'add') {
onChange([...value, ...staticSelectingTimes.current]); onChange([...value, ...staticSelectingTimes.current]);
} else if (staticMode.current === 'remove') { } else if (staticMode.current === 'remove') {
onChange(value.filter(t => !staticSelectingTimes.current.includes(t))); onChange(value.filter(t => !staticSelectingTimes.current.includes(t)));
} }
setMode(null); setMode(null);
}, { once: true }); }, { once: true });
}} }}
onPointerEnter={() => { onPointerEnter={() => {
if (staticMode.current) { if (staticMode.current) {
let found = []; let found = [];
for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y)+1; cy++) { for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y)+1; cy++) {
for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x)+1; cx++) { for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x)+1; cx++) {
found.push({y: cy, x: cx}); found.push({y: cy, x: cx});
} }
} }
setSelectingTimes(found.filter(d => timeLabels[d.y].time?.length === 4).map(d => `${timeLabels[d.y].time}-${dates[d.x]}`)); setSelectingTimes(found.filter(d => timeLabels[d.y].time?.length === 4).map(d => `${timeLabels[d.y].time}-${dates[d.x]}`));
} }
}} }}
/> />
); );
})} })}
</Times> </Times>
</Date> </Date>
{last && dates.length !== x+1 && ( {last && dates.length !== x+1 && (
<Spacer /> <Spacer />
)} )}
</Fragment> </Fragment>
); );
})} })}
</Container> </Container>
</ScrollWrapper> </ScrollWrapper>
</Wrapper> </Wrapper>
</> </>
); );
}; };
export default AvailabilityEditor; export default AvailabilityEditor;

View file

@ -1,24 +1,24 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const Time = styled.div` export const Time = styled.div`
height: 10px; height: 10px;
touch-action: none; touch-action: none;
transition: background-color .1s; transition: background-color .1s;
${props => props.time.slice(2, 4) === '00' && ` ${props => props.time.slice(2, 4) === '00' && `
border-top: 2px solid ${props.theme.text}; border-top: 2px solid ${props.theme.text};
`} `}
${props => props.time.slice(2, 4) !== '00' && ` ${props => props.time.slice(2, 4) !== '00' && `
border-top: 2px solid transparent; border-top: 2px solid transparent;
`} `}
${props => props.time.slice(2, 4) === '30' && ` ${props => props.time.slice(2, 4) === '30' && `
border-top: 2px dotted ${props.theme.text}; border-top: 2px dotted ${props.theme.text};
`} `}
${props => (props.selected || (props.mode === 'add' && props.selecting)) && ` ${props => (props.selected || (props.mode === 'add' && props.selecting)) && `
background-color: ${props.theme.primary}; background-color: ${props.theme.primary};
`}; `};
${props => props.mode === 'remove' && props.selecting && ` ${props => props.mode === 'remove' && props.selecting && `
background-color: ${props.theme.background}; background-color: ${props.theme.background};
`}; `};
`; `;

View file

@ -7,28 +7,29 @@ import relativeTime from 'dayjs/plugin/relativeTime';
import { useSettingsStore, useLocaleUpdateStore } from 'stores'; import { useSettingsStore, useLocaleUpdateStore } from 'stores';
import { Legend, Center } from 'components'; import { Legend } from 'components';
import { import {
Wrapper, Wrapper,
ScrollWrapper, ScrollWrapper,
Container, Container,
Date, Date,
Times, Times,
DateLabel, DateLabel,
DayLabel, DayLabel,
Time, Time,
Spacer, Spacer,
Tooltip, Tooltip,
TooltipTitle, TooltipTitle,
TooltipDate, TooltipDate,
TooltipContent, TooltipContent,
TooltipPerson, TooltipPerson,
TimeLabels, TimeLabels,
TimeLabel, TimeLabel,
TimeSpace, TimeSpace,
People, People,
Person, Person,
StyledMain, StyledMain,
Info,
} from './availabilityViewerStyle'; } from './availabilityViewerStyle';
import locales from 'res/dayjs_locales'; import locales from 'res/dayjs_locales';
@ -38,16 +39,16 @@ dayjs.extend(customParseFormat);
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
const AvailabilityViewer = ({ const AvailabilityViewer = ({
times, times,
timeLabels, timeLabels,
dates, dates,
isSpecificDates, isSpecificDates,
people = [], people = [],
min = 0, min = 0,
max = 0, max = 0,
...props ...props
}) => { }) => {
const [tooltip, setTooltip] = useState(null); const [tooltip, setTooltip] = useState(null);
const timeFormat = useSettingsStore(state => state.timeFormat); const timeFormat = useSettingsStore(state => state.timeFormat);
const highlight = useSettingsStore(state => state.highlight); const highlight = useSettingsStore(state => state.highlight);
const [filteredPeople, setFilteredPeople] = useState([]); const [filteredPeople, setFilteredPeople] = useState([]);
@ -153,7 +154,7 @@ const AvailabilityViewer = ({
times, times,
]); ]);
return ( return (
<> <>
<StyledMain> <StyledMain>
<Legend <Legend
@ -162,10 +163,10 @@ const AvailabilityViewer = ({
total={people.filter(p => p.availability.length > 0).length} total={people.filter(p => p.availability.length > 0).length}
onSegmentFocus={count => setFocusCount(count)} onSegmentFocus={count => setFocusCount(count)}
/> />
<Center style={{textAlign: 'center'}}>{t('event:group.info1')}</Center> <Info>{t('event:group.info1')}</Info>
{people.length > 1 && ( {people.length > 1 && (
<> <>
<Center style={{textAlign: 'center'}}>{t('event:group.info2')}</Center> <Info>{t('event:group.info2')}</Info>
<People> <People>
{people.map((person, i) => {people.map((person, i) =>
<Person <Person
@ -194,19 +195,19 @@ const AvailabilityViewer = ({
)} )}
</StyledMain> </StyledMain>
<Wrapper ref={wrapper}> <Wrapper ref={wrapper}>
<ScrollWrapper> <ScrollWrapper>
{heatmap} {heatmap}
{tooltip && ( {tooltip && (
<Tooltip <Tooltip
x={tooltip.x} x={tooltip.x}
y={tooltip.y} y={tooltip.y}
> >
<TooltipTitle>{tooltip.available}</TooltipTitle> <TooltipTitle>{tooltip.available}</TooltipTitle>
<TooltipDate>{tooltip.date}</TooltipDate> <TooltipDate>{tooltip.date}</TooltipDate>
{!!filteredPeople.length && ( {!!filteredPeople.length && (
<TooltipContent> <TooltipContent>
{tooltip.people.map(person => {tooltip.people.map(person =>
<TooltipPerson key={person}>{person}</TooltipPerson> <TooltipPerson key={person}>{person}</TooltipPerson>
)} )}
@ -215,12 +216,12 @@ const AvailabilityViewer = ({
)} )}
</TooltipContent> </TooltipContent>
)} )}
</Tooltip> </Tooltip>
)} )}
</ScrollWrapper> </ScrollWrapper>
</Wrapper> </Wrapper>
</> </>
); );
}; };
export default AvailabilityViewer; export default AvailabilityViewer;

View file

@ -1,8 +1,8 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const Wrapper = styled.div` export const Wrapper = styled.div`
overflow-y: visible; overflow-y: visible;
margin: 20px 0; margin: 20px 0;
position: relative; position: relative;
`; `;
@ -11,30 +11,30 @@ export const ScrollWrapper = styled.div`
`; `;
export const Container = styled.div` export const Container = styled.div`
display: inline-flex; display: inline-flex;
box-sizing: border-box; box-sizing: border-box;
min-width: 100%; min-width: 100%;
align-items: flex-end; align-items: flex-end;
justify-content: center; justify-content: center;
padding: 0 calc(calc(100% - 600px) / 2); padding: 0 calc(calc(100% - 600px) / 2);
@media (max-width: 660px) { @media (max-width: 660px) {
padding: 0 30px; padding: 0 30px;
} }
`; `;
export const Date = styled.div` export const Date = styled.div`
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 60px; width: 60px;
min-width: 60px; min-width: 60px;
margin-bottom: 10px; margin-bottom: 10px;
`; `;
export const Times = styled.div` export const Times = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-bottom: 2px solid ${props => props.theme.text}; border-bottom: 2px solid ${props => props.theme.text};
border-left: 1px solid ${props => props.theme.text}; border-left: 1px solid ${props => props.theme.text};
@ -57,44 +57,44 @@ export const Times = styled.div`
`; `;
export const DateLabel = styled.label` export const DateLabel = styled.label`
display: block; display: block;
font-size: 12px; font-size: 12px;
text-align: center; text-align: center;
user-select: none; user-select: none;
`; `;
export const DayLabel = styled.label` export const DayLabel = styled.label`
display: block; display: block;
font-size: 15px; font-size: 15px;
text-align: center; text-align: center;
user-select: none; user-select: none;
`; `;
export const Time = styled.div` export const Time = styled.div`
height: 10px; height: 10px;
background-origin: border-box; background-origin: border-box;
transition: background-color .1s; transition: background-color .1s;
${props => props.time.slice(2, 4) === '00' && ` ${props => props.time.slice(2, 4) === '00' && `
border-top: 2px solid ${props.theme.text}; border-top: 2px solid ${props.theme.text};
`} `}
${props => props.time.slice(2, 4) !== '00' && ` ${props => props.time.slice(2, 4) !== '00' && `
border-top: 2px solid transparent; border-top: 2px solid transparent;
`} `}
${props => props.time.slice(2, 4) === '30' && ` ${props => props.time.slice(2, 4) === '30' && `
border-top: 2px dotted ${props.theme.text}; border-top: 2px dotted ${props.theme.text};
`} `}
background-color: ${props => `${props.theme.primary}${Math.round((props.peopleCount/props.maxPeople)*255).toString(16)}`}; background-color: ${props => `${props.theme.primary}${Math.round((props.peopleCount/props.maxPeople)*255).toString(16)}`};
${props => props.highlight && props.peopleCount === props.maxPeople && props.peopleCount > 0 && ` ${props => props.highlight && props.peopleCount === props.maxPeople && props.peopleCount > 0 && `
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
45deg, 45deg,
transparent, transparent,
transparent 4.3px, transparent 4.3px,
${props.theme.primaryDark} 4.3px, ${props.theme.primaryDark} 4.3px,
${props.theme.primaryDark} 8.6px ${props.theme.primaryDark} 8.6px
); );
`} `}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@ -103,40 +103,40 @@ export const Time = styled.div`
`; `;
export const Spacer = styled.div` export const Spacer = styled.div`
width: 12px; width: 12px;
flex-shrink: 0; flex-shrink: 0;
`; `;
export const Tooltip = styled.div` export const Tooltip = styled.div`
position: absolute; position: absolute;
top: ${props => props.y}px; top: ${props => props.y}px;
left: ${props => props.x}px; left: ${props => props.x}px;
transform: translateX(-50%); transform: translateX(-50%);
border: 1px solid ${props => props.theme.text}; border: 1px solid ${props => props.theme.text};
border-radius: 3px; border-radius: 3px;
padding: 4px 8px; padding: 4px 8px;
background-color: ${props => props.theme.background}${props => props.theme.mode === 'light' ? 'EE' : 'DD'}; background-color: ${props => props.theme.background}${props => props.theme.mode === 'light' ? 'EE' : 'DD'};
max-width: 200px; max-width: 200px;
pointer-events: none; pointer-events: none;
z-index: 100; z-index: 100;
user-select: none; user-select: none;
`; `;
export const TooltipTitle = styled.span` export const TooltipTitle = styled.span`
font-size: 15px; font-size: 15px;
display: block; display: block;
font-weight: 700; font-weight: 700;
`; `;
export const TooltipDate = styled.span` export const TooltipDate = styled.span`
font-size: 13px; font-size: 13px;
display: block; display: block;
opacity: .8; opacity: .8;
font-weight: 600; font-weight: 600;
`; `;
export const TooltipContent = styled.div` export const TooltipContent = styled.div`
font-size: 13px; font-size: 13px;
padding: 4px 0; padding: 4px 0;
`; `;
@ -154,38 +154,38 @@ export const TooltipPerson = styled.span`
`; `;
export const TimeLabels = styled.div` export const TimeLabels = styled.div`
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 40px; width: 40px;
padding-right: 6px; padding-right: 6px;
`; `;
export const TimeSpace = styled.div` export const TimeSpace = styled.div`
height: 10px; height: 10px;
position: relative; position: relative;
border-top: 2px solid transparent; border-top: 2px solid transparent;
&.timespace { &.timespace {
background-origin: border-box; background-origin: border-box;
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
45deg, 45deg,
transparent, transparent,
transparent 4.3px, transparent 4.3px,
${props => props.theme.loading} 4.3px, ${props => props.theme.loading} 4.3px,
${props => props.theme.loading} 8.6px ${props => props.theme.loading} 8.6px
); );
} }
`; `;
export const TimeLabel = styled.label` export const TimeLabel = styled.label`
display: block; display: block;
position: absolute; position: absolute;
top: -.7em; top: -.7em;
font-size: 12px; font-size: 12px;
text-align: right; text-align: right;
user-select: none; user-select: none;
width: 100%; width: 100%;
`; `;
export const StyledMain = styled.div` export const StyledMain = styled.div`
@ -220,3 +220,12 @@ export const Person = styled.button`
border-color: ${props.theme.primary}; border-color: ${props.theme.primary};
`} `}
`; `;
export const Info = styled.span`
display: block;
text-align: center;
@media print {
display: none;
}
`;

View file

@ -1,7 +1,7 @@
import { Pressable } from './buttonStyle'; import { Pressable } from './buttonStyle';
const Button = ({ href, type = 'button', icon, children, ...props }) => ( const Button = ({ href, type = 'button', icon, children, ...props }) => (
<Pressable <Pressable
type={type} type={type}
as={href ? 'a' : 'button'} as={href ? 'a' : 'button'}
href={href} href={href}

View file

@ -60,34 +60,34 @@ export const Pressable = styled.button`
} }
${props => props.isLoading && ` ${props => props.isLoading && `
color: transparent; color: transparent;
cursor: wait; cursor: wait;
& img { & img {
opacity: 0; opacity: 0;
} }
@keyframes load { @keyframes load {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
} }
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
&:after { &:after {
content: ''; content: '';
position: absolute; position: absolute;
top: calc(50% - 12px); top: calc(50% - 12px);
left: calc(50% - 12px); left: calc(50% - 12px);
height: 18px; height: 18px;
width: 18px; width: 18px;
border: 3px solid ${props.primaryColor ? '#FFF' : props.theme.background}; border: 3px solid ${props.primaryColor ? '#FFF' : props.theme.background};
border-left-color: transparent; border-left-color: transparent;
border-radius: 100px; border-radius: 100px;
animation: load .5s linear infinite; animation: load .5s linear infinite;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
&:after { &:after {
@ -106,7 +106,7 @@ export const Pressable = styled.button`
justify-content: center; justify-content: center;
} }
} }
`} `}
${props => props.secondary && ` ${props => props.secondary && `
background: transparent; background: transparent;
@ -121,4 +121,14 @@ export const Pressable = styled.button`
transform: none; transform: none;
} }
`} `}
@media print {
${props => !props.secondary && `
box-shadow: 0 4px 0 0 ${props.secondaryColor || props.theme.primaryDark};
`}
&::before {
display: none;
}
}
`; `;

View file

@ -9,14 +9,14 @@ import { Button, ToggleField } from 'components';
import { useSettingsStore, useLocaleUpdateStore } from 'stores'; import { useSettingsStore, useLocaleUpdateStore } from 'stores';
import { import {
Wrapper, Wrapper,
StyledLabel, StyledLabel,
StyledSubLabel, StyledSubLabel,
CalendarHeader, CalendarHeader,
CalendarDays, CalendarDays,
CalendarBody, CalendarBody,
Date, Date,
Day, Day,
} from './calendarFieldStyle'; } from './calendarFieldStyle';
dayjs.extend(isToday); dayjs.extend(isToday);
@ -24,90 +24,90 @@ dayjs.extend(localeData);
dayjs.extend(updateLocale); dayjs.extend(updateLocale);
const calculateMonth = (month, year, weekStart) => { const calculateMonth = (month, year, weekStart) => {
const date = dayjs().month(month).year(year); const date = dayjs().month(month).year(year);
const daysInMonth = date.daysInMonth(); const daysInMonth = date.daysInMonth();
const daysBefore = date.date(1).day() - weekStart; const daysBefore = date.date(1).day() - weekStart;
const daysAfter = 6 - date.date(daysInMonth).day() + weekStart; const daysAfter = 6 - date.date(daysInMonth).day() + weekStart;
let dates = []; let dates = [];
let curDate = date.date(1).subtract(daysBefore, 'day'); let curDate = date.date(1).subtract(daysBefore, 'day');
let y = 0; let y = 0;
let x = 0; let x = 0;
for (let i = 0; i < daysBefore + daysInMonth + daysAfter; i++) { for (let i = 0; i < daysBefore + daysInMonth + daysAfter; i++) {
if (x === 0) dates[y] = []; if (x === 0) dates[y] = [];
dates[y][x] = curDate.clone(); dates[y][x] = curDate.clone();
curDate = curDate.add(1, 'day'); curDate = curDate.add(1, 'day');
x++; x++;
if (x > 6) { if (x > 6) {
x = 0; x = 0;
y++; y++;
} }
} }
return dates; return dates;
}; };
const CalendarField = forwardRef(({ const CalendarField = forwardRef(({
label, label,
subLabel, subLabel,
id, id,
setValue, setValue,
...props ...props
}, ref) => { }, ref) => {
const weekStart = useSettingsStore(state => state.weekStart); const weekStart = useSettingsStore(state => state.weekStart);
const locale = useLocaleUpdateStore(state => state.locale); const locale = useLocaleUpdateStore(state => state.locale);
const { t } = useTranslation('home'); const { t } = useTranslation('home');
const [type, setType] = useState(0); const [type, setType] = useState(0);
const [dates, setDates] = useState(calculateMonth(dayjs().month(), dayjs().year(), weekStart)); const [dates, setDates] = useState(calculateMonth(dayjs().month(), dayjs().year(), weekStart));
const [month, setMonth] = useState(dayjs().month()); const [month, setMonth] = useState(dayjs().month());
const [year, setYear] = useState(dayjs().year()); const [year, setYear] = useState(dayjs().year());
const [selectedDates, setSelectedDates] = useState([]); const [selectedDates, setSelectedDates] = useState([]);
const [selectingDates, _setSelectingDates] = useState([]); const [selectingDates, _setSelectingDates] = useState([]);
const staticSelectingDates = useRef([]); const staticSelectingDates = useRef([]);
const setSelectingDates = newDates => { const setSelectingDates = newDates => {
staticSelectingDates.current = newDates; staticSelectingDates.current = newDates;
_setSelectingDates(newDates); _setSelectingDates(newDates);
}; };
const [selectedDays, setSelectedDays] = useState([]); const [selectedDays, setSelectedDays] = useState([]);
const [selectingDays, _setSelectingDays] = useState([]); const [selectingDays, _setSelectingDays] = useState([]);
const staticSelectingDays = useRef([]); const staticSelectingDays = useRef([]);
const setSelectingDays = newDays => { const setSelectingDays = newDays => {
staticSelectingDays.current = newDays; staticSelectingDays.current = newDays;
_setSelectingDays(newDays); _setSelectingDays(newDays);
}; };
const startPos = useRef({}); const startPos = useRef({});
const staticMode = useRef(null); const staticMode = useRef(null);
const [mode, _setMode] = useState(staticMode.current); const [mode, _setMode] = useState(staticMode.current);
const setMode = newMode => { const setMode = newMode => {
staticMode.current = newMode; staticMode.current = newMode;
_setMode(newMode); _setMode(newMode);
}; };
useEffect(() => setValue(props.name, type ? JSON.stringify(selectedDays) : JSON.stringify(selectedDates)), [type, selectedDays, selectedDates, setValue, props.name]); useEffect(() => setValue(props.name, type ? JSON.stringify(selectedDays) : JSON.stringify(selectedDates)), [type, selectedDays, selectedDates, setValue, props.name]);
useEffect(() => { useEffect(() => {
if (dayjs.Ls.hasOwnProperty(locale) && weekStart !== dayjs.Ls[locale].weekStart) { if (dayjs.Ls.hasOwnProperty(locale) && weekStart !== dayjs.Ls[locale].weekStart) {
dayjs.updateLocale(locale, { weekStart }); dayjs.updateLocale(locale, { weekStart });
} }
setDates(calculateMonth(month, year, weekStart)); setDates(calculateMonth(month, year, weekStart));
}, [weekStart, month, year, locale]); }, [weekStart, month, year, locale]);
return ( return (
<Wrapper locale={locale}> <Wrapper locale={locale}>
{label && <StyledLabel htmlFor={id}>{label}</StyledLabel>} {label && <StyledLabel htmlFor={id}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>} {subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<input <input
id={id} id={id}
type="hidden" type="hidden"
ref={ref} ref={ref}
value={type ? JSON.stringify(selectedDays) : JSON.stringify(selectedDates)} value={type ? JSON.stringify(selectedDays) : JSON.stringify(selectedDates)}
{...props} {...props}
/> />
<ToggleField <ToggleField
id="calendarMode" id="calendarMode"
@ -122,50 +122,50 @@ const CalendarField = forwardRef(({
{type === 0 ? ( {type === 0 ? (
<> <>
<CalendarHeader> <CalendarHeader>
<Button <Button
size="30px" size="30px"
title={t('form.dates.tooltips.previous')} title={t('form.dates.tooltips.previous')}
onClick={() => { onClick={() => {
if (month-1 < 0) { if (month-1 < 0) {
setYear(year-1); setYear(year-1);
setMonth(11); setMonth(11);
} else { } else {
setMonth(month-1); setMonth(month-1);
} }
}} }}
>&lt;</Button> >&lt;</Button>
<span>{dayjs.months()[month]} {year}</span> <span>{dayjs.months()[month]} {year}</span>
<Button <Button
size="30px" size="30px"
title={t('form.dates.tooltips.next')} title={t('form.dates.tooltips.next')}
onClick={() => { onClick={() => {
if (month+1 > 11) { if (month+1 > 11) {
setYear(year+1); setYear(year+1);
setMonth(0); setMonth(0);
} else { } else {
setMonth(month+1); setMonth(month+1);
} }
}} }}
>&gt;</Button> >&gt;</Button>
</CalendarHeader> </CalendarHeader>
<CalendarDays> <CalendarDays>
{(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort()).map(name => {(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort()).map(name =>
<Day key={name}>{name}</Day> <Day key={name}>{name}</Day>
)} )}
</CalendarDays> </CalendarDays>
<CalendarBody> <CalendarBody>
{dates.length > 0 && dates.map((dateRow, y) => {dates.length > 0 && dates.map((dateRow, y) =>
dateRow.map((date, x) => dateRow.map((date, x) =>
<Date <Date
key={y+x} key={y+x}
otherMonth={date.month() !== month} otherMonth={date.month() !== month}
isToday={date.isToday()} isToday={date.isToday()}
title={`${date.date()} ${dayjs.months()[date.month()]}${date.isToday() ? ` (${t('form.dates.tooltips.today')})` : ''}`} title={`${date.date()} ${dayjs.months()[date.month()]}${date.isToday() ? ` (${t('form.dates.tooltips.today')})` : ''}`}
selected={selectedDates.includes(date.format('DDMMYYYY'))} selected={selectedDates.includes(date.format('DDMMYYYY'))}
selecting={selectingDates.includes(date)} selecting={selectingDates.includes(date)}
mode={mode} mode={mode}
type="button" type="button"
onKeyPress={e => { onKeyPress={e => {
if (e.key === ' ' || e.key === 'Enter') { if (e.key === ' ' || e.key === 'Enter') {
@ -176,37 +176,37 @@ const CalendarField = forwardRef(({
} }
} }
}} }}
onPointerDown={e => { onPointerDown={e => {
startPos.current = {x, y}; startPos.current = {x, y};
setMode(selectedDates.includes(date.format('DDMMYYYY')) ? 'remove' : 'add'); setMode(selectedDates.includes(date.format('DDMMYYYY')) ? 'remove' : 'add');
setSelectingDates([date]); setSelectingDates([date]);
e.currentTarget.releasePointerCapture(e.pointerId); e.currentTarget.releasePointerCapture(e.pointerId);
document.addEventListener('pointerup', () => { document.addEventListener('pointerup', () => {
if (staticMode.current === 'add') { if (staticMode.current === 'add') {
setSelectedDates([...selectedDates, ...staticSelectingDates.current.map(d => d.format('DDMMYYYY'))]); setSelectedDates([...selectedDates, ...staticSelectingDates.current.map(d => d.format('DDMMYYYY'))]);
} else if (staticMode.current === 'remove') { } else if (staticMode.current === 'remove') {
const toRemove = staticSelectingDates.current.map(d => d.format('DDMMYYYY')); const toRemove = staticSelectingDates.current.map(d => d.format('DDMMYYYY'));
setSelectedDates(selectedDates.filter(d => !toRemove.includes(d))); setSelectedDates(selectedDates.filter(d => !toRemove.includes(d)));
} }
setMode(null); setMode(null);
}, { once: true }); }, { once: true });
}} }}
onPointerEnter={() => { onPointerEnter={() => {
if (staticMode.current) { if (staticMode.current) {
let found = []; let found = [];
for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y)+1; cy++) { for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y)+1; cy++) {
for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x)+1; cx++) { for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x)+1; cx++) {
found.push({y: cy, x: cx}); found.push({y: cy, x: cx});
} }
} }
setSelectingDates(found.map(d => dates[d.y][d.x])); setSelectingDates(found.map(d => dates[d.y][d.x]));
} }
}} }}
>{date.date()}</Date> >{date.date()}</Date>
) )
)} )}
</CalendarBody> </CalendarBody>
</> </>
) : ( ) : (
<CalendarBody> <CalendarBody>
@ -257,8 +257,8 @@ const CalendarField = forwardRef(({
)} )}
</CalendarBody> </CalendarBody>
)} )}
</Wrapper> </Wrapper>
); );
}); });
export default CalendarField; export default CalendarField;

View file

@ -1,68 +1,68 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const Wrapper = styled.div` export const Wrapper = styled.div`
margin: 30px 0; margin: 30px 0;
`; `;
export const StyledLabel = styled.label` export const StyledLabel = styled.label`
display: block; display: block;
padding-bottom: 4px; padding-bottom: 4px;
font-size: 18px; font-size: 18px;
`; `;
export const StyledSubLabel = styled.label` export const StyledSubLabel = styled.label`
display: block; display: block;
font-size: 13px; font-size: 13px;
opacity: .6; opacity: .6;
`; `;
export const CalendarHeader = styled.div` export const CalendarHeader = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
user-select: none; user-select: none;
padding: 6px 0; padding: 6px 0;
font-size: 1.2em; font-size: 1.2em;
font-weight: bold; font-weight: bold;
`; `;
export const CalendarDays = styled.div` export const CalendarDays = styled.div`
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
grid-gap: 2px; grid-gap: 2px;
`; `;
export const Day = styled.div` export const Day = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 3px 0; padding: 3px 0;
font-weight: bold; font-weight: bold;
user-select: none; user-select: none;
opacity: .7; opacity: .7;
@media (max-width: 350px) { @media (max-width: 350px) {
font-size: 12px; font-size: 12px;
} }
`; `;
export const CalendarBody = styled.div` export const CalendarBody = styled.div`
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
grid-gap: 2px; grid-gap: 2px;
& button:first-of-type { & button:first-of-type {
border-top-left-radius: 3px; border-top-left-radius: 3px;
} }
& button:nth-of-type(7) { & button:nth-of-type(7) {
border-top-right-radius: 3px; border-top-right-radius: 3px;
} }
& button:nth-last-of-type(7) { & button:nth-last-of-type(7) {
border-bottom-left-radius: 3px; border-bottom-left-radius: 3px;
} }
& button:last-of-type { & button:last-of-type {
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
} }
`; `;
export const Date = styled.button` export const Date = styled.button`
@ -77,28 +77,28 @@ export const Date = styled.button`
transition: none; transition: none;
} }
background-color: ${props => props.theme.primaryBackground}; background-color: ${props => props.theme.primaryBackground};
border: 1px solid ${props => props.theme.primary}; border: 1px solid ${props => props.theme.primary};
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 10px 0; padding: 10px 0;
user-select: none; user-select: none;
touch-action: none; touch-action: none;
${props => props.otherMonth && ` ${props => props.otherMonth && `
color: ${props.theme.mode === 'light' ? props.theme.primaryLight : props.theme.primaryDark}; color: ${props.theme.mode === 'light' ? props.theme.primaryLight : props.theme.primaryDark};
`} `}
${props => props.isToday && ` ${props => props.isToday && `
font-weight: 900; font-weight: 900;
color: ${props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight}; color: ${props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
`} `}
${props => (props.selected || (props.mode === 'add' && props.selecting)) && ` ${props => (props.selected || (props.mode === 'add' && props.selecting)) && `
color: ${props.otherMonth ? 'rgba(255,255,255,.5)' : '#FFF'}; color: ${props.otherMonth ? 'rgba(255,255,255,.5)' : '#FFF'};
background-color: ${props.theme.primary}; background-color: ${props.theme.primary};
`} `}
${props => props.mode === 'remove' && props.selecting && ` ${props => props.mode === 'remove' && props.selecting && `
background-color: ${props.theme.primaryBackground}; background-color: ${props.theme.primaryBackground};
color: ${props.isToday ? props.theme.primaryDark : (props.otherMonth ? props.theme.primaryLight : 'inherit')}; color: ${props.isToday ? props.theme.primaryDark : (props.otherMonth ? props.theme.primaryLight : 'inherit')};
`} `}
`; `;

View file

@ -1,9 +1,9 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
const Center = styled.div` const Center = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
`; `;
export default Center; export default Center;

View file

@ -96,9 +96,9 @@ const Donate = () => {
}; };
return ( return (
<Wrapper> <Wrapper>
<Button <Button
small small
title={t('donate.title')} title={t('donate.title')}
onClick={event => { onClick={event => {
if (closed) { if (closed) {
@ -125,7 +125,7 @@ const Donate = () => {
role="button" role="button"
aria-expanded={isOpen ? 'true' : 'false'} aria-expanded={isOpen ? 'true' : 'false'}
style={{ whiteSpace: 'nowrap' }} style={{ whiteSpace: 'nowrap' }}
>{t('donate.button')}</Button> >{t('donate.button')}</Button>
<Options <Options
isOpen={isOpen} isOpen={isOpen}
@ -144,7 +144,7 @@ const Donate = () => {
<a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD&amount=10" target="_blank" rel="noreferrer noopener payment">{t('donate.options.$10')}</a> <a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD&amount=10" target="_blank" rel="noreferrer noopener payment">{t('donate.options.$10')}</a>
<a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD" target="_blank" rel="noreferrer noopener payment">{t('donate.options.choose')}</a> <a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD" target="_blank" rel="noreferrer noopener payment">{t('donate.options.choose')}</a>
</Options> </Options>
</Wrapper> </Wrapper>
); );
} }

View file

@ -7,7 +7,7 @@ export const Wrapper = styled.div`
`; `;
export const Options = styled.div` export const Options = styled.div`
position: absolute; position: absolute;
bottom: calc(100% + 20px); bottom: calc(100% + 20px);
right: 0; right: 0;
background-color: ${props => props.theme.background}; background-color: ${props => props.theme.background};

View file

@ -7,14 +7,14 @@ const Egg = ({ eggKey, onClose }) => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
return ( return (
<Wrapper title="Click anywhere to close" onClick={() => onClose()}> <Wrapper title="Click anywhere to close" onClick={() => onClose()}>
<Image <Image
src={`https://us-central1-flour-app-services.cloudfunctions.net/charliAPI?v=${eggKey}`} src={`https://us-central1-flour-app-services.cloudfunctions.net/charliAPI?v=${eggKey}`}
onLoadStart={() => setIsLoading(true)} onLoadStart={() => setIsLoading(true)}
onLoad={() => setIsLoading(false)} onLoad={() => setIsLoading(false)}
/> />
{isLoading && <Loading />} {isLoading && <Loading />}
</Wrapper> </Wrapper>
); );
} }

View file

@ -1,7 +1,7 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const Wrapper = styled.div` export const Wrapper = styled.div`
position: fixed; position: fixed;
background: rgba(0,0,0,.6); background: rgba(0,0,0,.6);
top: 0; top: 0;
left: 0; left: 0;
@ -17,7 +17,7 @@ export const Wrapper = styled.div`
`; `;
export const Image = styled.img` export const Image = styled.img`
max-width: 80%; max-width: 80%;
max-height: 80%; max-height: 80%;
position: absolute; position: absolute;
`; `;

View file

@ -1,17 +1,17 @@
import { Wrapper, CloseButton } from './errorStyle'; import { Wrapper, CloseButton } from './errorStyle';
const Error = ({ const Error = ({
children, children,
onClose, onClose,
open = true, open = true,
...props ...props
}) => ( }) => (
<Wrapper role="alert" open={open} {...props}> <Wrapper role="alert" open={open} {...props}>
{children} {children}
<CloseButton type="button" onClick={onClose} title="Close error"> <CloseButton type="button" onClick={onClose} title="Close error">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</CloseButton> </CloseButton>
</Wrapper> </Wrapper>
); );
export default Error; export default Error;

View file

@ -1,14 +1,14 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const Wrapper = styled.div` export const Wrapper = styled.div`
border-radius: 3px; border-radius: 3px;
background-color: ${props => props.theme.error}; background-color: ${props => props.theme.error};
color: #FFFFFF; color: #FFFFFF;
padding: 0 16px; padding: 0 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
font-size: 18px; font-size: 18px;
opacity: 0; opacity: 0;
max-height: 0; max-height: 0;
margin: 0; margin: 0;
@ -30,14 +30,14 @@ export const Wrapper = styled.div`
`; `;
export const CloseButton = styled.button` export const CloseButton = styled.button`
border: 0; border: 0;
background: none; background: none;
height: 30px; height: 30px;
width: 30px; width: 30px;
cursor: pointer; cursor: pointer;
color: inherit; color: inherit;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-left: 16px; margin-left: 16px;
`; `;

View file

@ -1,12 +1,12 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const Wrapper = styled.footer` export const Wrapper = styled.footer`
width: 600px; width: 600px;
margin: 20px auto; margin: 20px auto;
max-width: calc(100% - 60px); max-width: calc(100% - 60px);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
${props => props.small && ` ${props => props.small && `
margin: 60px auto 0; margin: 60px auto 0;
@ -19,4 +19,8 @@ export const Wrapper = styled.footer`
margin-bottom: 20px; margin-bottom: 20px;
} }
`} `}
@media print {
display: none;
}
`; `;

View file

@ -93,11 +93,11 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
} }
}, [signedIn]); }, [signedIn]);
return ( return (
<> <>
{!signedIn ? ( {!signedIn ? (
<Center> <Center>
<Button <Button
onClick={() => signIn()} onClick={() => signIn()}
isLoading={signedIn === undefined} isLoading={signedIn === undefined}
primaryColor="#4286F5" primaryColor="#4286F5"
@ -161,7 +161,7 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
</CalendarList> </CalendarList>
)} )}
</> </>
); );
}; };
export default GoogleCalendar; export default GoogleCalendar;

View file

@ -3,46 +3,46 @@ import { useSettingsStore } from 'stores';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Wrapper, Wrapper,
Label, Label,
Bar, Bar,
Grade, Grade,
} from './legendStyle'; } from './legendStyle';
const Legend = ({ const Legend = ({
min, min,
max, max,
total, total,
onSegmentFocus, onSegmentFocus,
...props ...props
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation('event'); const { t } = useTranslation('event');
const highlight = useSettingsStore(state => state.highlight); const highlight = useSettingsStore(state => state.highlight);
const setHighlight = useSettingsStore(state => state.setHighlight); const setHighlight = useSettingsStore(state => state.setHighlight);
return ( return (
<Wrapper> <Wrapper>
<Label>{min}/{total} {t('event:available')}</Label> <Label>{min}/{total} {t('event:available')}</Label>
<Bar <Bar
onMouseOut={() => onSegmentFocus(null)} onMouseOut={() => onSegmentFocus(null)}
onClick={() => setHighlight(!highlight)} onClick={() => setHighlight(!highlight)}
title={t('event:group.legend_tooltip')} title={t('event:group.legend_tooltip')}
> >
{[...Array(max+1-min).keys()].map(i => i+min).map(i => {[...Array(max+1-min).keys()].map(i => i+min).map(i =>
<Grade <Grade
key={i} key={i}
color={`${theme.primary}${Math.round((i/(max))*255).toString(16)}`} color={`${theme.primary}${Math.round((i/(max))*255).toString(16)}`}
highlight={highlight && i === max && max > 0} highlight={highlight && i === max && max > 0}
onMouseOver={() => onSegmentFocus(i)} onMouseOver={() => onSegmentFocus(i)}
/> />
)} )}
</Bar> </Bar>
<Label>{max}/{total} {t('event:available')}</Label> <Label>{max}/{total} {t('event:available')}</Label>
</Wrapper> </Wrapper>
); );
}; };
export default Legend; export default Legend;

View file

@ -1,52 +1,52 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const Wrapper = styled.div` export const Wrapper = styled.div`
margin: 10px 0; margin: 10px 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
& label:last-of-type { & label:last-of-type {
text-align: right; text-align: right;
} }
@media (max-width: 400px) { @media (max-width: 400px) {
display: block; display: block;
} }
`; `;
export const Label = styled.label` export const Label = styled.label`
display: block; display: block;
font-size: 14px; font-size: 14px;
text-align: left; text-align: left;
`; `;
export const Bar = styled.div` export const Bar = styled.div`
display: flex; display: flex;
width: 40%; width: 40%;
height: 20px; height: 20px;
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
margin: 0 8px; margin: 0 8px;
border: 1px solid ${props => props.theme.text}; border: 1px solid ${props => props.theme.text};
@media (max-width: 400px) { @media (max-width: 400px) {
width: 100%; width: 100%;
margin: 8px 0; margin: 8px 0;
} }
`; `;
export const Grade = styled.div` export const Grade = styled.div`
flex: 1; flex: 1;
background-color: ${props => props.color}; background-color: ${props => props.color};
${props => props.highlight && ` ${props => props.highlight && `
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
45deg, 45deg,
${props.theme.primary}, ${props.theme.primary},
${props.theme.primary} 4.5px, ${props.theme.primary} 4.5px,
${props.theme.primaryDark} 4.5px, ${props.theme.primaryDark} 4.5px,
${props.theme.primaryDark} 9px ${props.theme.primaryDark} 9px
); );
`} `}
`; `;

View file

@ -7,7 +7,7 @@ export const Wrapper = styled.div`
`; `;
export const A = styled.a` export const A = styled.a`
text-decoration: none; text-decoration: none;
@keyframes jelly { @keyframes jelly {
from,to { from,to {
@ -35,31 +35,35 @@ export const A = styled.a`
`; `;
export const Top = styled.div` export const Top = styled.div`
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
`; `;
export const Image = styled.img` export const Image = styled.img`
width: 2.5rem; width: 2.5rem;
margin-right: 16px; margin-right: 16px;
`; `;
export const Title = styled.span` export const Title = styled.span`
display: block; display: block;
font-size: 2rem; font-size: 2rem;
color: ${props => props.theme.primary}; color: ${props => props.theme.primary};
font-family: 'Molot', sans-serif; font-family: 'Molot', sans-serif;
font-weight: 400; font-weight: 400;
text-shadow: 0 2px 0 ${props => props.theme.primaryDark}; text-shadow: 0 2px 0 ${props => props.theme.primaryDark};
line-height: 1em; line-height: 1em;
`; `;
export const Tagline = styled.span` export const Tagline = styled.span`
text-decoration: underline; text-decoration: underline;
font-size: 14px; font-size: 14px;
padding-top: 2px; padding-top: 2px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@media print {
display: none;
}
`; `;

View file

@ -160,11 +160,11 @@ const OutlookCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
// eslint-disable-next-line // eslint-disable-next-line
}, [client]); }, [client]);
return ( return (
<> <>
{!client ? ( {!client ? (
<Center> <Center>
<Button <Button
onClick={() => signIn()} onClick={() => signIn()}
isLoading={client === undefined} isLoading={client === undefined}
primaryColor="#0364B9" primaryColor="#0364B9"
@ -228,7 +228,7 @@ const OutlookCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
</CalendarList> </CalendarList>
)} )}
</> </>
); );
}; };
export default OutlookCalendar; export default OutlookCalendar;

View file

@ -4,7 +4,7 @@ import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import { AboutSection, StyledMain } from '../../pages/Home/homeStyle'; import { AboutSection, StyledMain } from '../../pages/Home/homeStyle';
import { Recent } from './recentsStyle'; import { Wrapper, Recent } from './recentsStyle';
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@ -14,17 +14,19 @@ const Recents = ({ target }) => {
const { t } = useTranslation(['home', 'common']); const { t } = useTranslation(['home', 'common']);
return !!recents.length && ( return !!recents.length && (
<AboutSection id="recents"> <Wrapper>
<StyledMain> <AboutSection id="recents">
<h2>{t('home:recently_visited')}</h2> <StyledMain>
{recents.map(event => ( <h2>{t('home:recently_visited')}</h2>
<Recent href={`/${event.id}`} target={target} key={event.id}> {recents.map(event => (
<span className="name">{event.name}</span> <Recent href={`/${event.id}`} target={target} key={event.id}>
<span locale={locale} className="date" title={dayjs.unix(event.created).format('D MMMM, YYYY')}>{t('common:created', { date: dayjs.unix(event.created).fromNow() })}</span> <span className="name">{event.name}</span>
</Recent> <span locale={locale} className="date" title={dayjs.unix(event.created).format('D MMMM, YYYY')}>{t('common:created', { date: dayjs.unix(event.created).fromNow() })}</span>
))} </Recent>
</StyledMain> ))}
</AboutSection> </StyledMain>
</AboutSection>
</Wrapper>
); );
}; };

View file

@ -1,7 +1,13 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const Wrapper = styled.div`
@media print {
display: none;
}
`;
export const Recent = styled.a` export const Recent = styled.a`
text-decoration: none; text-decoration: none;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;

View file

@ -1,43 +1,43 @@
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { import {
Wrapper, Wrapper,
StyledLabel, StyledLabel,
StyledSubLabel, StyledSubLabel,
StyledSelect, StyledSelect,
} from './selectFieldStyle'; } from './selectFieldStyle';
const SelectField = forwardRef(({ const SelectField = forwardRef(({
label, label,
subLabel, subLabel,
id, id,
options = [], options = [],
inline = false, inline = false,
small = false, small = false,
defaultOption, defaultOption,
...props ...props
}, ref) => ( }, ref) => (
<Wrapper inline={inline} small={small}> <Wrapper inline={inline} small={small}>
{label && <StyledLabel htmlFor={id} inline={inline} small={small}>{label}</StyledLabel>} {label && <StyledLabel htmlFor={id} inline={inline} small={small}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>} {subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<StyledSelect <StyledSelect
id={id} id={id}
small={small} small={small}
ref={ref} ref={ref}
{...props} {...props}
> >
{defaultOption && <option value="">{defaultOption}</option>} {defaultOption && <option value="">{defaultOption}</option>}
{Array.isArray(options) ? ( {Array.isArray(options) ? (
options.map(value => options.map(value =>
<option key={value} value={value}>{value}</option> <option key={value} value={value}>{value}</option>
) )
) : ( ) : (
Object.entries(options).map(([key, value]) => Object.entries(options).map(([key, value]) =>
<option key={key} value={key}>{value}</option> <option key={key} value={key}>{value}</option>
) )
)} )}
</StyledSelect> </StyledSelect>
</Wrapper> </Wrapper>
)); ));
export default SelectField; export default SelectField;

View file

@ -1,60 +1,60 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const Wrapper = styled.div` export const Wrapper = styled.div`
margin: 30px 0; margin: 30px 0;
${props => props.inline && ` ${props => props.inline && `
margin: 0; margin: 0;
`} `}
${props => props.small && ` ${props => props.small && `
margin: 10px 0; margin: 10px 0;
`} `}
`; `;
export const StyledLabel = styled.label` export const StyledLabel = styled.label`
display: block; display: block;
padding-bottom: 4px; padding-bottom: 4px;
font-size: 18px; font-size: 18px;
${props => props.inline && ` ${props => props.inline && `
font-size: 16px; font-size: 16px;
`} `}
${props => props.small && ` ${props => props.small && `
font-size: .9rem; font-size: .9rem;
`} `}
`; `;
export const StyledSubLabel = styled.label` export const StyledSubLabel = styled.label`
display: block; display: block;
padding-bottom: 6px; padding-bottom: 6px;
font-size: 13px; font-size: 13px;
opacity: .6; opacity: .6;
`; `;
export const StyledSelect = styled.select` export const StyledSelect = styled.select`
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
font: inherit; font: inherit;
background: ${props => props.theme.primaryBackground}; background: ${props => props.theme.primaryBackground};
color: inherit; color: inherit;
padding: 10px 14px; padding: 10px 14px;
border: 1px solid ${props => props.theme.primary}; border: 1px solid ${props => props.theme.primary};
box-shadow: inset 0 0 0 0 ${props => props.theme.primary}; box-shadow: inset 0 0 0 0 ${props => props.theme.primary};
border-radius: 3px; border-radius: 3px;
outline: none; outline: none;
transition: border-color .15s, box-shadow .15s; transition: border-color .15s, box-shadow .15s;
appearance: none; appearance: none;
background-image: url("data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><foreignObject width=%22100px%22 height=%22100px%22><div xmlns=%22http://www.w3.org/1999/xhtml%22 style=%22color:${props => encodeURIComponent(props.theme.primary)};font-size:60px;display:flex;align-items:center;justify-content:center;height:100%25;width:100%25%22>▼</div></foreignObject></svg>"); background-image: url("data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><foreignObject width=%22100px%22 height=%22100px%22><div xmlns=%22http://www.w3.org/1999/xhtml%22 style=%22color:${props => encodeURIComponent(props.theme.primary)};font-size:60px;display:flex;align-items:center;justify-content:center;height:100%25;width:100%25%22>▼</div></foreignObject></svg>");
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right 10px center; background-position: right 10px center;
background-size: 1em; background-size: 1em;
&:focus { &:focus {
border: 1px solid ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight}; border: 1px solid ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
box-shadow: inset 0 -3px 0 0 ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight}; box-shadow: inset 0 -3px 0 0 ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
} }
${props => props.small && ` ${props => props.small && `
padding: 6px 8px; padding: 6px 8px;
`} `}
`; `;

View file

@ -1,15 +1,15 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const OpenButton = styled.button` export const OpenButton = styled.button`
border: 0; border: 0;
background: none; background: none;
height: 50px; height: 50px;
width: 50px; width: 50px;
cursor: pointer; cursor: pointer;
color: inherit; color: inherit;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: absolute; position: absolute;
top: 12px; top: 12px;
right: 12px; right: 12px;
@ -33,10 +33,13 @@ export const OpenButton = styled.button`
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
transition: none; transition: none;
} }
@media print {
display: none;
}
`; `;
export const Cover = styled.div` export const Cover = styled.div`
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
@ -50,7 +53,7 @@ export const Cover = styled.div`
`; `;
export const Modal = styled.div` export const Modal = styled.div`
position: absolute; position: absolute;
top: 70px; top: 70px;
right: 12px; right: 12px;
background-color: ${props => props.theme.background}; background-color: ${props => props.theme.background};
@ -81,10 +84,13 @@ export const Modal = styled.div`
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
transition: none; transition: none;
} }
@media print {
display: none;
}
`; `;
export const Heading = styled.span` export const Heading = styled.span`
font-size: 1.5rem; font-size: 1.5rem;
display: block; display: block;
margin: 6px 0; margin: 6px 0;
line-height: 1em; line-height: 1em;

View file

@ -1,23 +1,23 @@
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { import {
Wrapper, Wrapper,
StyledLabel, StyledLabel,
StyledSubLabel, StyledSubLabel,
StyledInput, StyledInput,
} from './textFieldStyle'; } from './textFieldStyle';
const TextField = forwardRef(({ const TextField = forwardRef(({
label, label,
subLabel, subLabel,
id, id,
inline = false, inline = false,
...props ...props
}, ref) => ( }, ref) => (
<Wrapper inline={inline}> <Wrapper inline={inline}>
{label && <StyledLabel htmlFor={id} inline={inline}>{label}</StyledLabel>} {label && <StyledLabel htmlFor={id} inline={inline}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>} {subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<StyledInput id={id} ref={ref} {...props} /> <StyledInput id={id} ref={ref} {...props} />
</Wrapper> </Wrapper>
)); ));
export default TextField; export default TextField;

View file

@ -1,46 +1,46 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const Wrapper = styled.div` export const Wrapper = styled.div`
margin: 30px 0; margin: 30px 0;
${props => props.inline && ` ${props => props.inline && `
margin: 0; margin: 0;
`} `}
`; `;
export const StyledLabel = styled.label` export const StyledLabel = styled.label`
display: block; display: block;
padding-bottom: 4px; padding-bottom: 4px;
font-size: 18px; font-size: 18px;
${props => props.inline && ` ${props => props.inline && `
font-size: 16px; font-size: 16px;
`} `}
`; `;
export const StyledSubLabel = styled.label` export const StyledSubLabel = styled.label`
display: block; display: block;
padding-bottom: 6px; padding-bottom: 6px;
font-size: 13px; font-size: 13px;
opacity: .6; opacity: .6;
`; `;
export const StyledInput = styled.input` export const StyledInput = styled.input`
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
font: inherit; font: inherit;
background: ${props => props.theme.primaryBackground}; background: ${props => props.theme.primaryBackground};
color: inherit; color: inherit;
padding: 10px 14px; padding: 10px 14px;
border: 1px solid ${props => props.theme.primary}; border: 1px solid ${props => props.theme.primary};
box-shadow: inset 0 0 0 0 ${props => props.theme.primary}; box-shadow: inset 0 0 0 0 ${props => props.theme.primary};
border-radius: 3px; border-radius: 3px;
font-size: 18px; font-size: 18px;
outline: none; outline: none;
transition: border-color .15s, box-shadow .15s; transition: border-color .15s, box-shadow .15s;
&:focus { &:focus {
border: 1px solid ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight}; border: 1px solid ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
box-shadow: inset 0 -3px 0 0 ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight}; box-shadow: inset 0 -3px 0 0 ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
} }
`; `;

View file

@ -4,94 +4,94 @@ import dayjs from 'dayjs';
import { useSettingsStore, useLocaleUpdateStore } from 'stores'; import { useSettingsStore, useLocaleUpdateStore } from 'stores';
import { import {
Wrapper, Wrapper,
StyledLabel, StyledLabel,
StyledSubLabel, StyledSubLabel,
Range, Range,
Handle, Handle,
Selected, Selected,
} from './timeRangeFieldStyle'; } from './timeRangeFieldStyle';
const times = ['00','01','02','03','04','05','06','07','08','09','10','11','12','13','14','15','16','17','18','19','20','21','22','23','24']; const times = ['00','01','02','03','04','05','06','07','08','09','10','11','12','13','14','15','16','17','18','19','20','21','22','23','24'];
const TimeRangeField = forwardRef(({ const TimeRangeField = forwardRef(({
label, label,
subLabel, subLabel,
id, id,
setValue, setValue,
...props ...props
}, ref) => { }, ref) => {
const timeFormat = useSettingsStore(state => state.timeFormat); const timeFormat = useSettingsStore(state => state.timeFormat);
const locale = useLocaleUpdateStore(state => state.locale); const locale = useLocaleUpdateStore(state => state.locale);
const [start, setStart] = useState(9); const [start, setStart] = useState(9);
const [end, setEnd] = useState(17); const [end, setEnd] = useState(17);
const isStartMoving = useRef(false); const isStartMoving = useRef(false);
const isEndMoving = useRef(false); const isEndMoving = useRef(false);
const rangeRef = useRef(); const rangeRef = useRef();
const rangeRect = useRef(); const rangeRect = useRef();
useEffect(() => { useEffect(() => {
if (rangeRef.current) { if (rangeRef.current) {
rangeRect.current = rangeRef.current.getBoundingClientRect(); rangeRect.current = rangeRef.current.getBoundingClientRect();
} }
}, [rangeRef]); }, [rangeRef]);
useEffect(() => setValue(props.name, JSON.stringify({start, end})), [start, end, setValue, props.name]); useEffect(() => setValue(props.name, JSON.stringify({start, end})), [start, end, setValue, props.name]);
const handleMouseMove = e => { const handleMouseMove = e => {
if (isStartMoving.current || isEndMoving.current) { if (isStartMoving.current || isEndMoving.current) {
let step = Math.round(((e.pageX - rangeRect.current.left) / rangeRect.current.width) * 24); let step = Math.round(((e.pageX - rangeRect.current.left) / rangeRect.current.width) * 24);
if (step < 0) step = 0; if (step < 0) step = 0;
if (step > 24) step = 24; if (step > 24) step = 24;
step = Math.abs(step); step = Math.abs(step);
if (isStartMoving.current) { if (isStartMoving.current) {
setStart(step); setStart(step);
} else if (isEndMoving.current) { } else if (isEndMoving.current) {
setEnd(step); setEnd(step);
} }
} }
}; };
return ( return (
<Wrapper locale={locale}> <Wrapper locale={locale}>
{label && <StyledLabel htmlFor={id}>{label}</StyledLabel>} {label && <StyledLabel htmlFor={id}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>} {subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<input <input
id={id} id={id}
type="hidden" type="hidden"
value={JSON.stringify({start, end})} value={JSON.stringify({start, end})}
ref={ref} ref={ref}
{...props} {...props}
/> />
<Range ref={rangeRef}> <Range ref={rangeRef}>
<Selected start={start} end={start > end ? 24 : end} /> <Selected start={start} end={start > end ? 24 : end} />
{start > end && <Selected start={start > end ? 0 : start} end={end} />} {start > end && <Selected start={start > end ? 0 : start} end={end} />}
<Handle <Handle
value={start} value={start}
label={timeFormat === '24h' ? times[start] : dayjs().hour(times[start]).format('ha')} label={timeFormat === '24h' ? times[start] : dayjs().hour(times[start]).format('ha')}
extraPadding={end - start === 1 ? 'padding-right: 20px;' : (start - end === 1 ? 'padding-left: 20px;' : '')} extraPadding={end - start === 1 ? 'padding-right: 20px;' : (start - end === 1 ? 'padding-left: 20px;' : '')}
onMouseDown={() => { onMouseDown={() => {
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
isStartMoving.current = true; isStartMoving.current = true;
document.addEventListener('mouseup', () => { document.addEventListener('mouseup', () => {
isStartMoving.current = false; isStartMoving.current = false;
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
}, { once: true }); }, { once: true });
}} }}
onTouchMove={(e) => { onTouchMove={(e) => {
const touch = e.targetTouches[0]; const touch = e.targetTouches[0];
let step = Math.round(((touch.pageX - rangeRect.current.left) / rangeRect.current.width) * 24); let step = Math.round(((touch.pageX - rangeRect.current.left) / rangeRect.current.width) * 24);
if (step < 0) step = 0; if (step < 0) step = 0;
if (step > 24) step = 24; if (step > 24) step = 24;
step = Math.abs(step); step = Math.abs(step);
setStart(step); setStart(step);
}} }}
tabIndex="0" tabIndex="0"
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
@ -103,29 +103,29 @@ const TimeRangeField = forwardRef(({
setStart(Math.min(start+1, 24)); setStart(Math.min(start+1, 24));
} }
}} }}
/> />
<Handle <Handle
value={end} value={end}
label={timeFormat === '24h' ? times[end] : dayjs().hour(times[end]).format('ha')} label={timeFormat === '24h' ? times[end] : dayjs().hour(times[end]).format('ha')}
extraPadding={end - start === 1 ? 'padding-left: 20px;' : (start - end === 1 ? 'padding-right: 20px;' : '')} extraPadding={end - start === 1 ? 'padding-left: 20px;' : (start - end === 1 ? 'padding-right: 20px;' : '')}
onMouseDown={() => { onMouseDown={() => {
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
isEndMoving.current = true; isEndMoving.current = true;
document.addEventListener('mouseup', () => { document.addEventListener('mouseup', () => {
isEndMoving.current = false; isEndMoving.current = false;
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
}, { once: true }); }, { once: true });
}} }}
onTouchMove={(e) => { onTouchMove={(e) => {
const touch = e.targetTouches[0]; const touch = e.targetTouches[0];
let step = Math.round(((touch.pageX - rangeRect.current.left) / rangeRect.current.width) * 24); let step = Math.round(((touch.pageX - rangeRect.current.left) / rangeRect.current.width) * 24);
if (step < 0) step = 0; if (step < 0) step = 0;
if (step > 24) step = 24; if (step > 24) step = 24;
step = Math.abs(step); step = Math.abs(step);
setEnd(step); setEnd(step);
}} }}
tabIndex="0" tabIndex="0"
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
@ -137,10 +137,10 @@ const TimeRangeField = forwardRef(({
setEnd(Math.min(end+1, 24)); setEnd(Math.min(end+1, 24));
} }
}} }}
/> />
</Range> </Range>
</Wrapper> </Wrapper>
); );
}); });
export default TimeRangeField; export default TimeRangeField;

View file

@ -1,82 +1,82 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const Wrapper = styled.div` export const Wrapper = styled.div`
margin: 30px 0; margin: 30px 0;
`; `;
export const StyledLabel = styled.label` export const StyledLabel = styled.label`
display: block; display: block;
padding-bottom: 4px; padding-bottom: 4px;
font-size: 18px; font-size: 18px;
`; `;
export const StyledSubLabel = styled.label` export const StyledSubLabel = styled.label`
display: block; display: block;
padding-bottom: 6px; padding-bottom: 6px;
font-size: 13px; font-size: 13px;
opacity: .6; opacity: .6;
`; `;
export const Range = styled.div` export const Range = styled.div`
user-select: none; user-select: none;
background-color: ${props => props.theme.primaryBackground}; background-color: ${props => props.theme.primaryBackground};
border: 1px solid ${props => props.theme.primary}; border: 1px solid ${props => props.theme.primary};
border-radius: 3px; border-radius: 3px;
height: 50px; height: 50px;
position: relative; position: relative;
margin: 38px 6px 18px; margin: 38px 6px 18px;
`; `;
export const Handle = styled.div` export const Handle = styled.div`
height: calc(100% + 20px); height: calc(100% + 20px);
width: 20px; width: 20px;
border: 1px solid ${props => props.theme.primary}; border: 1px solid ${props => props.theme.primary};
background-color: ${props => props.theme.primaryLight}; background-color: ${props => props.theme.primaryLight};
border-radius: 3px; border-radius: 3px;
position: absolute; position: absolute;
top: -10px; top: -10px;
left: calc(${props => props.value * 4.1666666666666666}% - 11px); left: calc(${props => props.value * 4.1666666666666666}% - 11px);
cursor: ew-resize; cursor: ew-resize;
touch-action: none; touch-action: none;
transition: left .1s; transition: left .1s;
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
transition: none; transition: none;
} }
&:after { &:after {
content: '|||'; content: '|||';
font-size: 8px; font-size: 8px;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: ${props => props.theme.primaryDark}; color: ${props => props.theme.primaryDark};
} }
&:before { &:before {
content: '${props => props.label}'; content: '${props => props.label}';
position: absolute; position: absolute;
bottom: calc(100% + 8px); bottom: calc(100% + 8px);
text-align: center; text-align: center;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
white-space: nowrap; white-space: nowrap;
${props => props.extraPadding} ${props => props.extraPadding}
} }
`; `;
export const Selected = styled.div` export const Selected = styled.div`
position: absolute; position: absolute;
height: 100%; height: 100%;
left: ${props => props.start * 4.1666666666666666}%; left: ${props => props.start * 4.1666666666666666}%;
right: calc(100% - ${props => props.end * 4.1666666666666666}%); right: calc(100% - ${props => props.end * 4.1666666666666666}%);
top: 0; top: 0;
background-color: ${props => props.theme.primary}; background-color: ${props => props.theme.primary};
border-radius: 2px; border-radius: 2px;
transition: left .1s, right .1s; transition: left .1s, right .1s;
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
transition: none; transition: none;

View file

@ -1,25 +1,25 @@
import { import {
Wrapper, Wrapper,
ToggleContainer, ToggleContainer,
StyledLabel, StyledLabel,
Option, Option,
HiddenInput, HiddenInput,
LabelButton, LabelButton,
} from './toggleFieldStyle'; } from './toggleFieldStyle';
const ToggleField = ({ const ToggleField = ({
label, label,
id, id,
name, name,
title = '', title = '',
options = [], options = [],
value, value,
onChange, onChange,
inputRef, inputRef,
...props ...props
}) => ( }) => (
<Wrapper> <Wrapper>
{label && <StyledLabel title={title}>{label} {title !== '' && <svg viewBox="0 0 24 24"><path fill="currentColor" d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z" /></svg>}</StyledLabel>} {label && <StyledLabel title={title}>{label} {title !== '' && <svg viewBox="0 0 24 24"><path fill="currentColor" d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z" /></svg>}</StyledLabel>}
<ToggleContainer> <ToggleContainer>
{Object.entries(options).map(([key, label]) => {Object.entries(options).map(([key, label]) =>
@ -37,7 +37,7 @@ const ToggleField = ({
</Option> </Option>
)} )}
</ToggleContainer> </ToggleContainer>
</Wrapper> </Wrapper>
); );
export default ToggleField; export default ToggleField;

View file

@ -1,11 +1,11 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const Wrapper = styled.div` export const Wrapper = styled.div`
margin: 10px 0; margin: 10px 0;
`; `;
export const ToggleContainer = styled.div` export const ToggleContainer = styled.div`
display: flex; display: flex;
border: 1px solid ${props => props.theme.primary}; border: 1px solid ${props => props.theme.primary};
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
@ -29,9 +29,9 @@ export const ToggleContainer = styled.div`
`; `;
export const StyledLabel = styled.label` export const StyledLabel = styled.label`
display: block; display: block;
padding-bottom: 4px; padding-bottom: 4px;
font-size: .9rem; font-size: .9rem;
& svg { & svg {
height: 1em; height: 1em;
@ -41,7 +41,7 @@ export const StyledLabel = styled.label`
`; `;
export const Option = styled.div` export const Option = styled.div`
flex: 1; flex: 1;
position: relative; position: relative;
`; `;

View file

@ -10,14 +10,14 @@ const UpdateDialog = ({ onClose }) => {
const { t } = useTranslation('common'); const { t } = useTranslation('common');
return ( return (
<Wrapper> <Wrapper>
<h2>{t('common:update.heading')}</h2> <h2>{t('common:update.heading')}</h2>
<p>{t('common:update.body')}</p> <p>{t('common:update.body')}</p>
<ButtonWrapper> <ButtonWrapper>
<Button secondary onClick={onClose}>{t('common:update.buttons.close')}</Button> <Button secondary onClick={onClose}>{t('common:update.buttons.close')}</Button>
<Button onClick={() => window.location.reload()}>{t('common:update.buttons.reload')}</Button> <Button onClick={() => window.location.reload()}>{t('common:update.buttons.reload')}</Button>
</ButtonWrapper> </ButtonWrapper>
</Wrapper> </Wrapper>
); );
} }

View file

@ -13,7 +13,7 @@ export const Wrapper = styled.div`
border-radius: 3px; border-radius: 3px;
width: 400px; width: 400px;
box-sizing: border-box; box-sizing: border-box;
max-width: calc(100% - 20px); max-width: calc(100% - 40px);
box-shadow: 0 3px 6px 0 rgba(0,0,0,.3); box-shadow: 0 3px 6px 0 rgba(0,0,0,.3);
& h2 { & h2 {
@ -31,4 +31,5 @@ export const ButtonWrapper = styled.div`
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 16px; gap: 16px;
flex-wrap: wrap;
`; `;

View file

@ -9,21 +9,21 @@ import timezone from 'dayjs/plugin/timezone';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
import { import {
TextField, TextField,
CalendarField, CalendarField,
TimeRangeField, TimeRangeField,
SelectField, SelectField,
Button, Button,
Error, Error,
Recents, Recents,
Footer, Footer,
} from 'components'; } from 'components';
import { import {
StyledMain, StyledMain,
CreateForm, CreateForm,
TitleSmall, TitleSmall,
TitleLarge, TitleLarge,
P, P,
OfflineMessage, OfflineMessage,
ShareInfo, ShareInfo,
@ -39,14 +39,14 @@ dayjs.extend(timezone);
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
const Create = ({ offline }) => { const Create = ({ offline }) => {
const { register, handleSubmit, setValue } = useForm({ const { register, handleSubmit, setValue } = useForm({
defaultValues: { defaultValues: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}, },
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [createdEvent, setCreatedEvent] = useState(null); const [createdEvent, setCreatedEvent] = useState(null);
const [copied, setCopied] = useState(null); const [copied, setCopied] = useState(null);
const [showFooter, setShowFooter] = useState(true); const [showFooter, setShowFooter] = useState(true);
@ -55,11 +55,11 @@ const Create = ({ offline }) => {
const addRecent = useRecentsStore(state => state.addRecent); const addRecent = useRecentsStore(state => state.addRecent);
useEffect(() => { useEffect(() => {
if (window.self === window.top) { if (window.self === window.top) {
push('/'); push('/');
} }
document.title = 'Create a Crab Fit'; document.title = 'Create a Crab Fit';
if (window.parent) { if (window.parent) {
window.parent.postMessage('crabfit-create', '*'); window.parent.postMessage('crabfit-create', '*');
@ -71,67 +71,67 @@ const Create = ({ offline }) => {
once: true once: true
}); });
} }
}, [push]); }, [push]);
const onSubmit = async data => { const onSubmit = async data => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const { start, end } = JSON.parse(data.times); const { start, end } = JSON.parse(data.times);
const dates = JSON.parse(data.dates); const dates = JSON.parse(data.dates);
if (dates.length === 0) { if (dates.length === 0) {
return setError(t('home:form.errors.no_dates')); return setError(t('home:form.errors.no_dates'));
} }
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8; const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8;
if (start === end) { if (start === end) {
return setError(t('home:form.errors.same_times')); return setError(t('home:form.errors.same_times'));
} }
let times = dates.reduce((times, date) => { let times = dates.reduce((times, date) => {
let day = []; let day = [];
for (let i = start; i < (start > end ? 24 : end); i++) { for (let i = start; i < (start > end ? 24 : end); i++) {
if (isSpecificDates) { if (isSpecificDates) {
day.push( day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone) dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY') .hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
); );
} else { } else {
day.push( day.push(
dayjs().tz(data.timezone) dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d') .day(date).hour(i).minute(0).utc().format('HHmm-d')
); );
} }
} }
if (start > end) { if (start > end) {
for (let i = 0; i < end; i++) { for (let i = 0; i < end; i++) {
if (isSpecificDates) { if (isSpecificDates) {
day.push( day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone) dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY') .hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
); );
} else { } else {
day.push( day.push(
dayjs().tz(data.timezone) dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d') .day(date).hour(i).minute(0).utc().format('HHmm-d')
); );
} }
} }
} }
return [...times, ...day]; return [...times, ...day];
}, []); }, []);
if (times.length === 0) { if (times.length === 0) {
return setError(t('home:form.errors.no_time')); return setError(t('home:form.errors.no_time'));
} }
const response = await api.post('/event', { const response = await api.post('/event', {
event: { event: {
name: data.name, name: data.name,
times: times, times: times,
timezone: data.timezone, timezone: data.timezone,
}, },
}); });
setCreatedEvent(response.data); setCreatedEvent(response.data);
addRecent({ addRecent({
id: response.data.id, id: response.data.id,
@ -141,19 +141,19 @@ const Create = ({ offline }) => {
gtag('event', 'create_event', { gtag('event', 'create_event', {
'event_category': 'create', 'event_category': 'create',
}); });
} catch (e) { } catch (e) {
setError(t('home:form.errors.unknown')); setError(t('home:form.errors.unknown'));
console.error(e); console.error(e);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
return ( return (
<> <>
<StyledMain> <StyledMain>
<TitleSmall>{t('home:create')}</TitleSmall> <TitleSmall>{t('home:create')}</TitleSmall>
<TitleLarge>CRAB FIT</TitleLarge> <TitleLarge>CRAB FIT</TitleLarge>
</StyledMain> </StyledMain>
{createdEvent ? ( {createdEvent ? (
@ -173,10 +173,10 @@ const Create = ({ offline }) => {
} }
title={!!navigator.clipboard ? t('event:nav.title') : ''} title={!!navigator.clipboard ? t('event:nav.title') : ''}
>{copied ?? `https://crab.fit/${createdEvent?.id}`}</ShareInfo> >{copied ?? `https://crab.fit/${createdEvent?.id}`}</ShareInfo>
<ShareInfo> <ShareInfo>
{/* eslint-disable-next-line */} {/* eslint-disable-next-line */}
<Trans i18nKey="event:nav.shareinfo_alt">Click the link above to copy it to your clipboard, or share via <a onClick={() => gtag('event', 'send_email', { 'event_category': 'event' })} href={`mailto:?subject=${encodeURIComponent(t('event:nav.email_subject', { event_name: createdEvent?.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} https://crab.fit/${createdEvent?.id}`)}`} target="_blank">email</a>.</Trans> <Trans i18nKey="event:nav.shareinfo_alt">Click the link above to copy it to your clipboard, or share via <a onClick={() => gtag('event', 'send_email', { 'event_category': 'event' })} href={`mailto:?subject=${encodeURIComponent(t('event:nav.email_subject', { event_name: createdEvent?.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} https://crab.fit/${createdEvent?.id}`)}`} target="_blank">email</a>.</Trans>
</ShareInfo> </ShareInfo>
{showFooter && <Footer small />} {showFooter && <Footer small />}
</OfflineMessage> </OfflineMessage>
</StyledMain> </StyledMain>
@ -191,52 +191,52 @@ const Create = ({ offline }) => {
<P>{t('home:offline')}</P> <P>{t('home:offline')}</P>
</OfflineMessage> </OfflineMessage>
) : ( ) : (
<CreateForm onSubmit={handleSubmit(onSubmit)} id="create"> <CreateForm onSubmit={handleSubmit(onSubmit)} id="create">
<TextField <TextField
label={t('home:form.name.label')} label={t('home:form.name.label')}
subLabel={t('home:form.name.sublabel')} subLabel={t('home:form.name.sublabel')}
type="text" type="text"
id="name" id="name"
{...register('name')} {...register('name')}
/> />
<CalendarField <CalendarField
label={t('home:form.dates.label')} label={t('home:form.dates.label')}
subLabel={t('home:form.dates.sublabel')} subLabel={t('home:form.dates.sublabel')}
id="dates" id="dates"
required required
setValue={setValue} setValue={setValue}
{...register('dates')} {...register('dates')}
/> />
<TimeRangeField <TimeRangeField
label={t('home:form.times.label')} label={t('home:form.times.label')}
subLabel={t('home:form.times.sublabel')} subLabel={t('home:form.times.sublabel')}
id="times" id="times"
required required
setValue={setValue} setValue={setValue}
{...register('times')} {...register('times')}
/> />
<SelectField <SelectField
label={t('home:form.timezone.label')} label={t('home:form.timezone.label')}
id="timezone" id="timezone"
options={timezones} options={timezones}
required required
{...register('timezone')} {...register('timezone')}
defaultOption={t('home:form.timezone.defaultOption')} defaultOption={t('home:form.timezone.defaultOption')}
/> />
<Error open={!!error} onClose={() => setError(null)}>{error}</Error> <Error open={!!error} onClose={() => setError(null)}>{error}</Error>
<Button type="submit" isLoading={isLoading} disabled={isLoading} style={{ width: '100%' }}>{t('home:form.button')}</Button> <Button type="submit" isLoading={isLoading} disabled={isLoading} style={{ width: '100%' }}>{t('home:form.button')}</Button>
</CreateForm> </CreateForm>
)} )}
</StyledMain> </StyledMain>
</> </>
)} )}
</> </>
); );
}; };
export default Create; export default Create;

View file

@ -1,9 +1,9 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const StyledMain = styled.div` export const StyledMain = styled.div`
width: 600px; width: 600px;
margin: 10px auto; margin: 10px auto;
max-width: calc(100% - 30px); max-width: calc(100% - 30px);
`; `;
export const CreateForm = styled.form` export const CreateForm = styled.form`
@ -11,43 +11,43 @@ export const CreateForm = styled.form`
`; `;
export const TitleSmall = styled.span` export const TitleSmall = styled.span`
display: block; display: block;
margin: 0; margin: 0;
font-size: 2rem; font-size: 2rem;
text-align: center; text-align: center;
font-family: 'Samurai Bob', sans-serif; font-family: 'Samurai Bob', sans-serif;
font-weight: 400; font-weight: 400;
color: ${props => props.theme.primaryDark}; color: ${props => props.theme.primaryDark};
line-height: 1em; line-height: 1em;
text-transform: uppercase; text-transform: uppercase;
`; `;
export const TitleLarge = styled.h1` export const TitleLarge = styled.h1`
margin: 0; margin: 0;
font-size: 2rem; font-size: 2rem;
text-align: center; text-align: center;
color: ${props => props.theme.primary}; color: ${props => props.theme.primary};
font-family: 'Molot', sans-serif; font-family: 'Molot', sans-serif;
font-weight: 400; font-weight: 400;
text-shadow: 0 3px 0 ${props => props.theme.primaryDark}; text-shadow: 0 3px 0 ${props => props.theme.primaryDark};
line-height: 1em; line-height: 1em;
text-transform: uppercase; text-transform: uppercase;
`; `;
export const P = styled.p` export const P = styled.p`
font-weight: 500; font-weight: 500;
line-height: 1.6em; line-height: 1.6em;
`; `;
export const OfflineMessage = styled.div` export const OfflineMessage = styled.div`
text-align: center; text-align: center;
margin: 50px 0 20px; margin: 50px 0 20px;
`; `;
export const ShareInfo = styled.p` export const ShareInfo = styled.p`
margin: 6px 0; margin: 6px 0;
text-align: center; text-align: center;
font-size: 15px; font-size: 15px;
padding: 10px 0; padding: 10px 0;
${props => props.onClick && ` ${props => props.onClick && `

View file

@ -9,27 +9,27 @@ import customParseFormat from 'dayjs/plugin/customParseFormat';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import { import {
Footer, Footer,
TextField, TextField,
SelectField, SelectField,
Button, Button,
AvailabilityViewer, AvailabilityViewer,
AvailabilityEditor, AvailabilityEditor,
Error, Error,
Logo, Logo,
} from 'components'; } from 'components';
import { StyledMain } from '../Home/homeStyle'; import { StyledMain } from '../Home/homeStyle';
import { import {
EventName, EventName,
EventDate, EventDate,
LoginForm, LoginForm,
LoginSection, LoginSection,
Info, Info,
ShareInfo, ShareInfo,
Tabs, Tabs,
Tab, Tab,
} from './eventStyle'; } from './eventStyle';
import api from 'services'; import api from 'services';
@ -47,94 +47,98 @@ const Event = (props) => {
const weekStart = useSettingsStore(state => state.weekStart); const weekStart = useSettingsStore(state => state.weekStart);
const addRecent = useRecentsStore(state => state.addRecent); const addRecent = useRecentsStore(state => state.addRecent);
const removeRecent = useRecentsStore(state => state.removeRecent);
const locale = useLocaleUpdateStore(state => state.locale); const locale = useLocaleUpdateStore(state => state.locale);
const { t } = useTranslation(['common', 'event']); const { t } = useTranslation(['common', 'event']);
const { register, handleSubmit, setFocus, reset } = useForm(); const { register, handleSubmit, setFocus, reset } = useForm();
const { id } = props.match.params; const { id } = props.match.params;
const { offline } = props; const { offline } = props;
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone); const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone);
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [password, setPassword] = useState(null); const [password, setPassword] = useState(null);
const [tab, setTab] = useState(user ? 'you' : 'group'); const [tab, setTab] = useState(user ? 'you' : 'group');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isLoginLoading, setIsLoginLoading] = useState(false); const [isLoginLoading, setIsLoginLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [event, setEvent] = useState(null); const [event, setEvent] = useState(null);
const [people, setPeople] = useState([]); const [people, setPeople] = useState([]);
const [times, setTimes] = useState([]); const [times, setTimes] = useState([]);
const [timeLabels, setTimeLabels] = useState([]); const [timeLabels, setTimeLabels] = useState([]);
const [dates, setDates] = useState([]); const [dates, setDates] = useState([]);
const [min, setMin] = useState(0); const [min, setMin] = useState(0);
const [max, setMax] = useState(0); const [max, setMax] = useState(0);
const [copied, setCopied] = useState(null); const [copied, setCopied] = useState(null);
useEffect(() => { useEffect(() => {
const fetchEvent = async () => { const fetchEvent = async () => {
try { try {
const response = await api.get(`/event/${id}`); const response = await api.get(`/event/${id}`);
setEvent(response.data); setEvent(response.data);
addRecent({ addRecent({
id: response.data.id, id: response.data.id,
created: response.data.created, created: response.data.created,
name: response.data.name, name: response.data.name,
}); });
document.title = `${response.data.name} | Crab Fit`; document.title = `${response.data.name} | Crab Fit`;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { if (e.status === 404) {
setIsLoading(false); removeRecent(id);
} }
}; } finally {
setIsLoading(false);
}
};
fetchEvent(); fetchEvent();
}, [id, addRecent]); }, [id, addRecent, removeRecent]);
useEffect(() => { useEffect(() => {
const fetchPeople = async () => { const fetchPeople = async () => {
try { try {
const response = await api.get(`/event/${id}/people`); const response = await api.get(`/event/${id}/people`);
const adjustedPeople = response.data.people.map(person => ({ const adjustedPeople = response.data.people.map(person => ({
...person, ...person,
availability: (!!person.availability.length && person.availability[0].length === 13) availability: (!!person.availability.length && person.availability[0].length === 13)
? person.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY')) ? person.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY'))
: person.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')), : person.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
})); }));
setPeople(adjustedPeople); setPeople(adjustedPeople);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
if (tab === 'group') { if (tab === 'group') {
fetchPeople(); fetchPeople();
} }
}, [tab, id, timezone]); }, [tab, id, timezone]);
// Convert to timezone and expand minute segments // Convert to timezone and expand minute segments
useEffect(() => { useEffect(() => {
if (event) { if (event) {
const isSpecificDates = event.times[0].length === 13; const isSpecificDates = event.times[0].length === 13;
setTimes(event.times.reduce( setTimes(event.times.reduce(
(allTimes, time) => { (allTimes, time) => {
const date = isSpecificDates ? const date = isSpecificDates ?
dayjs(time, 'HHmm-DDMMYYYY').utc(true).tz(timezone) dayjs(time, 'HHmm-DDMMYYYY').utc(true).tz(timezone)
: dayjs(time, 'HHmm').day(time.substring(5)).utc(true).tz(timezone); : dayjs(time, 'HHmm').day(time.substring(5)).utc(true).tz(timezone);
const format = isSpecificDates ? 'HHmm-DDMMYYYY' : 'HHmm-d'; const format = isSpecificDates ? 'HHmm-DDMMYYYY' : 'HHmm-d';
return [ return [
...allTimes, ...allTimes,
date.minute(0).format(format), date.minute(0).format(format),
date.minute(15).format(format), date.minute(15).format(format),
date.minute(30).format(format), date.minute(30).format(format),
date.minute(45).format(format), date.minute(45).format(format),
]; ];
}, },
[] []
).sort((a, b) => { ).sort((a, b) => {
if (isSpecificDates) { if (isSpecificDates) {
return dayjs(a, 'HHmm-DDMMYYYY').diff(dayjs(b, 'HHmm-DDMMYYYY')); return dayjs(a, 'HHmm-DDMMYYYY').diff(dayjs(b, 'HHmm-DDMMYYYY'));
} else { } else {
@ -142,154 +146,154 @@ const Event = (props) => {
.diff(dayjs(b, 'HHmm').day((parseInt(b.substring(5))-weekStart % 7 + 7) % 7)); .diff(dayjs(b, 'HHmm').day((parseInt(b.substring(5))-weekStart % 7 + 7) % 7));
} }
})); }));
} }
}, [event, timezone, weekStart]); }, [event, timezone, weekStart]);
useEffect(() => { useEffect(() => {
if (!!times.length && !!people.length) { if (!!times.length && !!people.length) {
setMin(times.reduce((min, time) => { setMin(times.reduce((min, time) => {
let total = people.reduce( let total = people.reduce(
(total, person) => person.availability.includes(time) ? total+1 : total, (total, person) => person.availability.includes(time) ? total+1 : total,
0 0
); );
return total < min ? total : min; return total < min ? total : min;
}, },
Infinity Infinity
)); ));
setMax(times.reduce((max, time) => { setMax(times.reduce((max, time) => {
let total = people.reduce( let total = people.reduce(
(total, person) => person.availability.includes(time) ? total+1 : total, (total, person) => person.availability.includes(time) ? total+1 : total,
0 0
); );
return total > max ? total : max; return total > max ? total : max;
}, },
-Infinity -Infinity
)); ));
} }
}, [times, people]); }, [times, people]);
useEffect(() => { useEffect(() => {
if (!!times.length) { if (!!times.length) {
setTimeLabels(times.reduce((labels, datetime) => { setTimeLabels(times.reduce((labels, datetime) => {
const time = datetime.substring(0, 4); const time = datetime.substring(0, 4);
if (labels.includes(time)) return labels; if (labels.includes(time)) return labels;
return [...labels, time]; return [...labels, time];
}, []) }, [])
.sort((a, b) => parseInt(a) - parseInt(b)) .sort((a, b) => parseInt(a) - parseInt(b))
.reduce((labels, time, i, allTimes) => { .reduce((labels, time, i, allTimes) => {
if (time.substring(2) === '30') return [...labels, { label: '', time }]; if (time.substring(2) === '30') return [...labels, { label: '', time }];
if (allTimes.length - 1 === i) return [ if (allTimes.length - 1 === i) return [
...labels, ...labels,
{ label: '', time }, { label: '', time },
{ label: dayjs(time, 'HHmm').add(1, 'hour').format(timeFormat === '12h' ? 'h A' : 'HH'), time: null } { label: dayjs(time, 'HHmm').add(1, 'hour').format(timeFormat === '12h' ? 'h A' : 'HH'), time: null }
]; ];
if (allTimes.length - 1 > i && parseInt(allTimes[i+1].substring(0, 2))-1 > parseInt(time.substring(0, 2))) return [ if (allTimes.length - 1 > i && parseInt(allTimes[i+1].substring(0, 2))-1 > parseInt(time.substring(0, 2))) return [
...labels, ...labels,
{ label: '', time }, { label: '', time },
{ label: dayjs(time, 'HHmm').add(1, 'hour').format(timeFormat === '12h' ? 'h A' : 'HH'), time: 'space' }, { label: dayjs(time, 'HHmm').add(1, 'hour').format(timeFormat === '12h' ? 'h A' : 'HH'), time: 'space' },
{ label: '', time: 'space' }, { label: '', time: 'space' },
{ label: '', time: 'space' }, { label: '', time: 'space' },
]; ];
if (time.substring(2) !== '00') return [...labels, { label: '', time }]; if (time.substring(2) !== '00') return [...labels, { label: '', time }];
return [...labels, { label: dayjs(time, 'HHmm').format(timeFormat === '12h' ? 'h A' : 'HH'), time }]; return [...labels, { label: dayjs(time, 'HHmm').format(timeFormat === '12h' ? 'h A' : 'HH'), time }];
}, [])); }, []));
setDates(times.reduce((allDates, time) => { setDates(times.reduce((allDates, time) => {
if (time.substring(2, 4) !== '00') return allDates; if (time.substring(2, 4) !== '00') return allDates;
const date = time.substring(5); const date = time.substring(5);
if (allDates.includes(date)) return allDates; if (allDates.includes(date)) return allDates;
return [...allDates, date]; return [...allDates, date];
}, [])); }, []));
} }
}, [times, timeFormat, locale]); }, [times, timeFormat, locale]);
useEffect(() => { useEffect(() => {
const fetchUser = async () => { const fetchUser = async () => {
try { try {
const response = await api.post(`/event/${id}/people/${user.name}`, { person: { password } }); const response = await api.post(`/event/${id}/people/${user.name}`, { person: { password } });
const adjustedUser = { const adjustedUser = {
...response.data, ...response.data,
availability: (!!response.data.availability.length && response.data.availability[0].length === 13) availability: (!!response.data.availability.length && response.data.availability[0].length === 13)
? response.data.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY')) ? response.data.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY'))
: response.data.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')), : response.data.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
}; };
setUser(adjustedUser); setUser(adjustedUser);
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} }
}; };
if (user) { if (user) {
fetchUser(); fetchUser();
} }
// eslint-disable-next-line // eslint-disable-next-line
}, [timezone]); }, [timezone]);
const onSubmit = async data => { const onSubmit = async data => {
if (!data.name || data.name.length === 0) { if (!data.name || data.name.length === 0) {
setFocus('name'); setFocus('name');
return setError(t('event:form.errors.name_required')); return setError(t('event:form.errors.name_required'));
} }
setIsLoginLoading(true); setIsLoginLoading(true);
setError(null); setError(null);
try { try {
const response = await api.post(`/event/${id}/people/${data.name}`, { const response = await api.post(`/event/${id}/people/${data.name}`, {
person: { person: {
password: data.password, password: data.password,
}, },
}); });
setPassword(data.password); setPassword(data.password);
const adjustedUser = { const adjustedUser = {
...response.data, ...response.data,
availability: (!!response.data.availability.length && response.data.availability[0].length === 13) availability: (!!response.data.availability.length && response.data.availability[0].length === 13)
? response.data.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY')) ? response.data.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY'))
: response.data.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')), : response.data.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
}; };
setUser(adjustedUser); setUser(adjustedUser);
setTab('you'); setTab('you');
} catch (e) { } catch (e) {
if (e.status === 401) { if (e.status === 401) {
setError(t('event:form.errors.password_incorrect')); setError(t('event:form.errors.password_incorrect'));
} else if (e.status === 404) { } else if (e.status === 404) {
// Create user // Create user
try { try {
await api.post(`/event/${id}/people`, { await api.post(`/event/${id}/people`, {
person: { person: {
name: data.name, name: data.name,
password: data.password, password: data.password,
}, },
}); });
setPassword(data.password); setPassword(data.password);
setUser({ setUser({
name: data.name, name: data.name,
availability: [], availability: [],
}); });
setTab('you'); setTab('you');
} catch (e) { } catch (e) {
setError(t('event:form.errors.unknown')); setError(t('event:form.errors.unknown'));
} }
} }
} finally { } finally {
setIsLoginLoading(false); setIsLoginLoading(false);
gtag('event', 'login', { gtag('event', 'login', {
'event_category': 'event', 'event_category': 'event',
}); });
reset(); reset();
} }
}; };
return ( return (
<> <>
<StyledMain> <StyledMain>
<Logo /> <Logo />
{(!!event || isLoading) ? ( {(!!event || isLoading) ? (
<> <>
<EventName isLoading={isLoading}>{event?.name}</EventName> <EventName isLoading={isLoading}>{event?.name}</EventName>
<EventDate isLoading={isLoading} locale={locale} title={event?.created && dayjs.unix(event?.created).format('D MMMM, YYYY')}>{event?.created && t('common:created', { date: dayjs.unix(event?.created).fromNow() })}</EventDate> <EventDate isLoading={isLoading} locale={locale} title={event?.created && dayjs.unix(event?.created).format('D MMMM, YYYY')}>{event?.created && t('common:created', { date: dayjs.unix(event?.created).fromNow() })}</EventDate>
<ShareInfo <ShareInfo
onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${id}`) onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${id}`)
.then(() => { .then(() => {
setCopied(t('event:nav.copied')); setCopied(t('event:nav.copied'));
@ -302,81 +306,81 @@ const Event = (props) => {
} }
title={!!navigator.clipboard ? t('event:nav.title') : ''} title={!!navigator.clipboard ? t('event:nav.title') : ''}
>{copied ?? `https://crab.fit/${id}`}</ShareInfo> >{copied ?? `https://crab.fit/${id}`}</ShareInfo>
<ShareInfo isLoading={isLoading}> <ShareInfo isLoading={isLoading} className="instructions">
{!!event?.name && {!!event?.name &&
<Trans i18nKey="event:nav.shareinfo">Copy the link to this page, or share via <a onClick={() => gtag('event', 'send_email', { 'event_category': 'event' })} href={`mailto:?subject=${encodeURIComponent(t('event:nav.email_subject', { event_name: event?.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} https://crab.fit/${id}`)}`}>email</a>.</Trans> <Trans i18nKey="event:nav.shareinfo">Copy the link to this page, or share via <a onClick={() => gtag('event', 'send_email', { 'event_category': 'event' })} href={`mailto:?subject=${encodeURIComponent(t('event:nav.email_subject', { event_name: event?.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} https://crab.fit/${id}`)}`}>email</a>.</Trans>
} }
</ShareInfo> </ShareInfo>
</> </>
) : ( ) : (
offline ? ( offline ? (
<div style={{ margin: '100px 0' }}> <div style={{ margin: '100px 0' }}>
<EventName>{t('event:offline.title')}</EventName> <EventName>{t('event:offline.title')}</EventName>
<ShareInfo><Trans i18nKey="event:offline.body" /></ShareInfo> <ShareInfo><Trans i18nKey="event:offline.body" /></ShareInfo>
</div> </div>
) : ( ) : (
<div style={{ margin: '100px 0' }}> <div style={{ margin: '100px 0' }}>
<EventName>{t('event:error.title')}</EventName> <EventName>{t('event:error.title')}</EventName>
<ShareInfo>{t('event:error.body')}</ShareInfo> <ShareInfo>{t('event:error.body')}</ShareInfo>
</div> </div>
) )
)} )}
</StyledMain> </StyledMain>
{(!!event || isLoading) && ( {(!!event || isLoading) && (
<> <>
<LoginSection id="login"> <LoginSection id="login">
<StyledMain> <StyledMain>
{user ? ( {user ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '20px 0', flexWrap: 'wrap', gap: '10px' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '20px 0', flexWrap: 'wrap', gap: '10px' }}>
<h2 style={{ margin: 0 }}>{t('event:form.signed_in', { name: user.name })}</h2> <h2 style={{ margin: 0 }}>{t('event:form.signed_in', { name: user.name })}</h2>
<Button small onClick={() => { <Button small onClick={() => {
setTab('group'); setTab('group');
setUser(null); setUser(null);
setPassword(null); setPassword(null);
}}>{t('event:form.logout_button')}</Button> }}>{t('event:form.logout_button')}</Button>
</div> </div>
) : ( ) : (
<> <>
<h2>{t('event:form.signed_out')}</h2> <h2>{t('event:form.signed_out')}</h2>
<LoginForm onSubmit={handleSubmit(onSubmit)}> <LoginForm onSubmit={handleSubmit(onSubmit)}>
<TextField <TextField
label={t('event:form.name')} label={t('event:form.name')}
type="text" type="text"
id="name" id="name"
inline inline
required required
{...register('name')} {...register('name')}
/> />
<TextField <TextField
label={t('event:form.password')} label={t('event:form.password')}
type="password" type="password"
id="password" id="password"
inline inline
{...register('password')} {...register('password')}
/> />
<Button <Button
type="submit" type="submit"
isLoading={isLoginLoading} isLoading={isLoginLoading}
disabled={isLoginLoading || isLoading} disabled={isLoginLoading || isLoading}
>{t('event:form.button')}</Button> >{t('event:form.button')}</Button>
</LoginForm> </LoginForm>
<Error open={!!error} onClose={() => setError(null)}>{error}</Error> <Error open={!!error} onClose={() => setError(null)}>{error}</Error>
<Info>{t('event:form.info')}</Info> <Info>{t('event:form.info')}</Info>
</> </>
)} )}
<SelectField <SelectField
label={t('event:form.timezone')} label={t('event:form.timezone')}
name="timezone" name="timezone"
id="timezone" id="timezone"
inline inline
value={timezone} value={timezone}
onChange={event => setTimezone(event.currentTarget.value)} onChange={event => setTimezone(event.currentTarget.value)}
options={timezones} options={timezones}
/> />
{/* eslint-disable-next-line */} {/* eslint-disable-next-line */}
{event?.timezone && event.timezone !== timezone && <p><Trans i18nKey="event:form.created_in_timezone">This event was created in the timezone <strong>{{timezone: event.timezone}}</strong>. <a href="#" onClick={e => { {event?.timezone && event.timezone !== timezone && <p><Trans i18nKey="event:form.created_in_timezone">This event was created in the timezone <strong>{{timezone: event.timezone}}</strong>. <a href="#" onClick={e => {
e.preventDefault(); e.preventDefault();
@ -395,84 +399,84 @@ const Event = (props) => {
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone); setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}}>Click here</a> to use it.</Trans></p> }}>Click here</a> to use it.</Trans></p>
)} )}
</StyledMain> </StyledMain>
</LoginSection> </LoginSection>
<StyledMain> <StyledMain>
<Tabs> <Tabs>
<Tab <Tab
href="#you" href="#you"
onClick={e => { onClick={e => {
e.preventDefault(); e.preventDefault();
if (user) { if (user) {
setTab('you'); setTab('you');
} else { } else {
setFocus('name'); setFocus('name');
} }
}} }}
selected={tab === 'you'} selected={tab === 'you'}
disabled={!user} disabled={!user}
title={user ? '' : t('event:tabs.you_tooltip')} title={user ? '' : t('event:tabs.you_tooltip')}
>{t('event:tabs.you')}</Tab> >{t('event:tabs.you')}</Tab>
<Tab <Tab
href="#group" href="#group"
onClick={e => { onClick={e => {
e.preventDefault(); e.preventDefault();
setTab('group'); setTab('group');
}} }}
selected={tab === 'group'} selected={tab === 'group'}
>{t('event:tabs.group')}</Tab> >{t('event:tabs.group')}</Tab>
</Tabs> </Tabs>
</StyledMain> </StyledMain>
{tab === 'group' ? ( {tab === 'group' ? (
<section id="group"> <section id="group">
<AvailabilityViewer <AvailabilityViewer
times={times} times={times}
timeLabels={timeLabels} timeLabels={timeLabels}
dates={dates} dates={dates}
isSpecificDates={!!dates.length && dates[0].length === 8} isSpecificDates={!!dates.length && dates[0].length === 8}
people={people.filter(p => p.availability.length > 0)} people={people.filter(p => p.availability.length > 0)}
min={min} min={min}
max={max} max={max}
/> />
</section> </section>
) : ( ) : (
<section id="you"> <section id="you">
<AvailabilityEditor <AvailabilityEditor
times={times} times={times}
timeLabels={timeLabels} timeLabels={timeLabels}
dates={dates} dates={dates}
timezone={timezone} timezone={timezone}
isSpecificDates={!!dates.length && dates[0].length === 8} isSpecificDates={!!dates.length && dates[0].length === 8}
value={user.availability} value={user.availability}
onChange={async availability => { onChange={async availability => {
const oldAvailability = [...user.availability]; const oldAvailability = [...user.availability];
const utcAvailability = (!!availability.length && availability[0].length === 13) const utcAvailability = (!!availability.length && availability[0].length === 13)
? availability.map(date => dayjs.tz(date, 'HHmm-DDMMYYYY', timezone).utc().format('HHmm-DDMMYYYY')) ? availability.map(date => dayjs.tz(date, 'HHmm-DDMMYYYY', timezone).utc().format('HHmm-DDMMYYYY'))
: availability.map(date => dayjs.tz(date, 'HHmm', timezone).day(date.substring(5)).utc().format('HHmm-d')); : availability.map(date => dayjs.tz(date, 'HHmm', timezone).day(date.substring(5)).utc().format('HHmm-d'));
setUser({ ...user, availability }); setUser({ ...user, availability });
try { try {
await api.patch(`/event/${id}/people/${user.name}`, { await api.patch(`/event/${id}/people/${user.name}`, {
person: { person: {
password, password,
availability: utcAvailability, availability: utcAvailability,
}, },
}); });
} catch (e) { } catch (e) {
console.log(e); console.log(e);
setUser({ ...user, oldAvailability }); setUser({ ...user, oldAvailability });
} }
}} }}
/> />
</section> </section>
)} )}
</> </>
)} )}
<Footer /> <Footer />
</> </>
); );
}; };
export default Event; export default Event;

View file

@ -1,21 +1,21 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const EventName = styled.h1` export const EventName = styled.h1`
text-align: center; text-align: center;
font-weight: 800; font-weight: 800;
margin: 20px 0 5px; margin: 20px 0 5px;
${props => props.isLoading && ` ${props => props.isLoading && `
&:after { &:after {
content: ''; content: '';
display: inline-block; display: inline-block;
height: 1em; height: 1em;
width: 400px; width: 400px;
max-width: 100%; max-width: 100%;
background-color: ${props.theme.loading}; background-color: ${props.theme.loading};
border-radius: 3px; border-radius: 3px;
} }
`} `}
`; `;
export const EventDate = styled.span` export const EventDate = styled.span`
@ -28,63 +28,73 @@ export const EventDate = styled.span`
letter-spacing: .01em; letter-spacing: .01em;
${props => props.isLoading && ` ${props => props.isLoading && `
&:after { &:after {
content: ''; content: '';
display: inline-block; display: inline-block;
height: 1em; height: 1em;
width: 200px; width: 200px;
max-width: 100%; max-width: 100%;
background-color: ${props.theme.loading}; background-color: ${props.theme.loading};
border-radius: 3px; border-radius: 3px;
} }
`} `}
@media print {
&::after {
content: ' - ' attr(title);
}
}
`; `;
export const LoginForm = styled.form` export const LoginForm = styled.form`
display: grid; display: grid;
grid-template-columns: 1fr 1fr auto; grid-template-columns: 1fr 1fr auto;
align-items: flex-end; align-items: flex-end;
grid-gap: 18px; grid-gap: 18px;
@media (max-width: 500px) { @media (max-width: 500px) {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
@media (max-width: 400px) { @media (max-width: 400px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
& div:last-child { & div:last-child {
--btn-width: 100%; --btn-width: 100%;
} }
} }
`; `;
export const LoginSection = styled.section` export const LoginSection = styled.section`
background-color: ${props => props.theme.primaryBackground}; background-color: ${props => props.theme.primaryBackground};
padding: 10px 0; padding: 10px 0;
@media print {
display: none;
}
`; `;
export const Info = styled.p` export const Info = styled.p`
margin: 18px 0; margin: 18px 0;
opacity: .6; opacity: .6;
font-size: 12px; font-size: 12px;
`; `;
export const ShareInfo = styled.p` export const ShareInfo = styled.p`
margin: 6px 0; margin: 6px 0;
text-align: center; text-align: center;
font-size: 15px; font-size: 15px;
${props => props.isLoading && ` ${props => props.isLoading && `
&:after { &:after {
content: ''; content: '';
display: inline-block; display: inline-block;
height: 1em; height: 1em;
width: 300px; width: 300px;
max-width: 100%; max-width: 100%;
background-color: ${props.theme.loading}; background-color: ${props.theme.loading};
border-radius: 3px; border-radius: 3px;
} }
`} `}
${props => props.onClick && ` ${props => props.onClick && `
cursor: pointer; cursor: pointer;
@ -93,36 +103,46 @@ export const ShareInfo = styled.p`
color: ${props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight}; color: ${props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
} }
`} `}
@media print {
&.instructions {
display: none;
}
}
`; `;
export const Tabs = styled.div` export const Tabs = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 30px 0 20px; margin: 30px 0 20px;
@media print {
display: none;
}
`; `;
export const Tab = styled.a` export const Tab = styled.a`
user-select: none; user-select: none;
text-decoration: none; text-decoration: none;
display: block; display: block;
color: ${props => props.theme.text}; color: ${props => props.theme.text};
padding: 8px 18px; padding: 8px 18px;
background-color: ${props => props.theme.primaryBackground}; background-color: ${props => props.theme.primaryBackground};
border: 1px solid ${props => props.theme.primary}; border: 1px solid ${props => props.theme.primary};
border-bottom: 0; border-bottom: 0;
margin: 0 4px; margin: 0 4px;
border-top-left-radius: 5px; border-top-left-radius: 5px;
border-top-right-radius: 5px; border-top-right-radius: 5px;
${props => props.selected && ` ${props => props.selected && `
color: #FFF; color: #FFF;
background-color: ${props.theme.primary}; background-color: ${props.theme.primary};
border-color: ${props.theme.primary}; border-color: ${props.theme.primary};
`} `}
${props => props.disabled && ` ${props => props.disabled && `
opacity: .5; opacity: .5;
cursor: not-allowed; cursor: not-allowed;
`} `}
`; `;

View file

@ -3,41 +3,42 @@ import { Link, useHistory } from 'react-router-dom';
import { useTranslation, Trans } from 'react-i18next'; import { useTranslation, Trans } from 'react-i18next';
import { import {
Button, Button,
Center, Center,
Footer, Footer,
AvailabilityViewer, AvailabilityViewer,
Logo, Logo,
} from 'components'; } from 'components';
import { import {
StyledMain, StyledMain,
AboutSection, AboutSection,
P, P,
} from '../Home/homeStyle'; } from '../Home/homeStyle';
import { import {
Step, Step,
FakeCalendar, FakeCalendar,
FakeTimeRange, FakeTimeRange,
ButtonArea,
} from './helpStyle'; } from './helpStyle';
const Help = () => { const Help = () => {
const { push } = useHistory(); const { push } = useHistory();
const { t } = useTranslation(['common', 'help']); const { t } = useTranslation(['common', 'help']);
useEffect(() => { useEffect(() => {
document.title = t('help:name'); document.title = t('help:name');
}, [t]); }, [t]);
return ( return (
<> <>
<StyledMain> <StyledMain>
<Logo /> <Logo />
</StyledMain> </StyledMain>
<StyledMain> <StyledMain>
<h1>{t('help:name')}</h1> <h1>{t('help:name')}</h1>
<P>{t('help:p1')}</P> <P>{t('help:p1')}</P>
<P>{t('help:p2')}</P> <P>{t('help:p2')}</P>
@ -80,17 +81,19 @@ const Help = () => {
min={0} min={0}
max={5} max={5}
/> />
</StyledMain> </StyledMain>
<AboutSection id="about"> <ButtonArea>
<StyledMain> <AboutSection id="about">
<Center><Button onClick={() => push('/')}>{t('common:cta')}</Button></Center> <StyledMain>
</StyledMain> <Center><Button onClick={() => push('/')}>{t('common:cta')}</Button></Center>
</AboutSection> </StyledMain>
</AboutSection>
</ButtonArea>
<Footer /> <Footer />
</> </>
); );
}; };
export default Help; export default Help;

View file

@ -1,43 +1,43 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const Step = styled.h2` export const Step = styled.h2`
text-decoration-color: ${props => props.theme.primary}; text-decoration-color: ${props => props.theme.primary};
text-decoration-style: solid; text-decoration-style: solid;
text-decoration-line: underline; text-decoration-line: underline;
margin-top: 30px; margin-top: 30px;
`; `;
export const FakeCalendar = styled.div` export const FakeCalendar = styled.div`
user-select: none; user-select: none;
& div { & div {
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
grid-gap: 2px; grid-gap: 2px;
} }
& .days span { & .days span {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 3px 0; padding: 3px 0;
font-weight: bold; font-weight: bold;
user-select: none; user-select: none;
opacity: .7; opacity: .7;
@media (max-width: 350px) { @media (max-width: 350px) {
font-size: 12px; font-size: 12px;
} }
} }
& .dates span { & .dates span {
background-color: ${props => props.theme.primaryBackground}; background-color: ${props => props.theme.primaryBackground};
border: 1px solid ${props => props.theme.primary}; border: 1px solid ${props => props.theme.primary};
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 10px 0; padding: 10px 0;
&.selected { &.selected {
color: #FFF; color: #FFF;
background-color: ${props => props.theme.primary}; background-color: ${props => props.theme.primary};
} }
} }
& .dates span:first-of-type { & .dates span:first-of-type {
@ -51,45 +51,45 @@ export const FakeCalendar = styled.div`
`; `;
export const FakeTimeRange = styled.div` export const FakeTimeRange = styled.div`
user-select: none; user-select: none;
background-color: ${props => props.theme.primaryBackground}; background-color: ${props => props.theme.primaryBackground};
border: 1px solid ${props => props.theme.primary}; border: 1px solid ${props => props.theme.primary};
border-radius: 3px; border-radius: 3px;
height: 50px; height: 50px;
position: relative; position: relative;
margin: 38px 6px 18px; margin: 38px 6px 18px;
& div { & div {
height: calc(100% + 20px); height: calc(100% + 20px);
width: 20px; width: 20px;
border: 1px solid ${props => props.theme.primary}; border: 1px solid ${props => props.theme.primary};
background-color: ${props => props.theme.primaryLight}; background-color: ${props => props.theme.primaryLight};
border-radius: 3px; border-radius: 3px;
position: absolute; position: absolute;
top: -10px; top: -10px;
&:after { &:after {
content: '|||'; content: '|||';
font-size: 8px; font-size: 8px;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: ${props => props.theme.primaryDark}; color: ${props => props.theme.primaryDark};
} }
&:before { &:before {
content: attr(data-label); content: attr(data-label);
position: absolute; position: absolute;
bottom: calc(100% + 8px); bottom: calc(100% + 8px);
text-align: center; text-align: center;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
} }
} }
& .start { & .start {
left: calc(${11 * 4.1666666666666666}% - 11px); left: calc(${11 * 4.1666666666666666}% - 11px);
@ -100,11 +100,17 @@ export const FakeTimeRange = styled.div`
&:before { &:before {
content: ''; content: '';
position: absolute; position: absolute;
height: 100%; height: 100%;
left: ${11 * 4.1666666666666666}%; left: ${11 * 4.1666666666666666}%;
right: calc(100% - ${17 * 4.1666666666666666}%); right: calc(100% - ${17 * 4.1666666666666666}%);
top: 0; top: 0;
background-color: ${props => props.theme.primary}; background-color: ${props => props.theme.primary};
border-radius: 2px; border-radius: 2px;
}
`;
export const ButtonArea = styled.div`
@media print {
display: none;
} }
`; `;

View file

@ -9,36 +9,37 @@ import timezone from 'dayjs/plugin/timezone';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
import { import {
TextField, TextField,
CalendarField, CalendarField,
TimeRangeField, TimeRangeField,
SelectField, SelectField,
Button, Button,
Center, Center,
Error, Error,
Footer, Footer,
Recents, Recents,
} from 'components'; } from 'components';
import { import {
StyledMain, StyledMain,
CreateForm, CreateForm,
TitleSmall, TitleSmall,
TitleLarge, TitleLarge,
Logo, Logo,
Links, Links,
AboutSection, AboutSection,
P, P,
Stats, Stats,
Stat, Stat,
StatNumber, StatNumber,
StatLabel, StatLabel,
OfflineMessage, OfflineMessage,
ButtonArea, ButtonArea,
} from './homeStyle'; } from './homeStyle';
import api from 'services'; import api from 'services';
import { detect_browser } from 'utils'; import { detect_browser } from 'utils';
import { useTWAStore } from 'stores';
import logo from 'res/logo.svg'; import logo from 'res/logo.svg';
import timezones from 'res/timezones.json'; import timezones from 'res/timezones.json';
@ -48,119 +49,120 @@ dayjs.extend(timezone);
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
const Home = ({ offline }) => { const Home = ({ offline }) => {
const { register, handleSubmit, setValue } = useForm({ const { register, handleSubmit, setValue } = useForm({
defaultValues: { defaultValues: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}, },
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [stats, setStats] = useState({ const [stats, setStats] = useState({
eventCount: null, eventCount: null,
personCount: null, personCount: null,
version: 'loading...', version: 'loading...',
}); });
const [browser, setBrowser] = useState(undefined); const [browser, setBrowser] = useState(undefined);
const { push } = useHistory(); const { push } = useHistory();
const { t } = useTranslation(['common', 'home']); const { t } = useTranslation(['common', 'home']);
const isTWA = useTWAStore(state => state.TWA);
useEffect(() => { useEffect(() => {
const fetch = async () => { const fetch = async () => {
try { try {
const response = await api.get('/stats'); const response = await api.get('/stats');
setStats(response.data); setStats(response.data);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
}; };
fetch(); fetch();
document.title = 'Crab Fit'; document.title = 'Crab Fit';
setBrowser(detect_browser()); setBrowser(detect_browser());
}, []); }, []);
const onSubmit = async data => { const onSubmit = async data => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const { start, end } = JSON.parse(data.times); const { start, end } = JSON.parse(data.times);
const dates = JSON.parse(data.dates); const dates = JSON.parse(data.dates);
if (dates.length === 0) { if (dates.length === 0) {
return setError(t('home:form.errors.no_dates')); return setError(t('home:form.errors.no_dates'));
} }
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8; const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8;
if (start === end) { if (start === end) {
return setError(t('home:form.errors.same_times')); return setError(t('home:form.errors.same_times'));
} }
let times = dates.reduce((times, date) => { let times = dates.reduce((times, date) => {
let day = []; let day = [];
for (let i = start; i < (start > end ? 24 : end); i++) { for (let i = start; i < (start > end ? 24 : end); i++) {
if (isSpecificDates) { if (isSpecificDates) {
day.push( day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone) dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY') .hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
); );
} else { } else {
day.push( day.push(
dayjs().tz(data.timezone) dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d') .day(date).hour(i).minute(0).utc().format('HHmm-d')
); );
} }
} }
if (start > end) { if (start > end) {
for (let i = 0; i < end; i++) { for (let i = 0; i < end; i++) {
if (isSpecificDates) { if (isSpecificDates) {
day.push( day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone) dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY') .hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
); );
} else { } else {
day.push( day.push(
dayjs().tz(data.timezone) dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d') .day(date).hour(i).minute(0).utc().format('HHmm-d')
); );
} }
} }
} }
return [...times, ...day]; return [...times, ...day];
}, []); }, []);
if (times.length === 0) { if (times.length === 0) {
return setError(t('home:form.errors.no_time')); return setError(t('home:form.errors.no_time'));
} }
const response = await api.post('/event', { const response = await api.post('/event', {
event: { event: {
name: data.name, name: data.name,
times: times, times: times,
timezone: data.timezone, timezone: data.timezone,
}, },
}); });
push(`/${response.data.id}`); push(`/${response.data.id}`);
gtag('event', 'create_event', { gtag('event', 'create_event', {
'event_category': 'home', 'event_category': 'home',
}); });
} catch (e) { } catch (e) {
setError(t('home:form.errors.unknown')); setError(t('home:form.errors.unknown'));
console.error(e); console.error(e);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
return ( return (
<> <>
<StyledMain> <StyledMain>
<Center> <Center>
<Logo src={logo} alt="" /> <Logo src={logo} alt="" />
</Center> </Center>
<TitleSmall altChars={/[A-Z]/g.test(t('home:create'))}>{t('home:create')}</TitleSmall> <TitleSmall altChars={/[A-Z]/g.test(t('home:create'))}>{t('home:create')}</TitleSmall>
<TitleLarge>CRAB FIT</TitleLarge> <TitleLarge>CRAB FIT</TitleLarge>
<Links> <Links>
<a href="#about">{t('home:nav.about')}</a> / <a href="#donate">{t('home:nav.donate')}</a> <a href="#about">{t('home:nav.about')}</a> / <a href="#donate">{t('home:nav.donate')}</a>
</Links> </Links>
</StyledMain> </StyledMain>
<Recents /> <Recents />
@ -172,107 +174,109 @@ const Home = ({ offline }) => {
<P>{t('home:offline')}</P> <P>{t('home:offline')}</P>
</OfflineMessage> </OfflineMessage>
) : ( ) : (
<CreateForm onSubmit={handleSubmit(onSubmit)} id="create"> <CreateForm onSubmit={handleSubmit(onSubmit)} id="create">
<TextField <TextField
label={t('home:form.name.label')} label={t('home:form.name.label')}
subLabel={t('home:form.name.sublabel')} subLabel={t('home:form.name.sublabel')}
type="text" type="text"
id="name" id="name"
{...register('name')} {...register('name')}
/> />
<CalendarField <CalendarField
label={t('home:form.dates.label')} label={t('home:form.dates.label')}
subLabel={t('home:form.dates.sublabel')} subLabel={t('home:form.dates.sublabel')}
id="dates" id="dates"
required required
setValue={setValue} setValue={setValue}
{...register('dates')} {...register('dates')}
/> />
<TimeRangeField <TimeRangeField
label={t('home:form.times.label')} label={t('home:form.times.label')}
subLabel={t('home:form.times.sublabel')} subLabel={t('home:form.times.sublabel')}
id="times" id="times"
required required
setValue={setValue} setValue={setValue}
{...register('times')} {...register('times')}
/> />
<SelectField <SelectField
label={t('home:form.timezone.label')} label={t('home:form.timezone.label')}
id="timezone" id="timezone"
options={timezones} options={timezones}
required required
{...register('timezone')} {...register('timezone')}
defaultOption={t('home:form.timezone.defaultOption')} defaultOption={t('home:form.timezone.defaultOption')}
/> />
<Error open={!!error} onClose={() => setError(null)}>{error}</Error> <Error open={!!error} onClose={() => setError(null)}>{error}</Error>
<Center> <Center>
<Button type="submit" isLoading={isLoading} disabled={isLoading}>{t('home:form.button')}</Button> <Button type="submit" isLoading={isLoading} disabled={isLoading}>{t('home:form.button')}</Button>
</Center> </Center>
</CreateForm> </CreateForm>
)} )}
</StyledMain> </StyledMain>
<AboutSection id="about"> <AboutSection id="about">
<StyledMain> <StyledMain>
<h2>{t('home:about.name')}</h2> <h2>{t('home:about.name')}</h2>
<Stats> <Stats>
<Stat> <Stat>
<StatNumber>{stats.eventCount ?? '350+'}</StatNumber> <StatNumber>{stats.eventCount ?? '350+'}</StatNumber>
<StatLabel>{t('home:about.events')}</StatLabel> <StatLabel>{t('home:about.events')}</StatLabel>
</Stat> </Stat>
<Stat> <Stat>
<StatNumber>{stats.personCount ?? '550+'}</StatNumber> <StatNumber>{stats.personCount ?? '550+'}</StatNumber>
<StatLabel>{t('home:about.availabilities')}</StatLabel> <StatLabel>{t('home:about.availabilities')}</StatLabel>
</Stat> </Stat>
</Stats> </Stats>
<P><Trans i18nKey="home:about.content.p1">Crab Fit helps you fit your event around everyone's schedules. Simply create an event above and send the link to everyone that is participating. Results update live and you will be able to see a heat-map of when everyone is free.<br /><Link to="/how-to" rel="help">Learn more about how to Crab Fit</Link>.</Trans></P> <P><Trans i18nKey="home:about.content.p1">Crab Fit helps you fit your event around everyone's schedules. Simply create an event above and send the link to everyone that is participating. Results update live and you will be able to see a heat-map of when everyone is free.<br /><Link to="/how-to" rel="help">Learn more about how to Crab Fit</Link>.</Trans></P>
<ButtonArea> {isTWA !== true && (
{['chrome', 'firefox', 'safari'].includes(browser) && ( <ButtonArea>
{['chrome', 'firefox', 'safari'].includes(browser) && (
<Button
href={{
chrome: 'https://chrome.google.com/webstore/detail/crab-fit/pnafiibmjbiljofcpjlbonpgdofjhhkj',
firefox: 'https://addons.mozilla.org/en-US/firefox/addon/crab-fit/',
safari: 'https://apps.apple.com/us/app/crab-fit/id1570803259',
}[browser]}
icon={{
chrome: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" /></svg>,
firefox: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M9.27 7.94C9.27 7.94 9.27 7.94 9.27 7.94M6.85 6.74C6.86 6.74 6.86 6.74 6.85 6.74M21.28 8.6C20.85 7.55 19.96 6.42 19.27 6.06C19.83 7.17 20.16 8.28 20.29 9.1L20.29 9.12C19.16 6.3 17.24 5.16 15.67 2.68C15.59 2.56 15.5 2.43 15.43 2.3C15.39 2.23 15.36 2.16 15.32 2.09C15.26 1.96 15.2 1.83 15.17 1.69C15.17 1.68 15.16 1.67 15.15 1.67H15.13L15.12 1.67L15.12 1.67L15.12 1.67C12.9 2.97 11.97 5.26 11.74 6.71C11.05 6.75 10.37 6.92 9.75 7.22C9.63 7.27 9.58 7.41 9.62 7.53C9.67 7.67 9.83 7.74 9.96 7.68C10.5 7.42 11.1 7.27 11.7 7.23L11.75 7.23C11.83 7.22 11.92 7.22 12 7.22C12.5 7.21 12.97 7.28 13.44 7.42L13.5 7.44C13.6 7.46 13.67 7.5 13.75 7.5C13.8 7.54 13.86 7.56 13.91 7.58L14.05 7.64C14.12 7.67 14.19 7.7 14.25 7.73C14.28 7.75 14.31 7.76 14.34 7.78C14.41 7.82 14.5 7.85 14.54 7.89C14.58 7.91 14.62 7.94 14.66 7.96C15.39 8.41 16 9.03 16.41 9.77C15.88 9.4 14.92 9.03 14 9.19C17.6 11 16.63 17.19 11.64 16.95C11.2 16.94 10.76 16.85 10.34 16.7C10.24 16.67 10.14 16.63 10.05 16.58C10 16.56 9.93 16.53 9.88 16.5C8.65 15.87 7.64 14.68 7.5 13.23C7.5 13.23 8 11.5 10.83 11.5C11.14 11.5 12 10.64 12.03 10.4C12.03 10.31 10.29 9.62 9.61 8.95C9.24 8.59 9.07 8.42 8.92 8.29C8.84 8.22 8.75 8.16 8.66 8.1C8.43 7.3 8.42 6.45 8.63 5.65C7.6 6.12 6.8 6.86 6.22 7.5H6.22C5.82 7 5.85 5.35 5.87 5C5.86 5 5.57 5.16 5.54 5.18C5.19 5.43 4.86 5.71 4.56 6C4.21 6.37 3.9 6.74 3.62 7.14C3 8.05 2.5 9.09 2.28 10.18C2.28 10.19 2.18 10.59 2.11 11.1L2.08 11.33C2.06 11.5 2.04 11.65 2 11.91L2 11.94L2 12.27L2 12.32C2 17.85 6.5 22.33 12 22.33C16.97 22.33 21.08 18.74 21.88 14C21.9 13.89 21.91 13.76 21.93 13.63C22.13 11.91 21.91 10.11 21.28 8.6Z" /></svg>,
safari: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,14.09 4.8,16 6.11,17.41L9.88,9.88L17.41,6.11C16,4.8 14.09,4 12,4M12,20A8,8 0 0,0 20,12C20,9.91 19.2,8 17.89,6.59L14.12,14.12L6.59,17.89C8,19.2 9.91,20 12,20M12,12L11.23,11.23L9.7,14.3L12.77,12.77L12,12M12,17.5H13V19H12V17.5M15.88,15.89L16.59,15.18L17.65,16.24L16.94,16.95L15.88,15.89M17.5,12V11H19V12H17.5M12,6.5H11V5H12V6.5M8.12,8.11L7.41,8.82L6.35,7.76L7.06,7.05L8.12,8.11M6.5,12V13H5V12H6.5Z" /></svg>,
}[browser]}
onClick={() => gtag('event', `download_extension_${browser}`, { 'event_category': 'home'})}
target="_blank"
rel="noreferrer noopener"
secondary
>{{
chrome: t('home:about.chrome_extension'),
firefox: t('home:about.firefox_extension'),
safari: t('home:about.safari_extension'),
}[browser]}</Button>
)}
<Button <Button
href={{ href="https://play.google.com/store/apps/details?id=fit.crab"
chrome: 'https://chrome.google.com/webstore/detail/crab-fit/pnafiibmjbiljofcpjlbonpgdofjhhkj', icon={<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.53,12.9 20.18,13.18L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z" /></svg>}
firefox: 'https://addons.mozilla.org/en-US/firefox/addon/crab-fit/', onClick={() => gtag('event', 'download_android_app', { 'event_category': 'home' })}
safari: 'https://apps.apple.com/us/app/crab-fit/id1570803259',
}[browser]}
icon={{
chrome: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" /></svg>,
firefox: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M9.27 7.94C9.27 7.94 9.27 7.94 9.27 7.94M6.85 6.74C6.86 6.74 6.86 6.74 6.85 6.74M21.28 8.6C20.85 7.55 19.96 6.42 19.27 6.06C19.83 7.17 20.16 8.28 20.29 9.1L20.29 9.12C19.16 6.3 17.24 5.16 15.67 2.68C15.59 2.56 15.5 2.43 15.43 2.3C15.39 2.23 15.36 2.16 15.32 2.09C15.26 1.96 15.2 1.83 15.17 1.69C15.17 1.68 15.16 1.67 15.15 1.67H15.13L15.12 1.67L15.12 1.67L15.12 1.67C12.9 2.97 11.97 5.26 11.74 6.71C11.05 6.75 10.37 6.92 9.75 7.22C9.63 7.27 9.58 7.41 9.62 7.53C9.67 7.67 9.83 7.74 9.96 7.68C10.5 7.42 11.1 7.27 11.7 7.23L11.75 7.23C11.83 7.22 11.92 7.22 12 7.22C12.5 7.21 12.97 7.28 13.44 7.42L13.5 7.44C13.6 7.46 13.67 7.5 13.75 7.5C13.8 7.54 13.86 7.56 13.91 7.58L14.05 7.64C14.12 7.67 14.19 7.7 14.25 7.73C14.28 7.75 14.31 7.76 14.34 7.78C14.41 7.82 14.5 7.85 14.54 7.89C14.58 7.91 14.62 7.94 14.66 7.96C15.39 8.41 16 9.03 16.41 9.77C15.88 9.4 14.92 9.03 14 9.19C17.6 11 16.63 17.19 11.64 16.95C11.2 16.94 10.76 16.85 10.34 16.7C10.24 16.67 10.14 16.63 10.05 16.58C10 16.56 9.93 16.53 9.88 16.5C8.65 15.87 7.64 14.68 7.5 13.23C7.5 13.23 8 11.5 10.83 11.5C11.14 11.5 12 10.64 12.03 10.4C12.03 10.31 10.29 9.62 9.61 8.95C9.24 8.59 9.07 8.42 8.92 8.29C8.84 8.22 8.75 8.16 8.66 8.1C8.43 7.3 8.42 6.45 8.63 5.65C7.6 6.12 6.8 6.86 6.22 7.5H6.22C5.82 7 5.85 5.35 5.87 5C5.86 5 5.57 5.16 5.54 5.18C5.19 5.43 4.86 5.71 4.56 6C4.21 6.37 3.9 6.74 3.62 7.14C3 8.05 2.5 9.09 2.28 10.18C2.28 10.19 2.18 10.59 2.11 11.1L2.08 11.33C2.06 11.5 2.04 11.65 2 11.91L2 11.94L2 12.27L2 12.32C2 17.85 6.5 22.33 12 22.33C16.97 22.33 21.08 18.74 21.88 14C21.9 13.89 21.91 13.76 21.93 13.63C22.13 11.91 21.91 10.11 21.28 8.6Z" /></svg>,
safari: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,14.09 4.8,16 6.11,17.41L9.88,9.88L17.41,6.11C16,4.8 14.09,4 12,4M12,20A8,8 0 0,0 20,12C20,9.91 19.2,8 17.89,6.59L14.12,14.12L6.59,17.89C8,19.2 9.91,20 12,20M12,12L11.23,11.23L9.7,14.3L12.77,12.77L12,12M12,17.5H13V19H12V17.5M15.88,15.89L16.59,15.18L17.65,16.24L16.94,16.95L15.88,15.89M17.5,12V11H19V12H17.5M12,6.5H11V5H12V6.5M8.12,8.11L7.41,8.82L6.35,7.76L7.06,7.05L8.12,8.11M6.5,12V13H5V12H6.5Z" /></svg>,
}[browser]}
onClick={() => gtag('event', `download_extension_${browser}`, { 'event_category': 'home'})}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
secondary secondary
>{{ >{t('home:about.android_app')}</Button>
chrome: t('home:about.chrome_extension'), </ButtonArea>
firefox: t('home:about.firefox_extension'), )}
safari: t('home:about.safari_extension'), <P><Trans i18nKey="home:about.content.p3">Created by <a href="https://bengrant.dev" target="_blank" rel="noreferrer noopener author">Ben Grant</a>, Crab Fit is the modern-day solution to your group event planning debates.</Trans></P>
}[browser]}</Button> <P><Trans i18nKey="home:about.content.p4">The code for Crab Fit is open source, if you find any issues or want to contribute, you can visit the <a href="https://github.com/GRA0007/crab.fit" target="_blank" rel="noreferrer noopener">repository</a>. By using Crab Fit you agree to the <Link to="/privacy" rel="license">privacy policy</Link>.</Trans></P>
)}
<Button
href="https://play.google.com/store/apps/details?id=fit.crab"
icon={<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.53,12.9 20.18,13.18L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z" /></svg>}
onClick={() => gtag('event', 'download_android_app', { 'event_category': 'home' })}
target="_blank"
rel="noreferrer noopener"
secondary
>{t('home:about.android_app')}</Button>
</ButtonArea>
<P><Trans i18nKey="home:about.content.p3">Created by <a href="https://bengrant.dev" target="_blank" rel="noreferrer noopener author">Ben Grant</a>, Crab Fit is the modern-day solution to your group event planning debates.</Trans></P>
<P><Trans i18nKey="home:about.content.p4">The code for Crab Fit is open source, if you find any issues or want to contribute, you can visit the <a href="https://github.com/GRA0007/crab.fit" target="_blank" rel="noreferrer noopener">repository</a>. By using Crab Fit you agree to the <Link to="/privacy" rel="license">privacy policy</Link>.</Trans></P>
<P>{t('home:about.content.p6')}</P> <P>{t('home:about.content.p6')}</P>
<P>{t('home:about.content.p5')}</P> <P>{t('home:about.content.p5')}</P>
</StyledMain> </StyledMain>
</AboutSection> </AboutSection>
<Footer /> <Footer />
</> </>
); );
}; };
export default Home; export default Home;

View file

@ -1,9 +1,9 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const StyledMain = styled.div` export const StyledMain = styled.div`
width: 600px; width: 600px;
margin: 20px auto; margin: 20px auto;
max-width: calc(100% - 60px); max-width: calc(100% - 60px);
`; `;
export const CreateForm = styled.form` export const CreateForm = styled.form`
@ -11,14 +11,14 @@ export const CreateForm = styled.form`
`; `;
export const TitleSmall = styled.span` export const TitleSmall = styled.span`
display: block; display: block;
margin: 0; margin: 0;
font-size: 3rem; font-size: 3rem;
text-align: center; text-align: center;
font-family: 'Samurai Bob', sans-serif; font-family: 'Samurai Bob', sans-serif;
font-weight: 400; font-weight: 400;
color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight}; color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
line-height: 1em; line-height: 1em;
text-transform: uppercase; text-transform: uppercase;
${props => !props.altChars && ` ${props => !props.altChars && `
@ -30,23 +30,23 @@ export const TitleSmall = styled.span`
`; `;
export const TitleLarge = styled.h1` export const TitleLarge = styled.h1`
margin: 0; margin: 0;
font-size: 4rem; font-size: 4rem;
text-align: center; text-align: center;
color: ${props => props.theme.primary}; color: ${props => props.theme.primary};
font-family: 'Molot', sans-serif; font-family: 'Molot', sans-serif;
font-weight: 400; font-weight: 400;
text-shadow: 0 4px 0 ${props => props.theme.primaryDark}; text-shadow: 0 4px 0 ${props => props.theme.primaryDark};
line-height: 1em; line-height: 1em;
text-transform: uppercase; text-transform: uppercase;
@media (max-width: 350px) { @media (max-width: 350px) {
font-size: 3.5rem; font-size: 3.5rem;
} }
`; `;
export const Logo = styled.img` export const Logo = styled.img`
width: 80px; width: 80px;
transition: transform .15s; transition: transform .15s;
animation: jelly .5s 1 .05s; animation: jelly .5s 1 .05s;
user-select: none; user-select: none;
@ -81,14 +81,14 @@ export const Logo = styled.img`
`; `;
export const Links = styled.nav` export const Links = styled.nav`
text-align: center; text-align: center;
margin: 20px 0; margin: 20px 0;
`; `;
export const AboutSection = styled.section` export const AboutSection = styled.section`
margin: 30px 0 0; margin: 30px 0 0;
background-color: ${props => props.theme.primaryBackground}; background-color: ${props => props.theme.primaryBackground};
padding: 20px 0; padding: 20px 0;
& a { & a {
color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight}; color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
@ -96,42 +96,42 @@ export const AboutSection = styled.section`
`; `;
export const P = styled.p` export const P = styled.p`
font-weight: 500; font-weight: 500;
line-height: 1.6em; line-height: 1.6em;
`; `;
export const Stats = styled.div` export const Stats = styled.div`
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
align-items: flex-start; align-items: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
`; `;
export const Stat = styled.div` export const Stat = styled.div`
text-align: center; text-align: center;
padding: 0 6px; padding: 0 6px;
min-width: 160px; min-width: 160px;
margin: 10px 0; margin: 10px 0;
`; `;
export const StatNumber = styled.span` export const StatNumber = styled.span`
display: block; display: block;
font-weight: 900; font-weight: 900;
color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight}; color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
font-size: 2em; font-size: 2em;
`; `;
export const StatLabel = styled.span` export const StatLabel = styled.span`
display: block; display: block;
`; `;
export const OfflineMessage = styled.div` export const OfflineMessage = styled.div`
text-align: center; text-align: center;
margin: 50px 0 20px; margin: 50px 0 20px;
`; `;
export const ButtonArea = styled.div` export const ButtonArea = styled.div`
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View file

@ -3,18 +3,18 @@ import { useHistory } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Button, Button,
Center, Center,
Footer, Footer,
Logo, Logo,
} from 'components'; } from 'components';
import { import {
StyledMain, StyledMain,
AboutSection, AboutSection,
P, P,
} from '../Home/homeStyle'; } from '../Home/homeStyle';
import { Note } from './privacyStyle'; import { Note, ButtonArea } from './privacyStyle';
const translationDisclaimer = 'While the translated document is provided for your convenience, the English version as displayed at https://crab.fit is legally binding.'; const translationDisclaimer = 'While the translated document is provided for your convenience, the English version as displayed at https://crab.fit is legally binding.';
@ -24,20 +24,20 @@ const Privacy = () => {
const contentRef = useRef(); const contentRef = useRef();
const [content, setContent] = useState(''); const [content, setContent] = useState('');
useEffect(() => { useEffect(() => {
document.title = `${t('privacy:name')} - Crab Fit`; document.title = `${t('privacy:name')} - Crab Fit`;
}, [t]); }, [t]);
useEffect(() => setContent(contentRef.current?.innerText || ''), [contentRef]); useEffect(() => setContent(contentRef.current?.innerText || ''), [contentRef]);
return ( return (
<> <>
<StyledMain> <StyledMain>
<Logo /> <Logo />
</StyledMain> </StyledMain>
<StyledMain> <StyledMain>
<h1>{t('privacy:name')}</h1> <h1>{t('privacy:name')}</h1>
{!i18n.language.startsWith('en') && ( {!i18n.language.startsWith('en') && (
<p> <p>
@ -58,9 +58,9 @@ const Privacy = () => {
<h2>Information Collection and Use</h2> <h2>Information Collection and Use</h2>
<P>The Service uses third party services that may collect information used to identify you.</P> <P>The Service uses third party services that may collect information used to identify you.</P>
<P>Links to privacy policies of the third party service providers used by the Service:</P> <P>Links to privacy policies of the third party service providers used by the Service:</P>
<ul> <P as="ul">
<li><a href="https://www.google.com/policies/privacy/" target="blank">Google Play Services</a></li> <li><a href="https://www.google.com/policies/privacy/" target="blank">Google Play Services</a></li>
</ul> </P>
<h2>Log Data</h2> <h2>Log Data</h2>
<P>When you use the Service, in the case of an error, data and information is collected to improve the Service, which may include your IP address, device name, operating system version, app configuration and the time and date of the error.</P> <P>When you use the Service, in the case of an error, data and information is collected to improve the Service, which may include your IP address, device name, operating system version, app configuration and the time and date of the error.</P>
@ -71,12 +71,12 @@ const Privacy = () => {
<h2>Service Providers</h2> <h2>Service Providers</h2>
<P>Third-party companies may be employed for the following reasons:</P> <P>Third-party companies may be employed for the following reasons:</P>
<ul> <P as="ul">
<li>To facilitate the Service</li> <li>To facilitate the Service</li>
<li>To provide the Service on our behalf</li> <li>To provide the Service on our behalf</li>
<li>To perform Service-related services</li> <li>To perform Service-related services</li>
<li>To assist in analyzing how the Service is used</li> <li>To assist in analyzing how the Service is used</li>
</ul> </P>
<P>To perform these tasks, the third parties may have access to your Personal Information, but are obligated not to disclose or use this information for any purpose except the above.</P> <P>To perform these tasks, the third parties may have access to your Personal Information, but are obligated not to disclose or use this information for any purpose except the above.</P>
<h2>Security</h2> <h2>Security</h2>
@ -98,15 +98,17 @@ const Privacy = () => {
</div> </div>
</StyledMain> </StyledMain>
<AboutSection id="about"> <ButtonArea>
<StyledMain> <AboutSection>
<Center><Button onClick={() => push('/')}>{t('common:cta')}</Button></Center> <StyledMain>
</StyledMain> <Center><Button onClick={() => push('/')}>{t('common:cta')}</Button></Center>
</AboutSection> </StyledMain>
</AboutSection>
</ButtonArea>
<Footer /> <Footer />
</> </>
); );
}; };
export default Privacy; export default Privacy;

View file

@ -7,8 +7,16 @@ export const Note = styled.p`
padding: 12px 16px; padding: 12px 16px;
margin: 16px 0; margin: 16px 0;
box-sizing: border-box; box-sizing: border-box;
font-weight: 500;
line-height: 1.6em;
& a { & a {
color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight}; color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
} }
`; `;
export const ButtonArea = styled.div`
@media print {
display: none;
}
`;

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 2443 2500" style="enable-background:new 0 0 2443 2500;" xml:space="preserve"> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 2443 2500" style="enable-background:new 0 0 2443 2500;" xml:space="preserve">
<path style="fill:#FFFFFF" d="M1245.8,1018.7V1481h686.5c-13.8,114.9-88.6,287.9-254.7,404.2v0c-105.2,73.4-246.4,124.6-431.8,124.6 <path style="fill:#FFFFFF" d="M1245.8,1018.7V1481h686.5c-13.8,114.9-88.6,287.9-254.7,404.2v0c-105.2,73.4-246.4,124.6-431.8,124.6
c-329.4,0-609-217.3-708.7-517.7h0l0,0v0c-26.3-77.5-41.5-160.6-41.5-246.4c0-85.8,15.2-168.9,40.1-246.4v0 c-329.4,0-609-217.3-708.7-517.7h0l0,0v0c-26.3-77.5-41.5-160.6-41.5-246.4c0-85.8,15.2-168.9,40.1-246.4v0
c101-300.4,380.6-517.7,710.1-517.7v0c233.9,0,391.7,101,481.7,185.5l351.6-343.3C1863.2,123.2,1582.2,0,1245.8,0 c101-300.4,380.6-517.7,710.1-517.7v0c233.9,0,391.7,101,481.7,185.5l351.6-343.3C1863.2,123.2,1582.2,0,1245.8,0
C758.6,0,337.8,279.6,133,686.5h0h0C48.6,855.4,0.1,1045,0.1,1245.7S48.6,1636,133,1804.9h0l0,0 C758.6,0,337.8,279.6,133,686.5h0h0C48.6,855.4,0.1,1045,0.1,1245.7S48.6,1636,133,1804.9h0l0,0
c204.8,406.9,625.6,686.5,1112.8,686.5c336.3,0,618.7-110.7,824.9-301.7l0,0h0c235.3-217.3,370.9-537,370.9-916.3 c204.8,406.9,625.6,686.5,1112.8,686.5c336.3,0,618.7-110.7,824.9-301.7l0,0h0c235.3-217.3,370.9-537,370.9-916.3
c0-102.4-8.3-177.2-26.3-254.7H1245.8z"/> c0-102.4-8.3-177.2-26.3-254.7H1245.8z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 854 B

After

Width:  |  Height:  |  Size: 859 B

View file

@ -1,43 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"> <svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.cls-1,.cls-4{fill:#f79e00;}.cls-2,.cls-4{isolation:isolate;}.cls-3{fill:#f48600;}</style></defs><path class="cls-1" d="M181.11,311a123,123,0,0,1-123-123h0a123,123,0,0,1,123-123h64.32L211.66,98.73h32.46c0,79.47-108.36,32.47-136.43,50.75C73.65,171.64,181.11,311,181.11,311Z"/><path class="cls-1" d="M404,149.48C375.94,131.2,267.58,178.2,267.58,98.73H300L266.27,65h64.32a123,123,0,0,1,123,123h0a123,123,0,0,1-123,123S438.05,171.64,404,149.48Z"/><rect class="cls-1" x="266.27" y="200.39" width="20.89" height="57.44" rx="10.44"/><rect class="cls-1" x="224.49" y="200.39" width="20.89" height="57.44" rx="10.44"/><path class="cls-1" d="M190.55,229.11H321.11A108.35,108.35,0,0,1,429.47,337.47h0A108.36,108.36,0,0,1,321.11,445.83H190.55A108.37,108.37,0,0,1,82.19,337.47h0A108.36,108.36,0,0,1,190.55,229.11Z"/><g class="cls-2"><path class="cls-3" d="M293.69,268.29a68.83,68.83,0,0,0-37.86,11.29,69.19,69.19,0,1,0,0,115.8,69.19,69.19,0,1,0,37.86-127.09Z"/></g><ellipse class="cls-4" cx="255.83" cy="337.48" rx="31.32" ry="57.9"/><path class="cls-1" d="M402.77,386.23c36,12.31,66,40.07,59.44,60.75C440,431.17,413.11,416.91,389,409.83Z"/><path class="cls-1" d="M420.05,345.89c37.37,7.15,71,30.45,67.35,51.85-24.24-12.55-52.82-22.92-77.67-26.57Z"/><path class="cls-1" d="M417.26,303.44c38.05.39,75.26,17.34,75.5,39-26.08-8-56.05-13.16-81.15-12.32Z"/><path class="cls-1" d="M109.23,386.23c-36,12.31-66,40.07-59.44,60.75C72,431.17,98.89,416.91,123,409.83Z"/><path class="cls-1" d="M92,345.89C54.58,353,21,376.34,24.6,397.74c24.24-12.55,52.82-22.92,77.67-26.57Z"/><path class="cls-1" d="M94.74,303.44c-38,.39-75.26,17.34-75.5,39,26.08-8,56.05-13.16,81.15-12.32Z"/></svg>
<defs>
<style>
.cls-1 {
fill: #f4bb60;
}
.cls-2 {
fill: #f79e00;
}
.cls-3 {
fill: #f47f00;
opacity: 0.5;
}
</style>
</defs>
<g>
<path class="cls-1" d="M183.85,311.62a123,123,0,0,1-123-123h0a123,123,0,0,1,123-123h64.32L214.4,99.33h32.46c0,79.47-108.36,32.47-136.43,50.75C76.39,172.24,183.85,311.62,183.85,311.62Z"/>
<g>
<rect class="cls-2" x="267.75" y="200.99" width="20.89" height="57.44" rx="10.44"/>
<rect class="cls-2" x="225.97" y="200.99" width="20.89" height="57.44" rx="10.44"/>
</g>
<g>
<rect class="cls-1" x="21" y="326.33" width="107.06" height="23.5" rx="11.75"/>
<rect class="cls-1" x="28.83" y="289.77" width="107.06" height="23.5" rx="11.75" transform="translate(91.34 -10.92) rotate(16.92)"/>
<rect class="cls-1" x="26.22" y="362.88" width="107.06" height="23.5" rx="11.75" transform="matrix(0.95, -0.31, 0.31, 0.95, -110.74, 42.33)"/>
<rect class="cls-1" x="45.81" y="394.21" width="107.06" height="23.5" rx="11.75" transform="translate(-215.65 131.26) rotate(-35.16)"/>
</g>
<g>
<rect class="cls-1" x="383.94" y="326.33" width="107.06" height="23.5" rx="11.75" transform="translate(874.94 676.15) rotate(-180)"/>
<rect class="cls-1" x="376.11" y="289.77" width="107.06" height="23.5" rx="11.75" transform="translate(928.45 464.91) rotate(163.08)"/>
<rect class="cls-1" x="378.72" y="362.88" width="107.06" height="23.5" rx="11.75" transform="translate(729.24 863.49) rotate(-162.19)"/>
<rect class="cls-1" x="359.14" y="394.21" width="107.06" height="23.5" rx="11.75" transform="translate(516.28 975.5) rotate(-144.84)"/>
</g>
<path class="cls-2" d="M214.4,99.33V65.56H183.85a123,123,0,0,0-123,123h0C74.53,116.13,113.69,99.33,214.4,99.33Z"/>
<path class="cls-1" d="M331.42,311.62a123,123,0,0,0,123-123h0a123,123,0,0,0-123-123H267.1l33.77,33.77H268.4c0,79.47,108.36,32.47,136.43,50.75C438.87,172.24,331.42,311.62,331.42,311.62Z"/>
<path class="cls-2" d="M300.87,99.33V65.56h30.55a123,123,0,0,1,123,123h0C440.74,116.13,401.57,99.33,300.87,99.33Z"/>
<rect class="cls-2" x="83.67" y="229.71" width="347.28" height="216.72" rx="108.36"/>
<circle class="cls-3" cx="219.44" cy="338.08" r="69.19"/>
<circle class="cls-3" cx="295.17" cy="338.08" r="69.19"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because it is too large Load diff

View file

@ -1,45 +1,45 @@
import axios from 'axios'; import axios from 'axios';
export const instance = axios.create({ export const instance = axios.create({
baseURL: process.env.NODE_ENV === 'production' ? 'https://api-dot-crabfit.uc.r.appspot.com' : 'http://localhost:8080', baseURL: process.env.NODE_ENV === 'production' ? 'https://api-dot-crabfit.uc.r.appspot.com' : 'http://localhost:8080',
timeout: 1000 * 300, timeout: 1000 * 300,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
const handleError = error => { const handleError = error => {
if (error.response && error.response.status) { if (error.response && error.response.status) {
console.log('[Error handler] res:', error.response); console.log('[Error handler] res:', error.response);
} }
return Promise.reject(error.response); return Promise.reject(error.response);
}; };
const api = { const api = {
get: async (endpoint, data) => { get: async (endpoint, data) => {
try { try {
const response = await instance.get(endpoint, data); const response = await instance.get(endpoint, data);
return Promise.resolve(response); return Promise.resolve(response);
} catch (error) { } catch (error) {
return handleError(error); return handleError(error);
} }
}, },
post: async (endpoint, data, options = {}) => { post: async (endpoint, data, options = {}) => {
try { try {
const response = await instance.post(endpoint, data, options); const response = await instance.post(endpoint, data, options);
return Promise.resolve(response); return Promise.resolve(response);
} catch (error) { } catch (error) {
return handleError(error); return handleError(error);
} }
}, },
patch: async (endpoint, data) => { patch: async (endpoint, data) => {
try { try {
const response = await instance.patch(endpoint, data); const response = await instance.patch(endpoint, data);
return Promise.resolve(response); return Promise.resolve(response);
} catch (error) { } catch (error) {
return handleError(error); return handleError(error);
} }
}, },
}; };
export default api; export default api;

View file

@ -1,26 +1,26 @@
const theme = { const theme = {
light: { light: {
mode: 'light', mode: 'light',
background: '#FFFFFF', background: '#FFFFFF',
text: '#000000', text: '#000000',
primary: '#F79E00', primary: '#F79E00',
primaryDark: '#F48600', primaryDark: '#F48600',
primaryLight: '#F4BB60', primaryLight: '#F4BB60',
primaryBackground: '#FEF2DD', primaryBackground: '#FEF2DD',
error: '#D32F2F', error: '#D32F2F',
loading: '#DDDDDD', loading: '#DDDDDD',
}, },
dark: { dark: {
mode: 'dark', mode: 'dark',
background: '#111111', background: '#111111',
text: '#DDDDDD', text: '#DDDDDD',
primary: '#F79E00', primary: '#F79E00',
primaryDark: '#CC7313', primaryDark: '#CC7313',
primaryLight: '#F4BB60', primaryLight: '#F4BB60',
primaryBackground: '#30240F', primaryBackground: '#30240F',
error: '#E53935', error: '#E53935',
loading: '#444444', loading: '#444444',
}, },
}; };
export default theme; export default theme;