Set up API spec and basic components

This commit is contained in:
Ben Grant 2023-05-20 01:52:44 +10:00
parent 2adecd13f7
commit 61bd31eb7e
20 changed files with 353 additions and 26 deletions

1
frontend/.env.local Normal file
View file

@ -0,0 +1 @@
NEXT_PUBLIC_API_URL="http://127.0.0.1:3000"

View file

@ -27,6 +27,7 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.43.9",
"react-i18next": "^12.3.1",
"zod": "^3.21.4",
"zustand": "^4.3.8"
},
"devDependencies": {

View file

@ -1,4 +1,15 @@
import { Button, Footer, Header, Recents } from '/src/components'
import { Trans } from 'react-i18next/TransWithoutContext'
import Link from 'next/link'
import Button from '/src/components/Button/Button'
import Content from '/src/components/Content/Content'
import Footer from '/src/components/Footer/Footer'
import Header from '/src/components/Header/Header'
import { default as P } from '/src/components/Paragraph/Paragraph'
import Recents from '/src/components/Recents/Recents'
import Section from '/src/components/Section/Section'
import Stats from '/src/components/Stats/Stats'
import Video from '/src/components/Video/Video'
import { useTranslation } from '/src/i18n/server'
import styles from './home.module.scss'
@ -6,19 +17,82 @@ import styles from './home.module.scss'
const Page = async () => {
const { t } = await useTranslation('home')
return <div>
return <>
<Content>
{/* @ts-expect-error Async Server Component */}
<Header isFull />
<nav className={styles.nav}>
<a href="#about">{t('home:nav.about')}</a>
<a href="#about">{t('nav.about')}</a>
{' / '}
<a href="#donate">{t('home:nav.donate')}</a>
<a href="#donate">{t('nav.donate')}</a>
</nav>
</Content>
<Recents />
<Button>Hey there!</Button>
<Content>
<span>Form here</span>
<Button>Create</Button>
</Content>
<Section id="about">
<Content>
<h2>{t('about.name')}</h2>
{/* @ts-expect-error Async Server Component */}
<Stats />
<P><Trans i18nKey="home:about.content.p1" t={t}>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 href="/how-to" rel="help">Learn more about how to Crab Fit</Link>.</Trans></P>
<Video />
{/*
{!document.referrer.includes('android-app://fit.crab') && (
<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="about.content.p3" t={t}>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="about.content.p4" t={t}>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 href="/privacy" rel="license">privacy policy</Link>.</Trans></P>
<P>{t('about.content.p6')}</P>
<P>{t('about.content.p5')}</P>
</Content>
</Section>
{/* @ts-expect-error Async Server Component */}
<Footer />
</div>
</>
}
export default Page

View file

@ -0,0 +1,5 @@
.content {
width: 600px;
margin: 20px auto;
max-width: calc(100% - 60px);
}

View file

@ -0,0 +1,10 @@
import styles from './Content.module.scss'
interface ContentProps {
children: React.ReactNode
}
const Content = (props: ContentProps) =>
<div className={styles.content} {...props} />
export default Content

View file

@ -1,4 +1,4 @@
import { Button } from '/src/components'
import Button from '/src/components/Button/Button'
import { useTranslation } from '/src/i18n/server'
import { makeClass } from '/src/utils'

View file

@ -0,0 +1,4 @@
.p {
font-weight: 500;
line-height: 1.6em;
}

View file

@ -0,0 +1,10 @@
import styles from './Paragraph.module.scss'
interface ParagraphProps {
children: React.ReactNode
}
const Paragraph = (props: ParagraphProps) =>
<p className={styles.p} {...props} />
export default Paragraph

View file

@ -2,6 +2,8 @@
import Link from 'next/link'
import Content from '/src/components/Content/Content'
import Section from '/src/components/Section/Section'
import dayjs from '/src/config/dayjs'
import { useTranslation } from '/src/i18n/client'
import { useRecentsStore, useStore } from '/src/stores'
@ -16,8 +18,8 @@ const Recents = ({ target }: RecentsProps) => {
const recents = useStore(useRecentsStore, state => state.recents)
const { t } = useTranslation(['home', 'common'])
return recents?.length ? <section id="recents">
<div>
return recents?.length ? <Section id="recents">
<Content>
<h2>{t('home:recently_visited')}</h2>
{recents.map(event => (
<Link className={styles.recent} href={`/${event.id}`} target={target} key={event.id}>
@ -28,8 +30,8 @@ const Recents = ({ target }: RecentsProps) => {
>{t('common:created', { date: dayjs.unix(event.created_at).fromNow() })}</span>
</Link>
))}
</div>
</section> : null
</Content>
</Section> : null
}
export default Recents

View file

@ -0,0 +1,9 @@
.section {
margin: 30px 0 0;
background-color: var(--surface);
padding: 20px 0;
& a {
color: var(--secondary);
}
}

View file

@ -0,0 +1,11 @@
import styles from './Section.module.scss'
interface SectionProps {
children: React.ReactNode
id?: string
}
const Section = (props: SectionProps) =>
<section className={styles.section} {...props} />
export default Section

View file

@ -0,0 +1,24 @@
.wrapper {
display: flex;
justify-content: space-around;
align-items: flex-start;
flex-wrap: wrap;
& > div {
text-align: center;
padding: 0 6px;
min-width: 160px;
margin: 10px 0;
}
}
.number {
display: block;
font-weight: 900;
color: var(--secondary);
font-size: 2em;
}
.label {
display: block;
}

View file

@ -0,0 +1,33 @@
import { API_BASE, StatsResponse } from '/src/config/api'
import { useTranslation } from '/src/i18n/server'
import styles from './Stats.module.scss'
const getStats = async () => {
const res = await fetch(new URL('/stats', API_BASE))
.catch(console.warn)
if (!res?.ok) return
return StatsResponse.parse(await res.json())
}
const Stats = async () => {
const stats = await getStats()
const { t } = await useTranslation('home')
return <div className={styles.wrapper}>
<div>
<span className={styles.number}>
{new Intl.NumberFormat().format(stats?.event_count || 17000)}{!stats?.event_count && '+'}
</span>
<span className={styles.label}>{t('about.events')}</span>
</div>
<div>
<span className={styles.number}>
{new Intl.NumberFormat().format(stats?.person_count || 65000)}{!stats?.person_count && '+'}
</span>
<span className={styles.label}>{t('about.availabilities')}</span>
</div>
</div>
}
export default Stats

View file

@ -0,0 +1,64 @@
.videoWrapper {
margin: 0 auto;
position: relative;
padding-bottom: 56.4%;
width: 100%;
iframe {
position: absolute;
width: 100%;
height: 100%;
border-radius: 10px;
}
}
.preview {
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,43 @@
'use client'
import { useState } from 'react'
import { useTranslation } from '/src/i18n/client'
import video_thumb from '/src/res/video_thumb.jpg'
import styles from './Video.module.scss'
const Video = () => {
const { t } = useTranslation('common')
const [isPlaying, setIsPlaying] = useState(false)
return isPlaying ? (
<div className={styles.videoWrapper}>
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/yXGd4VXZzcY?modestbranding=1&rel=0&autoplay=1"
title={t<string>('video.title')}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
) : (
<a
className={styles.preview}
href="https://www.youtube.com/watch?v=yXGd4VXZzcY"
target="_blank"
rel="nofollow noreferrer"
onClick={e => {
e.preventDefault()
setIsPlaying(true)
}}
>
<img src={video_thumb.src} alt={t<string>('video.button')} />
<span>{t('video.button')}</span>
</a>
)
}
export default Video

View file

@ -4,19 +4,14 @@
// export { default as TimeRangeField } from './TimeRangeField/TimeRangeField'
// export { default as ToggleField } from './ToggleField/ToggleField'
export { default as Button } from './Button/Button'
// export { default as Legend } from './Legend/Legend'
// export { default as AvailabilityViewer } from './AvailabilityViewer/AvailabilityViewer'
// export { default as AvailabilityEditor } from './AvailabilityEditor/AvailabilityEditor'
export { default as Error } from './Error/Error'
// export { default as Loading } from './Loading/Loading'
// export { default as Center } from './Center/Center'
// export { default as Settings } from './Settings/Settings'
// export { default as Egg } from './Egg/Egg'
export { default as Footer } from './Footer/Footer'
export { default as Recents } from './Recents/Recents'
export { default as Header } from './Header/Header'
// export { default as TranslateDialog } from './TranslateDialog/TranslateDialog'
// export const _GoogleCalendar = () => import('./GoogleCalendar/GoogleCalendar')

View file

@ -0,0 +1,44 @@
// TODO: Write a simple rust crate that generates these from the OpenAPI spec
import { z } from 'zod'
if (process.env.NEXT_PUBLIC_API_URL === undefined) {
throw new Error('Expected API url environment variable')
}
export const API_BASE = new URL(process.env.NEXT_PUBLIC_API_URL)
export const EventInput = z.object({
name: z.string().optional(),
times: z.string().array(),
timezone: z.string(),
})
export type EventInput = z.infer<typeof EventInput>
export const EventResponse = z.object({
id: z.string(),
name: z.string(),
times: z.string().array(),
timezone: z.string(),
created_at: z.number(),
})
export type EventResponse = z.infer<typeof EventResponse>
export const PersonInput = z.object({
availability: z.string().array(),
})
export type PersonInput = z.infer<typeof PersonInput>
export const PersonResponse = z.object({
name: z.string(),
availability: z.string().array(),
created_at: z.number(),
})
export type PersonResponse = z.infer<typeof PersonResponse>
export const StatsResponse = z.object({
event_count: z.number(),
person_count: z.number(),
version: z.string(),
})
export type StatsResponse = z.infer<typeof StatsResponse>

View file

@ -15,7 +15,7 @@ export const getOptions = (lng = fallbackLng, ns: InitOptions['ns'] = defaultNS)
ns,
fallbackNS: defaultNS,
defaultNS,
debug: process.env.NODE_ENV !== 'production',
// debug: true,
interpolation: {
escapeValue: false,
},

View file

@ -16,10 +16,7 @@ const initI18next = async (language: string, ns: string | string []) => {
.use(resourcesToBackend((language: string, namespace: string) =>
import(`./locales/${language}/${namespace}.json`)
))
.init({
...getOptions(language, ns),
debug: false,
})
.init(getOptions(language, ns))
return i18nInstance
}

View file

@ -2802,7 +2802,7 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zod@3.21.4:
zod@3.21.4, zod@^3.21.4:
version "3.21.4"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db"
integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==