Rename main folders and write sql backend adaptor

This commit is contained in:
Ben Grant 2023-05-11 17:04:17 +10:00
parent 1d34f8e06d
commit fdc58b428b
212 changed files with 3577 additions and 4775 deletions

View file

@ -0,0 +1,242 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useTranslation, Trans } from 'react-i18next'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import {
TextField,
CalendarField,
TimeRangeField,
SelectField,
Button,
Error,
Recents,
Footer,
} from '/src/components'
import {
StyledMain,
CreateForm,
TitleSmall,
TitleLarge,
P,
OfflineMessage,
ShareInfo,
} from './Create.styles'
import api from '/src/services'
import { useRecentsStore } from '/src/stores'
import timezones from '/src/res/timezones.json'
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(customParseFormat)
const Create = ({ offline }) => {
const { register, handleSubmit, setValue } = useForm({
defaultValues: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const [createdEvent, setCreatedEvent] = useState(null)
const [copied, setCopied] = useState(null)
const [showFooter, setShowFooter] = useState(true)
const navigate = useNavigate()
const { t } = useTranslation(['common', 'home', 'event'])
const addRecent = useRecentsStore(state => state.addRecent)
useEffect(() => {
if (window.self === window.top) {
navigate('/')
}
document.title = 'Create a Crab Fit'
if (window.parent) {
window.parent.postMessage('crabfit-create', '*')
window.addEventListener('message', e => {
if (e.data === 'safari-extension') {
setShowFooter(false)
}
}, {
once: true
})
}
}, [navigate])
const onSubmit = async data => {
setIsLoading(true)
setError(null)
try {
const { start, end } = JSON.parse(data.times)
const dates = JSON.parse(data.dates)
if (dates.length === 0) {
return setError(t('home:form.errors.no_dates'))
}
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8
if (start === end) {
return setError(t('home:form.errors.same_times'))
}
const times = dates.reduce((times, date) => {
const day = []
for (let i = start; i < (start > end ? 24 : end); i++) {
if (isSpecificDates) {
day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
)
} else {
day.push(
dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
)
}
}
if (start > end) {
for (let i = 0; i < end; i++) {
if (isSpecificDates) {
day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
)
} else {
day.push(
dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
)
}
}
}
return [...times, ...day]
}, [])
if (times.length === 0) {
return setError(t('home:form.errors.no_time'))
}
const event = await api.post('/event', {
event: {
name: data.name,
times: times,
timezone: data.timezone,
},
})
setCreatedEvent(event)
addRecent({
id: event.id,
created: event.created,
name: event.name,
})
gtag('event', 'create_event', {
'event_category': 'create',
})
} catch (e) {
setError(t('home:form.errors.unknown'))
console.error(e)
} finally {
setIsLoading(false)
}
}
return (
<>
<StyledMain>
<TitleSmall>{t('home:create')}</TitleSmall>
<TitleLarge>CRAB FIT</TitleLarge>
</StyledMain>
{createdEvent ? (
<StyledMain>
<OfflineMessage>
<h2>{createdEvent?.name}</h2>
<ShareInfo
onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${createdEvent.id}`)
.then(() => {
setCopied(t('event:nav.copied'))
setTimeout(() => setCopied(null), 1000)
gtag('event', 'copy_link', {
'event_category': 'event',
})
})
.catch(e => console.error('Failed to copy', e))
}
title={navigator.clipboard ? t('event:nav.title') : ''}
>{copied ?? `https://crab.fit/${createdEvent?.id}`}</ShareInfo>
<ShareInfo>
{/* 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>
</ShareInfo>
{showFooter && <Footer small />}
</OfflineMessage>
</StyledMain>
) : (
<>
<Recents target="_blank" />
<StyledMain>
{offline ? (
<OfflineMessage>
<h1>🦀📵</h1>
<P>{t('home:offline')}</P>
</OfflineMessage>
) : (
<CreateForm onSubmit={handleSubmit(onSubmit)} id="create">
<TextField
label={t('home:form.name.label')}
subLabel={t('home:form.name.sublabel')}
type="text"
id="name"
{...register('name')}
/>
<CalendarField
label={t('home:form.dates.label')}
subLabel={t('home:form.dates.sublabel')}
id="dates"
required
setValue={setValue}
{...register('dates')}
/>
<TimeRangeField
label={t('home:form.times.label')}
subLabel={t('home:form.times.sublabel')}
id="times"
required
setValue={setValue}
{...register('times')}
/>
<SelectField
label={t('home:form.timezone.label')}
id="timezone"
options={timezones}
required
{...register('timezone')}
defaultOption={t('home:form.timezone.defaultOption')}
/>
<Error open={!!error} onClose={() => setError(null)}>{error}</Error>
<Button type="submit" isLoading={isLoading} disabled={isLoading} style={{ width: '100%' }}>{t('home:form.button')}</Button>
</CreateForm>
)}
</StyledMain>
</>
)}
</>
)
}
export default Create

View file

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

View file

@ -0,0 +1,470 @@
import { useForm } from 'react-hook-form'
import { useState, useEffect } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import { useParams } from 'react-router-dom'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import relativeTime from 'dayjs/plugin/relativeTime'
import {
Footer,
TextField,
SelectField,
Button,
AvailabilityViewer,
AvailabilityEditor,
Error,
Logo,
} from '/src/components'
import { StyledMain } from '../Home/Home.styles'
import {
EventName,
EventDate,
LoginForm,
LoginSection,
Info,
ShareInfo,
Tabs,
Tab,
} from './Event.styles'
import api from '/src/services'
import { useSettingsStore, useRecentsStore, useLocaleUpdateStore } from '/src/stores'
import timezones from '/src/res/timezones.json'
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(customParseFormat)
dayjs.extend(relativeTime)
const Event = () => {
const timeFormat = useSettingsStore(state => state.timeFormat)
const weekStart = useSettingsStore(state => state.weekStart)
const addRecent = useRecentsStore(state => state.addRecent)
const removeRecent = useRecentsStore(state => state.removeRecent)
const locale = useLocaleUpdateStore(state => state.locale)
const { t } = useTranslation(['common', 'event'])
const { register, handleSubmit, setFocus, reset } = useForm()
const { id } = useParams()
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone)
const [user, setUser] = useState(null)
const [password, setPassword] = useState(null)
const [tab, setTab] = useState(user ? 'you' : 'group')
const [isLoading, setIsLoading] = useState(true)
const [isLoginLoading, setIsLoginLoading] = useState(false)
const [error, setError] = useState(null)
const [event, setEvent] = useState(null)
const [people, setPeople] = useState([])
const [times, setTimes] = useState([])
const [timeLabels, setTimeLabels] = useState([])
const [dates, setDates] = useState([])
const [min, setMin] = useState(0)
const [max, setMax] = useState(0)
const [copied, setCopied] = useState(null)
useEffect(() => {
const fetchEvent = async () => {
try {
const event = await api.get(`/event/${id}`)
setEvent(event)
addRecent({
id: event.id,
created: event.created,
name: event.name,
})
document.title = `${event.name} | Crab Fit`
} catch (e) {
console.error(e)
if (e.status === 404) {
removeRecent(id)
}
} finally {
setIsLoading(false)
}
}
fetchEvent()
}, [id, addRecent, removeRecent])
useEffect(() => {
const fetchPeople = async () => {
try {
const { people } = await api.get(`/event/${id}/people`)
const adjustedPeople = people.map(person => ({
...person,
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').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
}))
setPeople(adjustedPeople)
} catch (e) {
console.error(e)
}
}
if (tab === 'group') {
fetchPeople()
}
}, [tab, id, timezone])
// Convert to timezone and expand minute segments
useEffect(() => {
if (event) {
const isSpecificDates = event.times[0].length === 13
setTimes(event.times.reduce(
(allTimes, time) => {
const date = isSpecificDates ?
dayjs(time, 'HHmm-DDMMYYYY').utc(true).tz(timezone)
: dayjs(time, 'HHmm').day(time.substring(5)).utc(true).tz(timezone)
const format = isSpecificDates ? 'HHmm-DDMMYYYY' : 'HHmm-d'
return [
...allTimes,
date.minute(0).format(format),
date.minute(15).format(format),
date.minute(30).format(format),
date.minute(45).format(format),
]
},
[]
).sort((a, b) => {
if (isSpecificDates) {
return dayjs(a, 'HHmm-DDMMYYYY').diff(dayjs(b, 'HHmm-DDMMYYYY'))
} else {
return dayjs(a, 'HHmm').day((parseInt(a.substring(5))-weekStart % 7 + 7) % 7)
.diff(dayjs(b, 'HHmm').day((parseInt(b.substring(5))-weekStart % 7 + 7) % 7))
}
}))
}
}, [event, timezone, weekStart])
useEffect(() => {
if (!!times.length && !!people.length) {
setMin(times.reduce((min, time) => {
const total = people.reduce(
(total, person) => person.availability.includes(time) ? total+1 : total,
0
)
return total < min ? total : min
}, Infinity))
setMax(times.reduce((max, time) => {
const total = people.reduce(
(total, person) => person.availability.includes(time) ? total+1 : total,
0
)
return total > max ? total : max
}, -Infinity))
}
}, [times, people])
useEffect(() => {
if (times.length) {
setTimeLabels(times.reduce((labels, datetime) => {
const time = datetime.substring(0, 4)
if (labels.includes(time)) return labels
return [...labels, time]
}, [])
.sort((a, b) => parseInt(a) - parseInt(b))
.reduce((labels, time, i, allTimes) => {
if (time.substring(2) === '30') return [...labels, { label: '', time }]
if (allTimes.length - 1 === i) return [
...labels,
{ label: '', time },
{ 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 [
...labels,
{ label: '', time },
{ label: dayjs(time, 'HHmm').add(1, 'hour').format(timeFormat === '12h' ? 'h A' : 'HH'), time: 'space' },
{ label: '', time: 'space' },
{ label: '', time: 'space' },
]
if (time.substring(2) !== '00') return [...labels, { label: '', time }]
return [...labels, { label: dayjs(time, 'HHmm').format(timeFormat === '12h' ? 'h A' : 'HH'), time }]
}, []))
setDates(times.reduce((allDates, time) => {
if (time.substring(2, 4) !== '00') return allDates
const date = time.substring(5)
if (allDates.includes(date)) return allDates
return [...allDates, date]
}, []))
}
}, [times, timeFormat, locale])
useEffect(() => {
const fetchUser = async () => {
try {
const resUser = await api.post(`/event/${id}/people/${user.name}`, { person: { password } })
const adjustedUser = {
...resUser,
availability: (!!resUser.availability.length && resUser.availability[0].length === 13)
? resUser.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY'))
: resUser.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
}
setUser(adjustedUser)
} catch (e) {
console.log(e)
}
}
if (user) {
fetchUser()
}
}, [timezone])
const onSubmit = async data => {
if (!data.name || data.name.length === 0) {
setFocus('name')
return setError(t('event:form.errors.name_required'))
}
setIsLoginLoading(true)
setError(null)
try {
const resUser = await api.post(`/event/${id}/people/${data.name}`, {
person: {
password: data.password,
},
})
setPassword(data.password)
const adjustedUser = {
...resUser,
availability: (!!resUser.availability.length && resUser.availability[0].length === 13)
? resUser.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY'))
: resUser.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
}
setUser(adjustedUser)
setTab('you')
} catch (e) {
if (e.status === 401) {
setError(t('event:form.errors.password_incorrect'))
} else if (e.status === 404) {
// Create user
try {
await api.post(`/event/${id}/people`, {
person: {
name: data.name,
password: data.password,
},
})
setPassword(data.password)
setUser({
name: data.name,
availability: [],
})
setTab('you')
} catch (e) {
setError(t('event:form.errors.unknown'))
}
}
} finally {
setIsLoginLoading(false)
gtag('event', 'login', {
'event_category': 'event',
})
reset()
}
}
return (
<>
<StyledMain>
<Logo />
{(!!event || isLoading) ? (
<>
<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>
<ShareInfo
onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${id}`)
.then(() => {
setCopied(t('event:nav.copied'))
setTimeout(() => setCopied(null), 1000)
gtag('event', 'copy_link', {
'event_category': 'event',
})
})
.catch(e => console.error('Failed to copy', e))
}
title={navigator.clipboard ? t('event:nav.title') : ''}
>{copied ?? `https://crab.fit/${id}`}</ShareInfo>
<ShareInfo $isLoading={isLoading} className="instructions">
{!!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>
}
</ShareInfo>
</>
) : (
<div style={{ margin: '100px 0' }}>
<EventName>{t('event:error.title')}</EventName>
<ShareInfo>{t('event:error.body')}</ShareInfo>
</div>
)}
</StyledMain>
{(!!event || isLoading) && (
<>
<LoginSection id="login">
<StyledMain>
{user ? (
<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>
<Button small onClick={() => {
setTab('group')
setUser(null)
setPassword(null)
}}>{t('event:form.logout_button')}</Button>
</div>
) : (
<>
<h2>{t('event:form.signed_out')}</h2>
<LoginForm onSubmit={handleSubmit(onSubmit)}>
<TextField
label={t('event:form.name')}
type="text"
id="name"
inline
required
{...register('name')}
/>
<TextField
label={t('event:form.password')}
type="password"
id="password"
inline
{...register('password')}
/>
<Button
type="submit"
$isLoading={isLoginLoading}
disabled={isLoginLoading || isLoading}
>{t('event:form.button')}</Button>
</LoginForm>
<Error open={!!error} onClose={() => setError(null)}>{error}</Error>
<Info>{t('event:form.info')}</Info>
</>
)}
<SelectField
label={t('event:form.timezone')}
name="timezone"
id="timezone"
inline
value={timezone}
onChange={event => setTimezone(event.currentTarget.value)}
options={timezones}
/>
{/* 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 => {
e.preventDefault()
setTimezone(event.timezone)
}}>Click here</a> to use it.</Trans></p>}
{((
Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone
&& (event?.timezone && event.timezone !== Intl.DateTimeFormat().resolvedOptions().timeZone)
) || (
event?.timezone === undefined
&& Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone
)) && (
/* eslint-disable-next-line */
<p><Trans i18nKey="event:form.local_timezone">Your local timezone is detected to be <strong>{{timezone: Intl.DateTimeFormat().resolvedOptions().timeZone}}</strong>. <a href="#" onClick={e => {
e.preventDefault()
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
}}>Click here</a> to use it.</Trans></p>
)}
</StyledMain>
</LoginSection>
<StyledMain>
<Tabs>
<Tab
href="#you"
onClick={e => {
e.preventDefault()
if (user) {
setTab('you')
} else {
setFocus('name')
}
}}
$selected={tab === 'you'}
disabled={!user}
title={user ? '' : t('event:tabs.you_tooltip')}
>{t('event:tabs.you')}</Tab>
<Tab
href="#group"
onClick={e => {
e.preventDefault()
setTab('group')
}}
$selected={tab === 'group'}
>{t('event:tabs.group')}</Tab>
</Tabs>
</StyledMain>
{tab === 'group' ? (
<section id="group">
<AvailabilityViewer
times={times}
timeLabels={timeLabels}
dates={dates}
isSpecificDates={!!dates.length && dates[0].length === 8}
people={people.filter(p => p.availability.length > 0)}
min={min}
max={max}
/>
</section>
) : (
<section id="you">
<AvailabilityEditor
times={times}
timeLabels={timeLabels}
dates={dates}
timezone={timezone}
isSpecificDates={!!dates.length && dates[0].length === 8}
value={user.availability}
onChange={async availability => {
const oldAvailability = [...user.availability]
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', timezone).day(date.substring(5)).utc().format('HHmm-d'))
setUser({ ...user, availability })
try {
await api.patch(`/event/${id}/people/${user.name}`, {
person: {
password,
availability: utcAvailability,
},
})
} catch (e) {
console.log(e)
setUser({ ...user, oldAvailability })
}
}}
/>
</section>
)}
</>
)}
<Footer />
</>
)
}
export default Event

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

