diff --git a/frontend/.env.local b/frontend/.env.local
new file mode 100644
index 0000000..5484c2c
--- /dev/null
+++ b/frontend/.env.local
@@ -0,0 +1 @@
+NEXT_PUBLIC_API_URL="http://127.0.0.1:3000"
diff --git a/frontend/package.json b/frontend/package.json
index 9057510..8f61d33 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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": {
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 85deea2..1cb8b63 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -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
-
+ return <>
+
+ {/* @ts-expect-error Async Server Component */}
+
-
- {t('home:nav.about')}
- {' / '}
- {t('home:nav.donate')}
-
+
+ {t('nav.about')}
+ {' / '}
+ {t('nav.donate')}
+
+
-
Hey there!
+
+
+ Form here
+ Create
+
+
+
+
+ {t('about.name')}
+
+ {/* @ts-expect-error Async Server Component */}
+
+
+ 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. Learn more about how to Crab Fit.
+
+
+
+ {/*
+ {!document.referrer.includes('android-app://fit.crab') && (
+
+ {['chrome', 'firefox', 'safari'].includes(browser) && (
+ ,
+ firefox: ,
+ safari: ,
+ }[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]}
+ )}
+ }
+ onClick={() => gtag('event', 'download_android_app', { 'event_category': 'home' })}
+ target="_blank"
+ rel="noreferrer noopener"
+ secondary
+ >{t('home:about.android_app')}
+
+ )} */}
+
+ Created by Ben Grant , Crab Fit is the modern-day solution to your group event planning debates.
+ The code for Crab Fit is open source, if you find any issues or want to contribute, you can visit the repository . By using Crab Fit you agree to the privacy policy.
+ {t('about.content.p6')}
+ {t('about.content.p5')}
+
+
+
+ {/* @ts-expect-error Async Server Component */}
-
+ >
}
export default Page
diff --git a/frontend/src/components/Content/Content.module.scss b/frontend/src/components/Content/Content.module.scss
new file mode 100644
index 0000000..53d571b
--- /dev/null
+++ b/frontend/src/components/Content/Content.module.scss
@@ -0,0 +1,5 @@
+.content {
+ width: 600px;
+ margin: 20px auto;
+ max-width: calc(100% - 60px);
+}
diff --git a/frontend/src/components/Content/Content.tsx b/frontend/src/components/Content/Content.tsx
new file mode 100644
index 0000000..0da66bd
--- /dev/null
+++ b/frontend/src/components/Content/Content.tsx
@@ -0,0 +1,10 @@
+import styles from './Content.module.scss'
+
+interface ContentProps {
+ children: React.ReactNode
+}
+
+const Content = (props: ContentProps) =>
+
+
+export default Content
diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx
index ef5eb6c..23bcff4 100644
--- a/frontend/src/components/Footer/Footer.tsx
+++ b/frontend/src/components/Footer/Footer.tsx
@@ -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'
diff --git a/frontend/src/components/Paragraph/Paragraph.module.scss b/frontend/src/components/Paragraph/Paragraph.module.scss
new file mode 100644
index 0000000..1b87409
--- /dev/null
+++ b/frontend/src/components/Paragraph/Paragraph.module.scss
@@ -0,0 +1,4 @@
+.p {
+ font-weight: 500;
+ line-height: 1.6em;
+}
diff --git a/frontend/src/components/Paragraph/Paragraph.tsx b/frontend/src/components/Paragraph/Paragraph.tsx
new file mode 100644
index 0000000..16a84fa
--- /dev/null
+++ b/frontend/src/components/Paragraph/Paragraph.tsx
@@ -0,0 +1,10 @@
+import styles from './Paragraph.module.scss'
+
+interface ParagraphProps {
+ children: React.ReactNode
+}
+
+const Paragraph = (props: ParagraphProps) =>
+
+
+export default Paragraph
diff --git a/frontend/src/components/Recents/Recents.tsx b/frontend/src/components/Recents/Recents.tsx
index 502eb75..4740444 100644
--- a/frontend/src/components/Recents/Recents.tsx
+++ b/frontend/src/components/Recents/Recents.tsx
@@ -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 ?
-
+ return recents?.length ?
+
{t('home:recently_visited')}
{recents.map(event => (
@@ -28,8 +30,8 @@ const Recents = ({ target }: RecentsProps) => {
>{t('common:created', { date: dayjs.unix(event.created_at).fromNow() })}
))}
-
- : null
+
+ : null
}
export default Recents
diff --git a/frontend/src/components/Section/Section.module.scss b/frontend/src/components/Section/Section.module.scss
new file mode 100644
index 0000000..7d6bc65
--- /dev/null
+++ b/frontend/src/components/Section/Section.module.scss
@@ -0,0 +1,9 @@
+.section {
+ margin: 30px 0 0;
+ background-color: var(--surface);
+ padding: 20px 0;
+
+ & a {
+ color: var(--secondary);
+ }
+}
diff --git a/frontend/src/components/Section/Section.tsx b/frontend/src/components/Section/Section.tsx
new file mode 100644
index 0000000..56f8fe0
--- /dev/null
+++ b/frontend/src/components/Section/Section.tsx
@@ -0,0 +1,11 @@
+import styles from './Section.module.scss'
+
+interface SectionProps {
+ children: React.ReactNode
+ id?: string
+}
+
+const Section = (props: SectionProps) =>
+
+
+export default Section
diff --git a/frontend/src/components/Stats/Stats.module.scss b/frontend/src/components/Stats/Stats.module.scss
new file mode 100644
index 0000000..2b6af4d
--- /dev/null
+++ b/frontend/src/components/Stats/Stats.module.scss
@@ -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;
+}
diff --git a/frontend/src/components/Stats/Stats.tsx b/frontend/src/components/Stats/Stats.tsx
new file mode 100644
index 0000000..e15493a
--- /dev/null
+++ b/frontend/src/components/Stats/Stats.tsx
@@ -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
+
+
+ {new Intl.NumberFormat().format(stats?.event_count || 17000)}{!stats?.event_count && '+'}
+
+ {t('about.events')}
+
+
+
+ {new Intl.NumberFormat().format(stats?.person_count || 65000)}{!stats?.person_count && '+'}
+
+ {t('about.availabilities')}
+
+
+}
+
+export default Stats
diff --git a/frontend/src/components/Video/Video.module.scss b/frontend/src/components/Video/Video.module.scss
new file mode 100644
index 0000000..b8423de
--- /dev/null
+++ b/frontend/src/components/Video/Video.module.scss
@@ -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);
+ }
+ }
+}
diff --git a/frontend/src/components/Video/Video.tsx b/frontend/src/components/Video/Video.tsx
new file mode 100644
index 0000000..902f77e
--- /dev/null
+++ b/frontend/src/components/Video/Video.tsx
@@ -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 ? (
+
+ VIDEO
+ ) : (
+ {
+ e.preventDefault()
+ setIsPlaying(true)
+ }}
+ >
+ ('video.button')} />
+ {t('video.button')}
+
+ )
+}
+
+export default Video
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts
index 28458a9..71ee668 100644
--- a/frontend/src/components/index.ts
+++ b/frontend/src/components/index.ts
@@ -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')
diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts
new file mode 100644
index 0000000..daa4abd
--- /dev/null
+++ b/frontend/src/config/api.ts
@@ -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
+
+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
+
+export const PersonInput = z.object({
+ availability: z.string().array(),
+})
+export type PersonInput = z.infer
+
+export const PersonResponse = z.object({
+ name: z.string(),
+ availability: z.string().array(),
+ created_at: z.number(),
+})
+export type PersonResponse = z.infer
+
+export const StatsResponse = z.object({
+ event_count: z.number(),
+ person_count: z.number(),
+ version: z.string(),
+})
+export type StatsResponse = z.infer
diff --git a/frontend/src/i18n/options.ts b/frontend/src/i18n/options.ts
index 99bd319..a696e94 100644
--- a/frontend/src/i18n/options.ts
+++ b/frontend/src/i18n/options.ts
@@ -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,
},
diff --git a/frontend/src/i18n/server.ts b/frontend/src/i18n/server.ts
index 1d43aad..f99d4d6 100644
--- a/frontend/src/i18n/server.ts
+++ b/frontend/src/i18n/server.ts
@@ -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
}
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index fe229d2..478b114 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -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==