diff --git a/crabfit-backend/index.js b/crabfit-backend/index.js index c12f52f..46e4947 100644 --- a/crabfit-backend/index.js +++ b/crabfit-backend/index.js @@ -2,6 +2,7 @@ require('dotenv').config(); const { Datastore } = require('@google-cloud/datastore'); const express = require('express'); +const cors = require('cors'); const package = require('./package.json'); @@ -20,6 +21,9 @@ const datastore = new Datastore({ keyFilename: process.env.GOOGLE_APPLICATION_CREDENTIALS, }); +app.use(cors({ + origin: 'http://localhost:3000', +})); app.use(express.json()); app.use((req, res, next) => { req.datastore = datastore; diff --git a/crabfit-backend/package.json b/crabfit-backend/package.json index 0ae4ecc..c87703f 100644 --- a/crabfit-backend/package.json +++ b/crabfit-backend/package.json @@ -15,6 +15,7 @@ "dependencies": { "@google-cloud/datastore": "^6.3.1", "bcrypt": "^5.0.1", + "cors": "^2.8.5", "dayjs": "^1.10.4", "dotenv": "^8.2.0", "express": "^4.17.1" diff --git a/crabfit-backend/routes/stats.js b/crabfit-backend/routes/stats.js index 7b539cc..25a7363 100644 --- a/crabfit-backend/routes/stats.js +++ b/crabfit-backend/routes/stats.js @@ -7,8 +7,8 @@ module.exports = async (req, res) => { try { const query = req.datastore.createQuery(['__Stat_Kind__']); - eventCount = (await req.datastore.runQuery(query.filter('kind_name', 'Event')))[0].count; - personCount = (await req.datastore.runQuery(query.filter('kind_name', 'Person')))[0].count; + eventCount = (await req.datastore.runQuery(query.filter('kind_name', 'Event')))[0][0].count; + personCount = (await req.datastore.runQuery(query.filter('kind_name', 'Person')))[0][0].count; } catch (e) { console.error(e); } diff --git a/crabfit-backend/yarn.lock b/crabfit-backend/yarn.lock index 266f6af..4c35f38 100644 --- a/crabfit-backend/yarn.lock +++ b/crabfit-backend/yarn.lock @@ -306,6 +306,14 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= +cors@^2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + dayjs@^1.10.4: version "1.10.4" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2" @@ -851,7 +859,7 @@ number-is-nan@^1.0.0: resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= -object-assign@^4.1.0: +object-assign@^4, object-assign@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= @@ -1180,7 +1188,7 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= -vary@~1.1.2: +vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= diff --git a/crabfit-frontend/package.json b/crabfit-frontend/package.json index 90739d6..15dbfc0 100644 --- a/crabfit-frontend/package.json +++ b/crabfit-frontend/package.json @@ -12,6 +12,7 @@ "@types/node": "^14.14.31", "@types/react": "^17.0.2", "@types/react-dom": "^17.0.1", + "axios": "^0.21.1", "dayjs": "^1.10.4", "react": "^17.0.1", "react-dom": "^17.0.1", diff --git a/crabfit-frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx b/crabfit-frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx index 4254ab5..6e38ebd 100644 --- a/crabfit-frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx +++ b/crabfit-frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx @@ -45,7 +45,7 @@ const AvailabilityEditor = ({ - {times.concat([`${parseInt(times[times.length-1].slice(0, 2))+1}00`]).map((time, i) => + {!!times.length && times.concat([`${parseInt(times[times.length-1].slice(0, 2))+1}00`]).map((time, i) => {time.slice(-2) === '00' && {dayjs().hour(time.slice(0, 2)).minute(time.slice(-2)).format('h A')}} diff --git a/crabfit-frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx b/crabfit-frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx index 24d67e6..dccc0f3 100644 --- a/crabfit-frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx +++ b/crabfit-frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx @@ -35,7 +35,7 @@ const AvailabilityViewer = ({ - {times.concat([`${parseInt(times[times.length-1].slice(0, 2))+1}00`]).map((time, i) => + {!!times.length && times.concat([`${parseInt(times[times.length-1].slice(0, 2))+1}00`]).map((time, i) => {time.slice(-2) === '00' && {dayjs().hour(time.slice(0, 2)).minute(time.slice(-2)).format('h A')}} diff --git a/crabfit-frontend/src/components/Button/buttonStyle.ts b/crabfit-frontend/src/components/Button/buttonStyle.ts index 47293a5..46115c4 100644 --- a/crabfit-frontend/src/components/Button/buttonStyle.ts +++ b/crabfit-frontend/src/components/Button/buttonStyle.ts @@ -37,6 +37,34 @@ export const Top = styled.button` &:focus-visible { filter: brightness(1.2); } + + ${props => props.isLoading && ` + text-shadow: none; + color: transparent; + cursor: wait; + + @keyframes load { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + &:after { + content: ''; + position: absolute; + top: calc(50% - 12px); + left: calc(50% - 12px); + height: 18px; + width: 18px; + border: 3px solid #FFF; + border-left-color: transparent; + border-radius: 100px; + animation: load .5s linear infinite; + } + `} `; export const Bottom = styled.div` diff --git a/crabfit-frontend/src/components/Donate/Donate.tsx b/crabfit-frontend/src/components/Donate/Donate.tsx index 4c28fc6..a800521 100644 --- a/crabfit-frontend/src/components/Donate/Donate.tsx +++ b/crabfit-frontend/src/components/Donate/Donate.tsx @@ -7,6 +7,7 @@ const Donate = () => ( buttonHeight="30px" buttonWidth="90px" type="button" + tabIndex="-1" >Donate diff --git a/crabfit-frontend/src/components/Error/Error.tsx b/crabfit-frontend/src/components/Error/Error.tsx new file mode 100644 index 0000000..8046cbe --- /dev/null +++ b/crabfit-frontend/src/components/Error/Error.tsx @@ -0,0 +1,16 @@ +import { Wrapper, CloseButton } from './errorStyle'; + +const Error = ({ + children, + onClose, + ...props +}) => ( + + {children} + + + + +); + +export default Error; diff --git a/crabfit-frontend/src/components/Error/errorStyle.ts b/crabfit-frontend/src/components/Error/errorStyle.ts new file mode 100644 index 0000000..c62e95a --- /dev/null +++ b/crabfit-frontend/src/components/Error/errorStyle.ts @@ -0,0 +1,26 @@ +import styled from '@emotion/styled'; + +export const Wrapper = styled.div` + margin: 20px 0; + border-radius: 3px; + background-color: ${props => props.theme.error}; + color: #FFFFFF; + padding: 12px 16px; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 18px; +`; + +export const CloseButton = styled.button` + border: 0; + background: none; + height: 30px; + width: 30px; + cursor: pointer; + color: inherit; + display: flex; + align-items: center; + justify-content: center; + margin-left: 16px; +`; diff --git a/crabfit-frontend/src/components/index.ts b/crabfit-frontend/src/components/index.ts index 5b31173..3ce1ec4 100644 --- a/crabfit-frontend/src/components/index.ts +++ b/crabfit-frontend/src/components/index.ts @@ -7,6 +7,7 @@ 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 Center } from './Center/Center'; export { default as Donate } from './Donate/Donate'; diff --git a/crabfit-frontend/src/pages/Event/Event.tsx b/crabfit-frontend/src/pages/Event/Event.tsx index 7a52090..6687995 100644 --- a/crabfit-frontend/src/pages/Event/Event.tsx +++ b/crabfit-frontend/src/pages/Event/Event.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom'; import { useForm } from 'react-hook-form'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Center, @@ -27,18 +27,60 @@ import { Tab, } from './eventStyle'; +import api from 'services'; + import logo from 'res/logo.svg'; import timezones from 'res/timezones.json'; const Event = (props) => { const { register, handleSubmit } = useForm(); - const id = props.match.params.id; + const { id } = props.match.params; const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone); - const [user, setUser] = useState({ - name: 'Benji', - availability: [], - }); + const [user, setUser] = useState(null); + const [password, setPassword] = useState(null); const [tab, setTab] = useState(user ? 'you' : 'group'); + const [isLoading, setIsLoading] = useState(true); + const [event, setEvent] = useState(null); + const [people, setPeople] = useState([]); + + useEffect(() => { + const fetchEvent = async () => { + const response = await api.get(`/event/${id}`); + if (response.status === 200) { + let times = []; + for (let i = response.data.startTime; i < response.data.endTime; i++) { + let hour = `${i}`.padStart(2, '0'); + times.push( + `${hour}00`, + `${hour}15`, + `${hour}30`, + `${hour}45`, + ); + } + + setEvent({ + ...response.data, + times, + }); + setIsLoading(false); + } else { + console.error(response); + //TODO: 404 + } + }; + + const fetchPeople = async () => { + const response = await api.get(`/event/${id}/people`); + if (response.status === 200) { + setPeople(response.data.people); + } else { + console.error(response); + } + }; + + fetchEvent(); + fetchPeople(); + }, [id]); const onSubmit = data => console.log('submit', data); @@ -52,9 +94,13 @@ const Event = (props) => { - Event name ({id}) - https://page.url - Copy the link to this page, or share via Email or Facebook. + {event?.name} + {!!event?.name && `https://crab.fit/${id}`} + + {!!event?.name && + <>Copy the link to this page, or share via Email. + } + @@ -129,47 +175,13 @@ const Event = (props) => { {tab === 'group' ? (
- +
Hover or tap the calendar below to see who is available
) : ( @@ -178,10 +190,23 @@ const Event = (props) => {
Click and drag the calendar below to set your availabilities
setUser({ ...user, availability })} + onChange={async availability => { + const oldAvailability = [...user.availability]; + setUser({ ...user, availability }); + const response = await api.patch(`/event/${id}/people/${user.name}`, { + person: { + password, + availability, + }, + }); + if (response.status !== 200) { + console.log(response); + setUser({ ...user, oldAvailability }); + } + }} /> )} diff --git a/crabfit-frontend/src/pages/Event/eventStyle.ts b/crabfit-frontend/src/pages/Event/eventStyle.ts index 62a6ede..f082645 100644 --- a/crabfit-frontend/src/pages/Event/eventStyle.ts +++ b/crabfit-frontend/src/pages/Event/eventStyle.ts @@ -34,6 +34,18 @@ export const EventName = styled.h1` text-align: center; font-weight: 800; margin: 20px 0 14px; + + ${props => props.isLoading && ` + &:after { + content: ''; + display: inline-block; + height: 1em; + width: 300px; + max-width: 100%; + background-color: ${props.theme.loading}; + border-radius: 3px; + } + `} `; export const LoginForm = styled.form` @@ -65,6 +77,18 @@ 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: 500px; + max-width: 100%; + background-color: ${props.theme.loading}; + border-radius: 3px; + } + `} `; export const Tabs = styled.div` diff --git a/crabfit-frontend/src/pages/Home/Home.tsx b/crabfit-frontend/src/pages/Home/Home.tsx index cc061d6..a82803f 100644 --- a/crabfit-frontend/src/pages/Home/Home.tsx +++ b/crabfit-frontend/src/pages/Home/Home.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from 'react'; +import { useHistory } from 'react-router-dom'; import { useForm } from 'react-hook-form'; import { @@ -8,6 +10,7 @@ import { Button, Center, Donate, + Error, } from 'components'; import { @@ -20,8 +23,14 @@ import { AboutSection, Footer, P, + Stats, + Stat, + StatNumber, + StatLabel, } from './homeStyle'; +import api from 'services'; + import logo from 'res/logo.svg'; import timezones from 'res/timezones.json'; @@ -31,8 +40,55 @@ const Home = () => { 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 { push } = useHistory(); - const onSubmit = data => console.log('submit', data); + useEffect(() => { + const fetch = async () => { + const response = await api.get('/stats'); + if (response.status === 200) { + setStats(response.data); + } + }; + + fetch(); + }, []); + + const onSubmit = async data => { + setIsLoading(true); + setError(null); + try { + const times = JSON.parse(data.times); + const response = await api.post('/event', { + event: { + name: data.name, + timezone: data.timezone, + startTime: times.start, + endTime: times.end, + dates: JSON.parse(data.dates), + }, + }); + + if (response.status === 201) { + // Success + push(`/${response.data.id}`); + } else { + setError('An error ocurred while creating the event. Please try again later.'); + console.error(response.status); + } + } catch (e) { + setError('An error ocurred while creating the event. Please try again later.'); + console.error(e); + } finally { + setIsLoading(false); + } + }; return ( <> @@ -83,8 +139,12 @@ const Home = () => { required /> + {error && ( + setError(null)}>{error} + )} +
- +
@@ -92,6 +152,16 @@ const Home = () => {

About Crab Fit

+ + + {stats.eventCount ?? '10+'} + Events created + + + {stats.peopleCount ?? '10+'} + Availabilities entered + +

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.

{/* eslint-disable-next-line */}

Create by Ben Grant, Crab Fit is the modern-day solution to your group event planning debates.

diff --git a/crabfit-frontend/src/pages/Home/homeStyle.ts b/crabfit-frontend/src/pages/Home/homeStyle.ts index 16244bb..3c968f1 100644 --- a/crabfit-frontend/src/pages/Home/homeStyle.ts +++ b/crabfit-frontend/src/pages/Home/homeStyle.ts @@ -63,3 +63,28 @@ 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: ${props => props.theme.primaryDark}; + font-size: 2em; +`; + +export const StatLabel = styled.span` + display: block; +`; diff --git a/crabfit-frontend/src/services/index.js b/crabfit-frontend/src/services/index.js new file mode 100644 index 0000000..c209c24 --- /dev/null +++ b/crabfit-frontend/src/services/index.js @@ -0,0 +1,45 @@ +import axios from 'axios'; + +export const instance = axios.create({ + baseURL: 'http://localhost:8080', + timeout: 1000 * 300, + headers: { + 'Content-Type': 'application/json', + }, +}); + +const handleError = error => { + if (error.response && error.response.status) { + console.log('[Error handler] res:', error.response); + } + return Promise.reject(error); +}; + +const api = { + get: async (endpoint, data = {}) => { + try { + const response = await instance.get(endpoint, { params: data }); + return Promise.resolve(response); + } catch (error) { + return handleError(error); + } + }, + post: async (endpoint, data, options = {}) => { + try { + const response = await instance.post(endpoint, data, options); + return Promise.resolve(response); + } catch (error) { + return handleError(error); + } + }, + patch: async (endpoint, data) => { + try { + const response = await instance.patch(endpoint, data); + return Promise.resolve(response); + } catch (error) { + return handleError(error); + } + }, +}; + +export default api; diff --git a/crabfit-frontend/src/theme/index.ts b/crabfit-frontend/src/theme/index.ts index 29f9f72..9746b65 100644 --- a/crabfit-frontend/src/theme/index.ts +++ b/crabfit-frontend/src/theme/index.ts @@ -7,6 +7,8 @@ const theme = { primaryDark: '#F48600', primaryLight: '#F4BB60', primaryBackground: '#FEF2DD', + error: '#D32F2F', + loading: '#DDDDDD', }, dark: { mode: 'dark', @@ -16,6 +18,8 @@ const theme = { primaryDark: '#F4BB60', primaryLight: '#F48600', primaryBackground: '#30240F', + error: '#E53935', + loading: '#444444', }, }; diff --git a/crabfit-frontend/yarn.lock b/crabfit-frontend/yarn.lock index d1ce37f..5f963c4 100644 --- a/crabfit-frontend/yarn.lock +++ b/crabfit-frontend/yarn.lock @@ -2614,6 +2614,13 @@ axe-core@^4.0.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.1.2.tgz#7cf783331320098bfbef620df3b3c770147bc224" integrity sha512-V+Nq70NxKhYt89ArVcaNL9FDryB3vQOd+BFXZIfO3RP6rwtj+2yqqqdHEkacutglPaZLkJeuXKCjCJDMGPtPqg== +axios@^0.21.1: + version "0.21.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" + integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== + dependencies: + follow-redirects "^1.10.0" + axobject-query@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" @@ -5131,6 +5138,11 @@ follow-redirects@^1.0.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.2.tgz#dd73c8effc12728ba5cf4259d760ea5fb83e3147" integrity sha512-6mPTgLxYm3r6Bkkg0vNM0HTjfGrOEtsfbhagQvbxDEsEkpNhw582upBaoRZylzen6krEmxXJgt9Ju6HiI4O7BA== +follow-redirects@^1.10.0: + version "1.13.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267" + integrity sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA== + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"