@ -0,0 +1,304 @@
import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useTranslation, Trans } from 'react-i18next'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import {
TextField,
CalendarField,
TimeRangeField,
SelectField,
Button,
Center,
Error,
Footer,
Recents,
} from '/src/components'
import {
StyledMain,
CreateForm,
TitleSmall,
TitleLarge,
Logo,
Links,
AboutSection,
P,
Stats,
Stat,
StatNumber,
StatLabel,
OfflineMessage,
ButtonArea,
VideoWrapper,
VideoLink,
} from './Home.styles'
import api from '/src/services'
import { detect_browser } from '/src/utils'
import { useTWAStore } from '/src/stores'
import logo from '/src/res/logo.svg'
import video_thumb from '/src/res/video_thumb.jpg'
import timezones from '/src/res/timezones.json'
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(customParseFormat)
const Home = ({ offline }) => {
const { register, handleSubmit, setValue } = useForm({
defaultValues: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const [stats, setStats] = useState({
eventCount: null,
personCount: null,
version: 'loading...',
})
const [browser, setBrowser] = useState(undefined)
const [videoPlay, setVideoPlay] = useState(false)
const navigate = useNavigate()
const { t } = useTranslation(['common', 'home'])
const isTWA = useTWAStore(state => state.TWA)
useEffect(() => {
const fetch = async () => {
try {
const response = await api.get('/stats')
setStats(response)
} catch (e) {
console.error(e)
}
}
fetch()
document.title = 'Crab Fit'
setBrowser(detect_browser())
}, [])
const onSubmit = async data => {
setIsLoading(true)
setError(null)
try {
const { start, end } = JSON.parse(data.times)
const dates = JSON.parse(data.dates)
if (dates.length === 0) {
return setError(t('home:form.errors.no_dates'))
}
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8
if (start === end) {
return setError(t('home:form.errors.same_times'))
}
const times = dates.reduce((times, date) => {
const day = []
for (let i = start; i < (start > end ? 24 : end); i++) {
if (isSpecificDates) {
day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
)
} else {
day.push(
dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
)
}
}
if (start > end) {
for (let i = 0; i < end; i++) {
if (isSpecificDates) {
day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
)
} else {
day.push(
dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
)
}
}
}
return [...times, ...day]
}, [])
if (times.length === 0) {
return setError(t('home:form.errors.no_time'))
}
const response = await api.post('/event', {
event: {
name: data.name,
times: times,
timezone: data.timezone,
},
})
navigate(`/${response.id}`)
gtag('event', 'create_event', {
'event_category': 'home',
})
} catch (e) {
setError(t('home:form.errors.unknown'))
console.error(e)
} finally {
setIsLoading(false)
}
}
return (
<>
<StyledMain>
<Center>
<Logo src={logo} alt="" />
</Center>
<TitleSmall $altChars={/^[A-Za-z ]+$/.test(t('home:create'))}>{t('home:create')}</TitleSmall>
<TitleLarge>CRAB FIT</TitleLarge>
<Links>
<a href="#about">{t('home:nav.about')}</a> / <a href="#donate">{t('home:nav.donate')}</a>
</Links>
</StyledMain>
<Recents />
<StyledMain>
{offline ? (
<OfflineMessage>
<h1>🦀📵</h1>
<P>{t('home:offline')}</P>
</OfflineMessage>
) : (
<CreateForm onSubmit={handleSubmit(onSubmit)} id="create">
<TextField
label={t('home:form.name.label')}
subLabel={t('home:form.name.sublabel')}
type="text"
id="name"
{...register('name')}
/>
<CalendarField
label={t('home:form.dates.label')}
subLabel={t('home:form.dates.sublabel')}
id="dates"
required
setValue={setValue}
{...register('dates')}
/>
<TimeRangeField
label={t('home:form.times.label')}
subLabel={t('home:form.times.sublabel')}
id="times"
required
setValue={setValue}
{...register('times')}
/>
<SelectField
label={t('home:form.timezone.label')}
id="timezone"
options={timezones}
required
{...register('timezone')}
defaultOption={t('home:form.timezone.defaultOption')}
/>
<Error open={!!error} onClose={() => setError(null)}>{error}</Error>
<Center>
<Button type="submit" isLoading={isLoading} disabled={isLoading}>{t('home:form.button')}</Button>
</Center>
</CreateForm>
)}
</StyledMain>
<AboutSection id="about">
<StyledMain>
<h2>{t('home:about.name')}</h2>
<Stats>
<Stat>
<StatNumber>{new Intl.NumberFormat().format(stats.eventCount ?? 7000)}{!stats.eventCount && '+'}</StatNumber>
<StatLabel>{t('home:about.events')}</StatLabel>
</Stat>
<Stat>
<StatNumber>{new Intl.NumberFormat().format(stats.personCount ?? 25000)}{!stats.personCount && '+'}</StatNumber>
<StatLabel>{t('home:about.availabilities')}</StatLabel>
</Stat>
</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>
{videoPlay ? (
<VideoWrapper>
<iframe width="560" height="315" src="https://www.youtube.com/embed/yXGd4VXZzcY?modestbranding=1&rel=0&autoplay=1" title={t('common:video.title')} frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen></iframe>
</VideoWrapper>
) : (
<VideoLink
href="https://www.youtube.com/watch?v=yXGd4VXZzcY"
onClick={e => {
e.preventDefault()
setVideoPlay(true)
}}
>
<img src={video_thumb} alt={t('common:video.button')} />
<span>{t('common:video.button')}</span>
</VideoLink>
)}
{isTWA !== true && (
<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
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.p5')}</P>
</StyledMain>
</AboutSection>
<Footer />
</>
)
}
export default Home

View file

@ -0,0 +1,206 @@
import { keyframes, styled } from 'goober'
export const StyledMain = styled('div')`
width: 600px;
margin: 20px auto;
max-width: calc(100% - 60px);
`
export const CreateForm = styled('form')`
margin: 0 0 60px;
`
export const TitleSmall = styled('span')`
display: block;
margin: 0;
font-size: 3rem;
text-align: center;
font-family: 'Samurai Bob', sans-serif;
font-weight: 400;
color: var(--secondary);
line-height: 1em;
text-transform: uppercase;
${props => !props.$altChars && `
font-family: sans-serif;
font-size: 2rem;
font-weight: 600;
line-height: 1.2em;
padding-top: .3em;
`}
`
export const TitleLarge = styled('h1')`
margin: 0;
font-size: 4rem;
text-align: center;
color: var(--primary);
font-family: 'Molot', sans-serif;
font-weight: 400;
text-shadow: 0 4px 0 var(--shadow);
line-height: 1em;
text-transform: uppercase;
@media (max-width: 350px) {
font-size: 3.5rem;
}
`
const jelly = keyframes`
from,to {
transform: scale(1,1);
}
25% {
transform: scale(.9,1.1);
}
50% {
transform: scale(1.1,.9);
}
75% {
transform: scale(.95,1.05);
}
`
export const Logo = styled('img')`
width: 80px;
transition: transform .15s;
animation: ${jelly} .5s 1 .05s;
user-select: none;
&:active {
animation: none;
transform: scale(.85);
}
@media (prefers-reduced-motion: reduce) {
animation: none;
transition: none;
&:active {
transform: none;
}
}
`
export const Links = styled('nav')`
text-align: center;
margin: 20px 0;
`
export const AboutSection = styled('section')`
margin: 30px 0 0;
background-color: var(--surface);
padding: 20px 0;
& a {
color: var(--secondary);
}
`
export const P = styled('p')`
font-weight: 500;
line-height: 1.6em;
`
export const Stats = styled('div')`
display: flex;
justify-content: space-around;
align-items: flex-start;
flex-wrap: wrap;
`
export const Stat = styled('div')`
text-align: center;
padding: 0 6px;
min-width: 160px;
margin: 10px 0;
`
export const StatNumber = styled('span')`
display: block;
font-weight: 900;
color: var(--secondary);
font-size: 2em;
`
export const StatLabel = styled('span')`
display: block;
`
export const OfflineMessage = styled('div')`
text-align: center;
margin: 50px 0 20px;
`
export const ButtonArea = styled('div')`
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px;
margin: 30px 0;
`
export const VideoWrapper = styled('div')`
margin: 0 auto;
position: relative;
padding-bottom: 56.4%;
width: 100%;
iframe {
position: absolute;
width: 100%;
height: 100%;
border-radius: 10px;
}
`
export const VideoLink = styled('a')`
display: block;
text-decoration: none;
position: relative;
width: 100%;
max-width: 400px;
margin: 0 auto;
transition: transform .15s;
&:hover, &:focus {
transform: translateY(-2px);
}
&:active {
transform: translateY(-1px);
}
img {
width: 100%;
display: block;
border-radius: 10px;
background-color: #CCC;
}
span {
color: #FFFFFF;
position: absolute;
top: 50%;
font-size: 1.5rem;
text-align: center;
width: 100%;
display: block;
transform: translateY(-50%);
text-shadow: 0 0 20px rgba(0,0,0,.8);
user-select: none;
&::before {
content: '';
display: block;
height: 2em;
width: 2em;
background: currentColor;
border-radius: 100%;
margin: 0 auto .4em;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23F79E00' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-play'%3E%3Cpolygon points='5 3 19 12 5 21 5 3'%3E%3C/polygon%3E%3C/svg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 1em;
box-shadow: 0 0 20px 0 rgba(0,0,0,.3);
}
}
`

View file

@ -0,0 +1,103 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Button, Center, Footer, Logo } from '/src/components'
import { StyledMain, AboutSection, P } from '../Home/Home.styles'
import { Note, ButtonArea } from './Privacy.styles'
const translationDisclaimer = 'While the translated document is provided for your convenience, the English version as displayed at https://crab.fit is legally binding.'
const Privacy = () => {
const navigate = useNavigate()
const { t, i18n } = useTranslation(['common', 'privacy'])
const contentRef = useRef()
const [content, setContent] = useState('')
useEffect(() => {
document.title = `${t('privacy:name')} - Crab Fit`
}, [t])
useEffect(() => setContent(contentRef.current?.innerText || ''), [contentRef])
return <>
<StyledMain>
<Logo />
</StyledMain>
<StyledMain>
<h1>{t('privacy:name')}</h1>
{!i18n.language.startsWith('en') && (
<p>
<a
href={`https://translate.google.com/?sl=en&tl=${i18n.language.substring(0, 2)}&text=${encodeURIComponent(`${translationDisclaimer}\n\n${content}`)}&op=translate`}
target="_blank"
rel="noreferrer noopener"
>{t('privacy:translate')}</a>
</p>
)}
<h3>Crab Fit</h3>
<div ref={contentRef}>
<P>This SERVICE is provided by Benjamin Grant at no cost and is intended for use as is.</P>
<P>This page is used to inform visitors regarding the policies of the collection, use, and disclosure of Personal Information if using the Service.</P>
<P>If you choose to use the Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that is collected is used for providing and improving the Service. Your information will not be used or shared with anyone except as described in this Privacy Policy.</P>
<h2>Information Collection and Use</h2>
<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 as="ul">
<li><a href="https://www.google.com/policies/privacy/" target="blank">Google Play Services</a></li>
</P>
<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>
<h2>Cookies</h2>
<P>Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are sent to your browser from the websites that you visit and are stored on your device's internal memory.</P>
<P>Cookies are used by Google Analytics to track you across the web and provide anonymous statistics to improve the Service.</P>
<h2>Service Providers</h2>
<P>Third-party companies may be employed for the following reasons:</P>
<P as="ul">
<li>To facilitate the Service</li>
<li>To provide the Service on our behalf</li>
<li>To perform Service-related services</li>
<li>To assist in analyzing how the Service is used</li>
</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>
<P>Personal Information that is shared via the Service is protected, however remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, so take care when sharing Personal Information.</P>
<Note>Events that are created will be automatically permanently erased from storage after <strong>3 months</strong> of inactivity.</Note>
<h2>Links to Other Sites</h2>
<P>The Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by the Service. Therefore, you are advised to review the Privacy Policy of these websites.</P>
<h2>Children's Privacy</h2>
<P>The Service does not address anyone under the age of 13. Personally identifiable information is not knowingly collected from children under 13. If discovered that a child under 13 has provided the Service with personal information, such information will be immediately deleted from the servers. If you are a parent or guardian and you are aware that your child has provided the Service with personal information, please contact us using the details below so that this information can be removed.</P>
<h2>Changes to This Privacy Policy</h2>
<P>This Privacy Policy may be updated from time to time. Thus, you are advised to review this page periodically for any changes.</P>
<P>Last updated: 2021-06-16</P>
<h2>Contact Us</h2>
<P>If you have any questions or suggestions about the Privacy Policy, do not hesitate to contact us at <a href="mailto:contact@crab.fit">contact@crab.fit</a>.</P>
</div>
</StyledMain>
<ButtonArea>
<AboutSection>
<StyledMain>
<Center><Button onClick={() => navigate('/')}>{t('common:cta')}</Button></Center>
</StyledMain>
</AboutSection>
</ButtonArea>
<Footer />
</>
}
export default Privacy

View file

@ -0,0 +1,22 @@
import { styled } from 'goober'
export const Note = styled('p')`
background-color: var(--surface);
border: 1px solid var(--primary);
border-radius: 10px;
padding: 12px 16px;
margin: 16px 0;
box-sizing: border-box;
font-weight: 500;
line-height: 1.6em;
& a {
color: var(--secondary);
}
`
export const ButtonArea = styled('div')`
@media print {
display: none;
}
`

View file

@ -0,0 +1,7 @@
import { lazy } from 'react'
export const Home = lazy(() => import('./Home/Home'))
export const Event = lazy(() => import('./Event/Event'))
export const Create = lazy(() => import('./Create/Create'))
export const Help = lazy(() => import('./Help/Help'))
export const Privacy = lazy(() => import('./Privacy/Privacy'))