├── .prettierignore ├── .babelrc ├── public ├── images │ ├── app.png │ ├── image.png │ └── banner.png └── favicons │ ├── favicon.ico │ └── favicon.png ├── next-env.d.ts ├── .gitignore ├── .prettierrc.js ├── utils ├── deals.js ├── search.js └── auth.js ├── now.json ├── icons ├── Hamburger.js ├── Filter.js ├── Home.js ├── Deal.js ├── Map.js ├── WineGlass.js └── Location.js ├── .github └── workflows │ └── workflow.yml ├── tsconfig.json ├── test ├── test-utils.js ├── components │ └── BarCard.spec.js └── pages │ └── signin.spec.js ├── graphql ├── hooks.js ├── queries.js ├── mutations.js └── apollo.js ├── components ├── EmptySearch.js ├── Map.js ├── App.js ├── WeekdayButtonGroup.js ├── DealCard.js ├── BarCard.tsx ├── MobileNav.js ├── SideNav.js ├── Filters.js ├── NavLink.js ├── Voter.js ├── Header.js ├── Auth.js ├── AddDealModal.js └── Logo.js ├── seo.config.js ├── pages ├── signin.js ├── _app.js ├── _document.js ├── map.js ├── signup.js ├── bars.js ├── _error.js ├── deals.js └── index.js ├── README.md └── package.json /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | dist 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"] 3 | } 4 | -------------------------------------------------------------------------------- /public/images/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/daydrink/HEAD/public/images/app.png -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /public/images/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/daydrink/HEAD/public/images/image.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | .idea 4 | .next 5 | node_modules 6 | dist 7 | .env* 8 | .firebase -------------------------------------------------------------------------------- /public/images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/daydrink/HEAD/public/images/banner.png -------------------------------------------------------------------------------- /public/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/daydrink/HEAD/public/favicons/favicon.ico -------------------------------------------------------------------------------- /public/favicons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/daydrink/HEAD/public/favicons/favicon.png -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | bracketSpacing: false, 4 | printWidth: 120, 5 | singleQuote: true, 6 | tabWidth: 4 7 | }; 8 | -------------------------------------------------------------------------------- /utils/deals.js: -------------------------------------------------------------------------------- 1 | export const calculateScoreAndSortDesc = (deals) => { 2 | const dealsWithScore = deals.map((deal) => ({ 3 | ...deal, 4 | score: deal.userDeals.reduce((acc, deal) => acc + (deal.upvoted ? 1 : -1), 0) 5 | })); 6 | 7 | return dealsWithScore.sort((a, b) => (a.score < b.score ? 1 : -1)); 8 | }; 9 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "daydrink", 4 | "alias": "daydrink.io", 5 | "builds": [ 6 | { 7 | "src": "package.json", 8 | "use": "@now/next" 9 | } 10 | ], 11 | "routes": [ 12 | { 13 | "src": "/(.*\\.(js|jpg|json|css|ico|png)$)", 14 | "dest": "/$1", 15 | "headers": {"cache-control": "public,max-age=31536000,immutable"} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /icons/Hamburger.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {Box} from '@chakra-ui/core'; 3 | import {jsx} from '@emotion/core'; 4 | 5 | const Hamburger = (props) => ( 6 | 17 | 18 | 19 | ); 20 | 21 | export default Hamburger; 22 | -------------------------------------------------------------------------------- /icons/Filter.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {Box} from '@chakra-ui/core'; 3 | import {jsx} from '@emotion/core'; 4 | 5 | const Filter = (props) => ( 6 | 17 | 18 | 19 | ); 20 | 21 | export default Filter; 22 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: pull_request 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v1 8 | with: 9 | fetch-depth: 1 10 | - name: Setup Node.js 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: 12.13.1 14 | - name: Installing dependencies 15 | run: yarn install --frozen-lockfile 16 | - name: Running tests 17 | run: yarn test 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve" 16 | }, 17 | "exclude": ["node_modules"], 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 19 | } 20 | -------------------------------------------------------------------------------- /test/test-utils.js: -------------------------------------------------------------------------------- 1 | import {render} from '@testing-library/react'; 2 | import {ThemeProvider, ColorModeProvider} from '@chakra-ui/core'; 3 | import '@testing-library/jest-dom'; 4 | import 'mutationobserver-shim'; 5 | 6 | const ChakraRenderer = ({children}) => { 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | }; 13 | 14 | const customRender = (ui, options) => 15 | render(ui, { 16 | wrapper: ChakraRenderer, 17 | ...options 18 | }); 19 | 20 | export * from '@testing-library/react'; 21 | export {customRender as render}; 22 | -------------------------------------------------------------------------------- /graphql/hooks.js: -------------------------------------------------------------------------------- 1 | import {useQuery} from '@apollo/react-hooks'; 2 | import {GET_DEALS_QUERY} from './queries'; 3 | import {calculateScoreAndSortDesc} from '../utils/deals'; 4 | 5 | export const useDeals = (dayOfWeek) => { 6 | const {loading, error, data} = useQuery(GET_DEALS_QUERY, { 7 | variables: {dayOfWeek} 8 | }); 9 | 10 | if (!loading && data.deals) { 11 | return { 12 | loading, 13 | error, 14 | data: { 15 | deals: calculateScoreAndSortDesc(data.deals) 16 | } 17 | }; 18 | } 19 | 20 | return { 21 | loading, 22 | error, 23 | data 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /components/EmptySearch.js: -------------------------------------------------------------------------------- 1 | import {useColorMode, Stack, Text, Flex, Icon} from '@chakra-ui/core'; 2 | import React from 'react'; 3 | 4 | const EmptySearch = (props) => { 5 | const {colorMode} = useColorMode(); 6 | 7 | return ( 8 | 9 | 10 | 11 | 12 | No Results Found 13 | 14 | Nothing matched your search query. 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default EmptySearch; 21 | -------------------------------------------------------------------------------- /icons/Home.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {useColorMode, Box} from '@chakra-ui/core'; 3 | import {jsx} from '@emotion/core'; 4 | 5 | const Home = (props) => { 6 | const {colorMode} = useColorMode(); 7 | 8 | return ( 9 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default Home; 26 | -------------------------------------------------------------------------------- /icons/Deal.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {useColorMode, Box} from '@chakra-ui/core'; 3 | import {jsx} from '@emotion/core'; 4 | 5 | const Deal = (props) => { 6 | const {colorMode} = useColorMode(); 7 | 8 | return ( 9 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default Deal; 26 | -------------------------------------------------------------------------------- /icons/Map.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {useColorMode, Box} from '@chakra-ui/core'; 3 | import {jsx} from '@emotion/core'; 4 | 5 | const Map = (props) => { 6 | const {colorMode} = useColorMode(); 7 | 8 | return ( 9 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default Map; 26 | -------------------------------------------------------------------------------- /icons/WineGlass.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {useColorMode, Box} from '@chakra-ui/core'; 3 | import {jsx} from '@emotion/core'; 4 | 5 | const WineGlass = (props) => { 6 | const {colorMode} = useColorMode(); 7 | 8 | return ( 9 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default WineGlass; 25 | -------------------------------------------------------------------------------- /icons/Location.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {useColorMode, Box} from '@chakra-ui/core'; 3 | import {jsx} from '@emotion/core'; 4 | 5 | const Location = (props) => { 6 | const {colorMode} = useColorMode(); 7 | 8 | return ( 9 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default Location; 26 | -------------------------------------------------------------------------------- /seo.config.js: -------------------------------------------------------------------------------- 1 | const SEO = { 2 | title: 'daydrink | Find the best drink deals and happy hours in your area.', 3 | description: 'Find the best drink deals and happy hours in your area.', 4 | openGraph: { 5 | url: 'https://daydrink.io', 6 | title: 'daydrink', 7 | description: 'Find the best drink deals and happy hours in your area.', 8 | images: [ 9 | { 10 | url: 'https://daydrink.io/images/banner.png', 11 | width: 1200, 12 | height: 630, 13 | alt: 'daydrink | Find the best drink deals and happy hours in your area.' 14 | } 15 | ], 16 | site_name: 'daydrink' 17 | }, 18 | twitter: { 19 | handle: '@leeerob', 20 | site: '@leeerob', 21 | cardType: 'summary_large_image' 22 | } 23 | }; 24 | 25 | export default SEO; 26 | -------------------------------------------------------------------------------- /pages/signin.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core'; 3 | import {useToast} from '@chakra-ui/core'; 4 | import {useAuth} from '../utils/auth'; 5 | import Auth from '../components/Auth'; 6 | import {useRouter} from 'next/router'; 7 | 8 | export default () => { 9 | const auth = useAuth(); 10 | const toast = useToast(); 11 | const router = useRouter(); 12 | 13 | const signIn = ({email, pass}) => { 14 | auth.signin(email, pass) 15 | .then(() => { 16 | router.push('/deals'); 17 | }) 18 | .catch((error) => { 19 | toast({ 20 | title: 'An error occurred.', 21 | description: error.message, 22 | status: 'error', 23 | duration: 9000, 24 | isClosable: true 25 | }); 26 | }); 27 | }; 28 | 29 | return ; 30 | }; 31 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import {Global, css} from '@emotion/core'; 2 | import {ColorModeProvider, CSSReset, ThemeProvider} from '@chakra-ui/core'; 3 | import {DefaultSeo} from 'next-seo'; 4 | import React from 'react'; 5 | import seo from '../seo.config'; 6 | import {ProvideAuth} from '../utils/auth'; 7 | import {ProvideSearch} from '../utils/search'; 8 | 9 | export default ({Component, pageProps}) => ( 10 | 11 | 12 | 13 | 14 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | -------------------------------------------------------------------------------- /components/Map.js: -------------------------------------------------------------------------------- 1 | import {Component} from 'react'; 2 | import ReactMapGL from 'react-map-gl'; 3 | 4 | class Map extends Component { 5 | state = { 6 | viewport: { 7 | width: '100vw', 8 | height: '100vh', 9 | latitude: 41.5898, 10 | longitude: -93.585, 11 | zoom: 12 12 | } 13 | }; 14 | 15 | render() { 16 | const mapStyle = {light: 'mapbox/streets-v11', dark: 'lrobinson/ck3dd8clv3glw1cqv56o78etl'}; 17 | 18 | return ( 19 | this.setState({viewport})} 23 | {...this.state.viewport} 24 | > 25 | {this.props.children} 26 | 27 | ); 28 | } 29 | } 30 | 31 | export default Map; 32 | -------------------------------------------------------------------------------- /components/App.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core'; 3 | import {useColorMode, Box} from '@chakra-ui/core'; 4 | 5 | import {useSearch} from '../utils/search'; 6 | import SideNav from '../components/SideNav'; 7 | import Header from '../components/Header'; 8 | 9 | const App = ({children, ...rest}) => { 10 | const {colorMode} = useColorMode(); 11 | const {search, onSearch} = useSearch(); 12 | 13 | return ( 14 | <> 15 |
16 | 17 | 18 | 19 | 24 | {children} 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | export default App; 33 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import NextDocument, {Html, Head, Main, NextScript} from 'next/document'; 3 | 4 | class Document extends NextDocument { 5 | static async getInitialProps(ctx) { 6 | const initialProps = await NextDocument.getInitialProps(ctx); 7 | return {...initialProps}; 8 | } 9 | 10 | render() { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | ); 26 | } 27 | } 28 | 29 | export default Document; 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # daydrink 2 | 3 | This application is part of [Mastering Next.js](https://masteringnextjs.com/). 4 | 5 | ![banner](https://user-images.githubusercontent.com/9113740/74108760-1c6cb600-4b43-11ea-9932-dfb3c87ac843.png) 6 | app 7 | 8 | ## Running Locally 9 | 10 | ```bash 11 | $ git clone https://github.com/leerob/daydrink.git 12 | $ cd leerob.io 13 | ``` 14 | 15 | Set GraphQL URL for Apollo Client by adding the following field to `.env` file. 16 | ``` 17 | GRAPHQL_URL= 18 | ``` 19 | 20 | ``` 21 | $ yarn 22 | $ yarn dev 23 | ``` 24 | 25 | To use Firebase Auth, you will need to run `now dev` and have a `.env` file similar to this. 26 | 27 | ``` 28 | FIREBASE_API_KEY= 29 | FIREBASE_APP_ID= 30 | FIREBASE_AUTH_DOMAIN= 31 | FIREBASE_PROJECT_ID= 32 | ``` 33 | 34 | ## Built Using 35 | 36 | - [Next.js](https://nextjs.org/) 37 | - [Now](https://zeit.co/now) 38 | - [Chakra UI](https://chakra-ui.com/) 39 | - [Apollo GraphQL](https://www.apollographql.com/docs/react/) 40 | - [Hasura](https://hasura.io/) 41 | - [Prettier](https://prettier.io/) 42 | -------------------------------------------------------------------------------- /pages/map.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core'; 3 | import dynamic from 'next/dynamic'; 4 | import {useColorMode} from '@chakra-ui/core'; 5 | import {useQuery} from '@apollo/react-hooks'; 6 | import {Marker} from 'react-map-gl'; 7 | 8 | import {withApollo} from '../graphql/apollo'; 9 | import {GET_LOCATIONS_QUERY} from '../graphql/queries'; 10 | import App from '../components/App'; 11 | 12 | const Map = dynamic(() => import('../components/Map'), { 13 | ssr: false 14 | }); 15 | 16 | const MapPage = () => { 17 | const {colorMode} = useColorMode(); 18 | const {data, loading} = useQuery(GET_LOCATIONS_QUERY); 19 | 20 | return ( 21 | 22 | 23 | {!loading && 24 | data.locations.map(({id, name, lat, long}) => ( 25 | 26 | {'☀️'} 27 | 28 | ))} 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default withApollo(MapPage, { 35 | ssr: false 36 | }); 37 | -------------------------------------------------------------------------------- /pages/signup.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core'; 3 | import {useToast} from '@chakra-ui/core'; 4 | import {useAuth} from '../utils/auth'; 5 | import Auth from '../components/Auth'; 6 | import {useRouter} from 'next/router'; 7 | 8 | export default () => { 9 | const auth = useAuth(); 10 | const toast = useToast(); 11 | const router = useRouter(); 12 | 13 | const signUp = ({email, pass}) => { 14 | auth.signup(email, pass) 15 | .then(() => { 16 | toast({ 17 | title: 'Success! 🍻', 18 | description: 'Your account has been created.', 19 | status: 'success', 20 | duration: 3000, 21 | isClosable: true 22 | }); 23 | router.push('/deals'); 24 | }) 25 | .catch((error) => { 26 | toast({ 27 | title: 'An error occurred.', 28 | description: error.message, 29 | status: 'error', 30 | duration: 9000, 31 | isClosable: true 32 | }); 33 | }); 34 | }; 35 | 36 | return ; 37 | }; 38 | -------------------------------------------------------------------------------- /components/WeekdayButtonGroup.js: -------------------------------------------------------------------------------- 1 | import {Button, Stack} from '@chakra-ui/core'; 2 | 3 | const WeekdayButtonGroup = ({daysActive, onChange}) => { 4 | const updateDaysActive = (day) => { 5 | if (daysActive.includes(day)) { 6 | const withDayRemoved = daysActive.filter((dayActive) => dayActive !== day); 7 | 8 | return onChange(withDayRemoved); 9 | } 10 | 11 | const withDayAdded = [...daysActive, day]; 12 | 13 | onChange(withDayAdded); 14 | }; 15 | 16 | const DayOfWeek = ({children, ...rest}) => ( 17 | 24 | ); 25 | 26 | return ( 27 | 28 | Monday 29 | Tuesday 30 | Wednesday 31 | Thursday 32 | Friday 33 | Saturday 34 | Sunday 35 | 36 | ); 37 | }; 38 | 39 | export default WeekdayButtonGroup; 40 | -------------------------------------------------------------------------------- /test/components/BarCard.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {render} from '../test-utils'; 4 | import BarCard from '../../components/BarCard'; 5 | 6 | describe('BarCard', () => { 7 | let expectedProps; 8 | 9 | beforeEach(() => { 10 | expectedProps = { 11 | name: 'New Bar', 12 | address: '123 Park Dr.', 13 | deals: [{}], 14 | imageUrl: 'https://daydrink.io' 15 | }; 16 | }); 17 | 18 | test('should render name, address, and image', () => { 19 | const {getByText, getByAltText} = render(); 20 | const name = getByText(expectedProps.name); 21 | const address = getByText(expectedProps.address); 22 | const image = getByAltText(expectedProps.name); 23 | 24 | expect(name).toBeVisible(); 25 | expect(address).toBeVisible(); 26 | expect(image).toBeVisible(); 27 | }); 28 | 29 | test('badge with one deal', () => { 30 | const {getByText} = render(); 31 | const deal = getByText('1 deal'); 32 | 33 | expect(deal).toBeVisible(); 34 | }); 35 | 36 | test('badge with multiple deals', () => { 37 | expectedProps.deals = [{}, {}]; 38 | 39 | const {getByText} = render(); 40 | const deals = getByText('2 deals'); 41 | 42 | expect(deals).toBeVisible(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /graphql/queries.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const GET_DEALS_QUERY = gql` 4 | query getDeals($dayOfWeek: String!) { 5 | deals(where: {daysActive: {dayOfWeek: {_eq: $dayOfWeek}}}) { 6 | id 7 | description 8 | alcoholType 9 | userDeals { 10 | upvoted 11 | userId 12 | id 13 | } 14 | daysActive { 15 | id 16 | dayOfWeek 17 | startTime 18 | endTime 19 | allDay 20 | } 21 | location { 22 | id 23 | name 24 | } 25 | } 26 | } 27 | `; 28 | 29 | export const GET_LOCATIONS_QUERY = gql` 30 | query { 31 | locations { 32 | id 33 | name 34 | address 35 | imageUrl 36 | lat 37 | long 38 | city { 39 | id 40 | name 41 | state 42 | zip 43 | } 44 | deals { 45 | id 46 | } 47 | } 48 | } 49 | `; 50 | 51 | export const GET_CITIES_QUERY = gql` 52 | query { 53 | cities { 54 | id 55 | zip 56 | state 57 | name 58 | mapZoom 59 | long 60 | lat 61 | } 62 | } 63 | `; 64 | -------------------------------------------------------------------------------- /utils/search.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useContext, createContext} from 'react'; 2 | import {format} from 'date-fns'; 3 | 4 | const searchContext = createContext(); 5 | 6 | export function ProvideSearch({children}) { 7 | const search = useProvideSearch(); 8 | return {children}; 9 | } 10 | 11 | export const useSearch = () => { 12 | return useContext(searchContext); 13 | }; 14 | 15 | function useProvideSearch() { 16 | const today = format(new Date(), 'EEEE'); 17 | const [search, setSearch] = useState(''); 18 | const [dayOfWeek, setDayOfWeek] = useState(today); 19 | const [alcoholTypeFilters, setAlcoholTypeFilters] = useState(['BEER', 'WINE', 'LIQUOR', 'FOOD']); 20 | 21 | const onChangeDayOfWeek = (e) => { 22 | setDayOfWeek(e.target.value); 23 | }; 24 | 25 | const onFilterAlcoholType = (newValues) => { 26 | setAlcoholTypeFilters(newValues); 27 | }; 28 | 29 | const onSearch = (e) => { 30 | e.preventDefault(); 31 | 32 | const searchValue = e.target.value; 33 | const valueWithoutSlash = searchValue.replace('/', ''); 34 | 35 | setSearch(valueWithoutSlash); 36 | return valueWithoutSlash; 37 | }; 38 | 39 | return { 40 | alcoholTypeFilters, 41 | dayOfWeek, 42 | onFilterAlcoholType, 43 | onChangeDayOfWeek, 44 | onSearch, 45 | search 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "daydrink", 3 | "private": true, 4 | "engines": { 5 | "node": "12.13.1", 6 | "yarn": ">=1.10.0" 7 | }, 8 | "scripts": { 9 | "dev": "next dev", 10 | "build": "next build", 11 | "start": "next start", 12 | "test": "jest" 13 | }, 14 | "dependencies": { 15 | "@apollo/react-hooks": "^3.1.3", 16 | "@apollo/react-ssr": "^3.1.3", 17 | "@chakra-ui/core": "^0.5.2", 18 | "@emotion/core": "10.0.27", 19 | "@emotion/styled": "10.0.27", 20 | "apollo-cache-inmemory": "^1.6.5", 21 | "apollo-client": "^2.6.8", 22 | "apollo-link-http": "^1.5.16", 23 | "date-fns": "^2.9.0", 24 | "emotion-theming": "10.0.27", 25 | "firebase": "^7.7.0", 26 | "graphql": "^14.5.8", 27 | "graphql-tag": "^2.10.1", 28 | "isomorphic-unfetch": "^3.0.0", 29 | "next": "9.2.0", 30 | "next-seo": "3.3.0", 31 | "query-string": "^6.10.0", 32 | "react": "^16.8.6", 33 | "react-dom": "^16.8.6", 34 | "react-hook-form": "^4.5.5", 35 | "react-map-gl": "^5.2.1" 36 | }, 37 | "devDependencies": { 38 | "@testing-library/jest-dom": "^5.0.2", 39 | "@testing-library/react": "^9.4.0", 40 | "@types/react": "^16.9.19", 41 | "babel-jest": "^24.9.0", 42 | "jest": "^24.9.0", 43 | "mutationobserver-shim": "^0.3.3", 44 | "typescript": "^3.7.5" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /components/DealCard.js: -------------------------------------------------------------------------------- 1 | import {useColorMode, Box, Badge, Text, Flex, Stack} from '@chakra-ui/core'; 2 | 3 | import Voter from './Voter'; 4 | 5 | const badgeColors = { 6 | BEER: 'teal', 7 | WINE: 'red', 8 | LIQUOR: 'blue', 9 | FOOD: 'orange' 10 | }; 11 | 12 | const DealCard = ({id, userId, daysActive, location, score, userDeals, description, alcoholType}) => { 13 | const {colorMode} = useColorMode(); 14 | const start = daysActive[0].startTime; 15 | const end = daysActive[0].endTime; 16 | 17 | return ( 18 | 25 | 26 | 27 | 28 | 29 | {alcoholType} 30 | 31 | 32 | 33 | {description} 34 | 35 | {`${start} - ${end}`} 36 | 37 | {location.name} 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default DealCard; 45 | -------------------------------------------------------------------------------- /components/BarCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useColorMode, Box, Image, AspectRatioBox, Badge, Text, Flex, Stack} from '@chakra-ui/core'; 3 | import Location from '../icons/Location'; 4 | 5 | interface BarProps { 6 | name: string; 7 | address: string; 8 | deals: object[]; 9 | imageUrl: string; 10 | } 11 | 12 | const BarCard = ({name, address, deals, imageUrl}: BarProps) => { 13 | const {colorMode} = useColorMode(); 14 | const badge = deals.length === 1 ? `${deals.length} deal` : `${deals.length} deals`; 15 | 16 | return ( 17 | 24 | 25 | 26 | {name} 27 | 28 | 29 | 30 | {badge} 31 | 32 | 33 | {name} 34 | 35 | 36 | 37 | {address} 38 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default BarCard; 46 | -------------------------------------------------------------------------------- /pages/bars.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core'; 3 | import {Text, Flex, Spinner} from '@chakra-ui/core'; 4 | import {useQuery} from '@apollo/react-hooks'; 5 | 6 | import {useSearch} from '../utils/search'; 7 | import {withApollo} from '../graphql/apollo'; 8 | import {GET_LOCATIONS_QUERY} from '../graphql/queries'; 9 | import App from '../components/App'; 10 | import BarCard from '../components/BarCard'; 11 | 12 | const BarsPage = () => { 13 | const {search} = useSearch(); 14 | const {data} = useQuery(GET_LOCATIONS_QUERY); 15 | 16 | const matchesSearch = (location) => location.name.toLowerCase().includes(search.toLowerCase()); 17 | const allLocations = data ? data.locations : []; 18 | const filteredLocations = allLocations.filter(matchesSearch); 19 | 20 | return ( 21 | 22 | 23 | {'Open Now'} 24 | 25 | {!data ? ( 26 | 27 | 28 | 29 | ) : ( 30 | <> 31 | {filteredLocations.map((bar) => ( 32 | 33 | ))} 34 | 35 | {`Showing ${filteredLocations.length} out of ${allLocations.length} bars in Des Moines`} 36 | 37 | 38 | )} 39 | 40 | ); 41 | }; 42 | 43 | export default withApollo(BarsPage, { 44 | ssr: false 45 | }); 46 | -------------------------------------------------------------------------------- /components/MobileNav.js: -------------------------------------------------------------------------------- 1 | import {Drawer, DrawerBody, IconButton, useDisclosure, DrawerOverlay, DrawerContent} from '@chakra-ui/core'; 2 | import React, {useEffect} from 'react'; 3 | import {useRouter} from 'next/router'; 4 | 5 | import SideNav from './SideNav'; 6 | import Hamburger from '../icons/Hamburger'; 7 | 8 | const useRouteChanged = (callback) => { 9 | const router = useRouter(); 10 | 11 | useEffect(() => { 12 | const handleRouteChange = (url) => { 13 | callback(); 14 | console.log('App is changing to: ', url); 15 | }; 16 | 17 | router.events.on('routeChangeComplete', handleRouteChange); 18 | 19 | return () => { 20 | router.events.off('routeChangeComplete', handleRouteChange); 21 | }; 22 | }, [router.events, callback]); 23 | }; 24 | 25 | const MobileNav = () => { 26 | const {isOpen, onToggle, onClose} = useDisclosure(); 27 | 28 | useRouteChanged(onClose); 29 | 30 | return ( 31 | <> 32 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | export default MobileNav; 54 | -------------------------------------------------------------------------------- /graphql/mutations.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const CREATE_DEAL_MUTATION = gql` 4 | mutation createDeal( 5 | $alcoholType: String! 6 | $description: String! 7 | $locationId: uuid! 8 | $daysActive: [deal_day_insert_input!]! 9 | ) { 10 | insert_deals( 11 | objects: { 12 | alcoholType: $alcoholType 13 | description: $description 14 | locationId: $locationId 15 | daysActive: {data: $daysActive} 16 | } 17 | ) { 18 | returning { 19 | id 20 | description 21 | alcoholType 22 | userDeals { 23 | upvoted 24 | userId 25 | id 26 | } 27 | daysActive { 28 | id 29 | dayOfWeek 30 | startTime 31 | endTime 32 | allDay 33 | } 34 | location { 35 | id 36 | name 37 | } 38 | } 39 | } 40 | } 41 | `; 42 | 43 | export const UPDATE_USER_DEAL_MUTATION = gql` 44 | mutation updateUserDeal($upvoted: Boolean!, $dealId: uuid!, $userId: String!) { 45 | update_user_deal(where: {dealId: {_eq: $dealId}, userId: {_eq: $userId}}, _set: {upvoted: $upvoted}) { 46 | returning { 47 | upvoted 48 | userId 49 | id 50 | } 51 | } 52 | } 53 | `; 54 | 55 | export const INSERT_USER_DEAL_MUTATION = gql` 56 | mutation insertUserDeal($upvoted: Boolean!, $dealId: uuid!, $userId: String!) { 57 | insert_user_deal(objects: {upvoted: $upvoted, dealId: $dealId, userId: $userId}) { 58 | returning { 59 | upvoted 60 | userId 61 | id 62 | } 63 | } 64 | } 65 | `; 66 | -------------------------------------------------------------------------------- /components/SideNav.js: -------------------------------------------------------------------------------- 1 | import {useColorMode, Stack, Text, Box, Flex} from '@chakra-ui/core'; 2 | import React from 'react'; 3 | 4 | import {ComponentLink} from './NavLink'; 5 | import AddDealModal from './AddDealModal'; 6 | import Deal from '../icons/Deal'; 7 | import Filters from './Filters'; 8 | import Home from '../icons/Home'; 9 | import Map from '../icons/Map'; 10 | import WineGlass from '../icons/WineGlass'; 11 | 12 | const SideNavLink = ({href, children, icon}) => ( 13 | 14 | 15 | 16 | {children} 17 | 18 | 19 | ); 20 | 21 | const PageLinks = () => ( 22 | 23 | 24 | {'Home'} 25 | 26 | 27 | {'Deals'} 28 | 29 | 30 | {'Bars'} 31 | 32 | 33 | {'Map'} 34 | 35 | 36 | ); 37 | 38 | const SideNav = (props) => { 39 | const {colorMode} = useColorMode(); 40 | 41 | return ( 42 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ); 63 | }; 64 | 65 | export default SideNav; 66 | -------------------------------------------------------------------------------- /pages/_error.js: -------------------------------------------------------------------------------- 1 | import {Box, Flex, Heading, Text, Button} from '@chakra-ui/core'; 2 | import NextLink from 'next/link'; 3 | 4 | import Logo from '../components/Logo'; 5 | 6 | export const Container = (props) => ; 7 | 8 | const Header = ({onSignIn}) => ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | 28 | const ErrorPage = ({onSignIn}) => { 29 | return ( 30 | 31 |
32 | 33 | 34 | 35 | 36 | Bar Not Found: Closing Time 37 | 38 | 39 | 40 | You don't have to go home, but you can't stay here. 41 | 42 | 43 | 44 | 45 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | }; 56 | 57 | export default ErrorPage; 58 | -------------------------------------------------------------------------------- /components/Filters.js: -------------------------------------------------------------------------------- 1 | import {useColorMode, Box, Text, Stack, CheckboxGroup, Checkbox, Select} from '@chakra-ui/core'; 2 | 3 | import {useSearch} from '../utils/search'; 4 | 5 | const Filters = (props) => { 6 | const {colorMode} = useColorMode(); 7 | const {alcoholTypeFilters, dayOfWeek, onChangeDayOfWeek, onFilterAlcoholType} = useSearch(); 8 | const inputBg = {light: '#EDF2F7', dark: 'gray.700'}; 9 | 10 | return ( 11 | 12 | 13 | 14 | {'Location'} 15 | 16 | 19 | 20 | 21 | 22 | {'Showing Deals For'} 23 | 24 | 33 | 34 | 35 | 36 | 37 | {'Deal Type'} 38 | 39 | 45 | Beer 46 | Wine 47 | Liquor 48 | Food 49 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default Filters; 56 | -------------------------------------------------------------------------------- /pages/deals.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core'; 3 | import {Text, Flex, Spinner} from '@chakra-ui/core'; 4 | 5 | import {useDeals} from '../graphql/hooks'; 6 | import {useAuth} from '../utils/auth'; 7 | import {useSearch} from '../utils/search'; 8 | import {withApollo} from '../graphql/apollo'; 9 | import App from '../components/App'; 10 | import DealCard from '../components/DealCard'; 11 | import AddDealModal from '../components/AddDealModal'; 12 | import EmptySearch from '../components/EmptySearch'; 13 | 14 | const DealsPage = () => { 15 | const {userId} = useAuth(); 16 | const {dayOfWeek, alcoholTypeFilters, search} = useSearch(); 17 | const {data, loading} = useDeals(dayOfWeek); 18 | 19 | const matchesSearch = (deal) => deal.description.toLowerCase().includes(search.toLowerCase()); 20 | const matchesAlcoholType = (deal) => alcoholTypeFilters.includes(deal.alcoholType); 21 | const allDeals = data ? data.deals : []; 22 | const filteredDeals = allDeals.filter(matchesSearch).filter(matchesAlcoholType); 23 | 24 | return ( 25 | 26 | 27 | {'Active '} 28 | {dayOfWeek} 29 | {' in '} 30 | {'Des Moines'} 31 | 32 | {loading ? ( 33 | 34 | 35 | 36 | ) : ( 37 | <> 38 | {filteredDeals.length ? ( 39 | filteredDeals.map((deal) => ) 40 | ) : ( 41 | 42 | )} 43 | 44 | {`Showing ${filteredDeals.length} out of ${allDeals.length} deals in Des Moines`} 45 | 46 | 47 | 48 | 49 | 50 | )} 51 | 52 | ); 53 | }; 54 | 55 | export default withApollo(DealsPage, { 56 | ssr: false 57 | }); 58 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import {Box, Flex, Heading, Text, Button} from '@chakra-ui/core'; 2 | import NextLink from 'next/link'; 3 | 4 | import {withSignInRedirect} from '../components/Auth'; 5 | import Logo from '../components/Logo'; 6 | 7 | export const Container = (props) => ; 8 | 9 | const Header = ({onSignIn}) => ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | 29 | const HomePage = ({onSignIn}) => { 30 | return ( 31 | 32 |
33 | 34 | 35 | 36 | 37 | Find the cheapest drinks deals happening right now. 38 | 39 | 40 | 41 | daydrink helps you find the best drink deals and happy hours in your area. View the cheapest 42 | drinks for the day and filter down to exactly what you're searching for. 43 | 44 | 45 | 46 | 47 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | 59 | export default withSignInRedirect(HomePage); 60 | -------------------------------------------------------------------------------- /utils/auth.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect, useContext, createContext} from 'react'; 2 | import queryString from 'query-string'; 3 | import * as firebase from 'firebase/app'; 4 | import 'firebase/auth'; 5 | 6 | import prod from '../.firebase/prod.json'; 7 | 8 | if (!firebase.apps.length) { 9 | firebase.initializeApp(prod); 10 | } 11 | 12 | const authContext = createContext(); 13 | 14 | export function ProvideAuth({children}) { 15 | const auth = useProvideAuth(); 16 | return {children}; 17 | } 18 | 19 | export const useAuth = () => { 20 | return useContext(authContext); 21 | }; 22 | 23 | function useProvideAuth() { 24 | const [user, setUser] = useState(null); 25 | 26 | const signin = (email, password) => { 27 | return firebase 28 | .auth() 29 | .signInWithEmailAndPassword(email, password) 30 | .then((response) => { 31 | setUser(response.user); 32 | return response.user; 33 | }); 34 | }; 35 | 36 | const signup = (email, password) => { 37 | return firebase 38 | .auth() 39 | .createUserWithEmailAndPassword(email, password) 40 | .then((response) => { 41 | setUser(response.user); 42 | return response.user; 43 | }); 44 | }; 45 | 46 | const signout = () => { 47 | return firebase 48 | .auth() 49 | .signOut() 50 | .then(() => { 51 | setUser(false); 52 | }); 53 | }; 54 | 55 | const sendPasswordResetEmail = (email) => { 56 | return firebase 57 | .auth() 58 | .sendPasswordResetEmail(email) 59 | .then(() => { 60 | return true; 61 | }); 62 | }; 63 | 64 | const confirmPasswordReset = (password, code) => { 65 | const resetCode = code || getFromQueryString('oobCode'); 66 | 67 | return firebase 68 | .auth() 69 | .confirmPasswordReset(resetCode, password) 70 | .then(() => { 71 | return true; 72 | }); 73 | }; 74 | 75 | useEffect(() => { 76 | const unsubscribe = firebase.auth().onAuthStateChanged((user) => { 77 | if (user) { 78 | setUser(user); 79 | } else { 80 | setUser(false); 81 | } 82 | }); 83 | 84 | return () => unsubscribe(); 85 | }, []); 86 | 87 | return { 88 | userId: user && user.uid, 89 | signin, 90 | signup, 91 | signout, 92 | sendPasswordResetEmail, 93 | confirmPasswordReset 94 | }; 95 | } 96 | 97 | const getFromQueryString = (key) => { 98 | return queryString.parse(window.location.search)[key]; 99 | }; 100 | -------------------------------------------------------------------------------- /components/NavLink.js: -------------------------------------------------------------------------------- 1 | import {Box, PseudoBox, useColorMode} from '@chakra-ui/core'; 2 | import NextLink from 'next/link'; 3 | import {useRouter} from 'next/router'; 4 | import React, {cloneElement, forwardRef} from 'react'; 5 | 6 | const NavLink = ({children, ...props}) => { 7 | const router = useRouter(); 8 | let isActive = false; 9 | 10 | if (router.pathname === props.href) { 11 | isActive = true; 12 | } 13 | 14 | return ( 15 | 16 | {typeof children === 'function' ? children(isActive) : children} 17 | 18 | ); 19 | }; 20 | 21 | export const stringToUrl = (str, path = '/') => { 22 | return `${path}${str 23 | .toLowerCase() 24 | .split(' ') 25 | .join('-')}`; 26 | }; 27 | 28 | export const SideNavLink = forwardRef(({children, icon, ...props}, ref) => { 29 | const {colorMode} = useColorMode(); 30 | const color = {light: 'gray.700', dark: 'whiteAlpha.700'}; 31 | return ( 32 | 48 | {icon && cloneElement(icon, {mr: 3})} 49 | {children} 50 | 51 | ); 52 | }); 53 | 54 | export const TopNavLink = forwardRef(({href, ...props}, ref) => { 55 | return ( 56 | 57 | {(isActive) => ( 58 | 65 | )} 66 | 67 | ); 68 | }); 69 | 70 | export const ComponentLink = forwardRef(({href, ...props}, ref) => { 71 | const {colorMode} = useColorMode(); 72 | const hoverColor = {light: 'gray.900', dark: 'whiteAlpha.900'}; 73 | const activeColor = {light: 'teal.800', dark: 'teal.200'}; 74 | const activeBg = {light: 'gray.100', dark: 'gray.700'}; 75 | 76 | return ( 77 | 78 | {(isActive) => ( 79 | 95 | )} 96 | 97 | ); 98 | }); 99 | -------------------------------------------------------------------------------- /components/Voter.js: -------------------------------------------------------------------------------- 1 | import {Box, IconButton, Stack} from '@chakra-ui/core'; 2 | import {useMutation} from '@apollo/react-hooks'; 3 | 4 | import {UPDATE_USER_DEAL_MUTATION, INSERT_USER_DEAL_MUTATION} from '../graphql/mutations'; 5 | import {GET_DEALS_QUERY} from '../graphql/queries'; 6 | import {calculateScoreAndSortDesc} from '../utils/deals'; 7 | import {useSearch} from '../utils/search'; 8 | import {withAuthModal} from './Auth'; 9 | 10 | const updateCacheAfterInsert = ({cache, data, dealId, dayOfWeek}) => { 11 | const cachedData = cache.readQuery({ 12 | query: GET_DEALS_QUERY, 13 | variables: {dayOfWeek} 14 | }); 15 | 16 | const newUserDeal = data['insert_user_deal'].returning[0]; 17 | const currentDeal = cachedData.deals.find((deal) => deal.id === dealId); 18 | 19 | currentDeal.userDeals.push(newUserDeal); 20 | 21 | cache.writeQuery({ 22 | query: GET_DEALS_QUERY, 23 | variables: {dayOfWeek}, 24 | data: { 25 | ...cachedData, 26 | deals: calculateScoreAndSortDesc(cachedData.deals) 27 | } 28 | }); 29 | }; 30 | 31 | const Voter = ({dealId, userId, score, userDeals, openAuthModal}) => { 32 | const {dayOfWeek} = useSearch(); 33 | const currentUserVotedDeal = userDeals.find((voted) => voted.userId === userId); 34 | const upvoted = currentUserVotedDeal && currentUserVotedDeal.upvoted; 35 | const downvoted = currentUserVotedDeal && !currentUserVotedDeal.upvoted; 36 | 37 | const [updateUserDeal] = useMutation(UPDATE_USER_DEAL_MUTATION); 38 | const [insertUserDeal] = useMutation(INSERT_USER_DEAL_MUTATION); 39 | 40 | const onVote = (upvoted) => { 41 | if (!userId) { 42 | openAuthModal(); 43 | } 44 | 45 | if (currentUserVotedDeal) { 46 | return updateUserDeal({ 47 | variables: { 48 | dealId, 49 | upvoted, 50 | userId 51 | } 52 | }); 53 | } 54 | 55 | return insertUserDeal({ 56 | variables: { 57 | dealId, 58 | upvoted, 59 | userId 60 | }, 61 | update: (cache, {data}) => 62 | updateCacheAfterInsert({ 63 | cache, 64 | data, 65 | dealId, 66 | dayOfWeek 67 | }) 68 | }); 69 | }; 70 | 71 | return ( 72 | <> 73 | 74 | onVote(true)} 80 | variant={upvoted ? 'solid' : 'ghost'} 81 | color="gray.500" 82 | /> 83 | {score} 84 | onVote(false)} 90 | variant={downvoted ? 'solid' : 'ghost'} 91 | color="gray.500" 92 | /> 93 | 94 | 95 | ); 96 | }; 97 | 98 | export default withAuthModal(Voter); 99 | -------------------------------------------------------------------------------- /test/pages/signin.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useRouter} from 'next/router'; 3 | 4 | import SignIn from '../../pages/signin'; 5 | import {act, render, fireEvent} from '../test-utils'; 6 | import {useAuth} from '../../utils/auth'; 7 | 8 | jest.mock('next/router'); 9 | jest.mock('../../utils/auth'); 10 | 11 | describe('SignIn', () => { 12 | let expectedSignIn, expectedEmail, expectedPassword, expectedRouterPush; 13 | 14 | beforeEach(() => { 15 | expectedRouterPush = jest.fn(); 16 | expectedSignIn = jest.fn(); 17 | expectedSignIn.mockResolvedValue(''); 18 | expectedEmail = 'me@leerob.io'; 19 | expectedPassword = '123'; 20 | 21 | useRouter.mockReturnValue({push: expectedRouterPush}); 22 | useAuth.mockReturnValue({ 23 | signin: expectedSignIn, 24 | userId: 123 25 | }); 26 | }); 27 | 28 | test('should redirect on sign in', async () => { 29 | const {getByText, getByLabelText} = render(); 30 | const email = getByLabelText('Email Address'); 31 | const password = getByLabelText('Password'); 32 | const signInButton = getByText('Sign In'); 33 | 34 | await act(async () => { 35 | fireEvent.change(email, {target: {value: expectedEmail}}); 36 | fireEvent.change(password, {target: {value: expectedPassword}}); 37 | fireEvent.click(signInButton); 38 | }); 39 | 40 | expect(expectedSignIn).toHaveBeenCalledTimes(1); 41 | expect(expectedSignIn).toHaveBeenCalledWith(expectedEmail, expectedPassword); 42 | 43 | expect(expectedRouterPush).toHaveBeenCalledTimes(1); 44 | expect(expectedRouterPush).toHaveBeenCalledWith('/deals'); 45 | }); 46 | 47 | test('should show toast error', async () => { 48 | expectedSignIn.mockRejectedValue({ 49 | message: 'Invalid username.' 50 | }); 51 | 52 | const {getByText, getByLabelText} = render(); 53 | const email = getByLabelText('Email Address'); 54 | const password = getByLabelText('Password'); 55 | const signInButton = getByText('Sign In'); 56 | 57 | await act(async () => { 58 | fireEvent.change(email, {target: {value: 'foo'}}); 59 | fireEvent.change(password, {target: {value: expectedPassword}}); 60 | fireEvent.click(signInButton); 61 | }); 62 | 63 | expect(expectedSignIn).toHaveBeenCalledTimes(1); 64 | expect(expectedSignIn).toHaveBeenCalledWith('foo', expectedPassword); 65 | 66 | const errorToast = getByText('An error occurred.'); 67 | const errorMessage = getByText('Invalid username.'); 68 | 69 | expect(errorToast).toBeVisible(); 70 | expect(errorMessage).toBeVisible(); 71 | }); 72 | 73 | test('should show error for required fields', async () => { 74 | const {getByText} = render(); 75 | const signInButton = getByText('Sign In'); 76 | 77 | await act(async () => { 78 | fireEvent.click(signInButton); 79 | }); 80 | 81 | const emailError = getByText('Please enter your email.'); 82 | const passwordError = getByText('Please enter a password.'); 83 | 84 | expect(emailError).toBeVisible(); 85 | expect(passwordError).toBeVisible(); 86 | 87 | expect(expectedSignIn).not.toHaveBeenCalled(); 88 | expect(expectedRouterPush).not.toHaveBeenCalled(); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /components/Header.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {Box, Flex, IconButton, useColorMode, InputGroup, InputLeftElement, Input, Icon} from '@chakra-ui/core'; 3 | import {jsx} from '@emotion/core'; 4 | import {useState, useEffect, useRef} from 'react'; 5 | 6 | import MobileNav from './MobileNav'; 7 | import Logo from './Logo'; 8 | 9 | const useKeyPress = (targetKey) => { 10 | const [keyPressed, setKeyPressed] = useState(false); 11 | 12 | const downHandler = ({key}) => { 13 | if (key === targetKey) { 14 | setKeyPressed(true); 15 | } 16 | }; 17 | 18 | const upHandler = ({key}) => { 19 | if (key === targetKey) { 20 | setKeyPressed(false); 21 | } 22 | }; 23 | 24 | useEffect(() => { 25 | window.addEventListener('keydown', downHandler); 26 | window.addEventListener('keyup', upHandler); 27 | 28 | return () => { 29 | window.removeEventListener('keydown', downHandler); 30 | window.removeEventListener('keyup', upHandler); 31 | }; 32 | }, []); 33 | 34 | return keyPressed; 35 | }; 36 | 37 | const Header = (props) => { 38 | const {onSearch, search, hideSearch, ...rest} = props; 39 | const {colorMode, toggleColorMode} = useColorMode(); 40 | const inputRef = useRef(); 41 | const slashPress = useKeyPress('/'); 42 | const bg = {light: 'white', dark: 'gray.800'}; 43 | 44 | if (slashPress) { 45 | inputRef.current.focus(); 46 | } 47 | 48 | return ( 49 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | } /> 69 | 78 | 79 | 80 | 81 | 90 | {!hideSearch && } 91 | 92 | 93 | 94 | 95 | ); 96 | }; 97 | 98 | export default Header; 99 | -------------------------------------------------------------------------------- /graphql/apollo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Head from 'next/head'; 3 | import {ApolloProvider} from '@apollo/react-hooks'; 4 | import {ApolloClient} from 'apollo-client'; 5 | import {InMemoryCache} from 'apollo-cache-inmemory'; 6 | import {HttpLink} from 'apollo-link-http'; 7 | import fetch from 'isomorphic-unfetch'; 8 | 9 | let apolloClient = null; 10 | 11 | /** 12 | * Creates and provides the apolloContext 13 | * to a next.js PageTree. Use it by wrapping 14 | * your PageComponent via HOC pattern. 15 | * @param {Function|Class} PageComponent 16 | * @param {Object} [config] 17 | * @param {Boolean} [config.ssr=true] 18 | */ 19 | export function withApollo(PageComponent, {ssr = true} = {}) { 20 | const WithApollo = ({apolloClient, apolloState, ...pageProps}) => { 21 | const client = apolloClient || initApolloClient(apolloState); 22 | return ( 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | // Set the correct displayName in development 30 | if (process.env.NODE_ENV !== 'production') { 31 | const displayName = PageComponent.displayName || PageComponent.name || 'Component'; 32 | 33 | if (displayName === 'App') { 34 | console.warn('This withApollo HOC only works with PageComponents.'); 35 | } 36 | 37 | WithApollo.displayName = `withApollo(${displayName})`; 38 | } 39 | 40 | if (ssr || PageComponent.getInitialProps) { 41 | WithApollo.getInitialProps = async (ctx) => { 42 | const {AppTree} = ctx; 43 | 44 | // Initialize ApolloClient, add it to the ctx object so 45 | // we can use it in `PageComponent.getInitialProp`. 46 | const apolloClient = (ctx.apolloClient = initApolloClient()); 47 | 48 | // Run wrapped getInitialProps methods 49 | let pageProps = {}; 50 | if (PageComponent.getInitialProps) { 51 | pageProps = await PageComponent.getInitialProps(ctx); 52 | } 53 | 54 | // Only on the server: 55 | if (typeof window === 'undefined') { 56 | // When redirecting, the response is finished. 57 | // No point in continuing to render 58 | if (ctx.res && ctx.res.finished) { 59 | return pageProps; 60 | } 61 | 62 | // Only if ssr is enabled 63 | if (ssr) { 64 | try { 65 | // Run all GraphQL queries 66 | const {getDataFromTree} = await import('@apollo/react-ssr'); 67 | await getDataFromTree( 68 | 74 | ); 75 | } catch (error) { 76 | // Prevent Apollo Client GraphQL errors from crashing SSR. 77 | // Handle them in components via the data.error prop: 78 | // https://www.apollographql.com/docs/react/api/react-apollo.html#graphql-query-data-error 79 | console.error('Error while running `getDataFromTree`', error); 80 | } 81 | 82 | // getDataFromTree does not call componentWillUnmount 83 | // head side effect therefore need to be cleared manually 84 | Head.rewind(); 85 | } 86 | } 87 | 88 | // Extract query data from the Apollo store 89 | const apolloState = apolloClient.cache.extract(); 90 | 91 | return { 92 | ...pageProps, 93 | apolloState 94 | }; 95 | }; 96 | } 97 | 98 | return WithApollo; 99 | } 100 | 101 | function initApolloClient(initialState) { 102 | // Make sure to create a new client for every server-side request so that data 103 | // isn't shared between connections (which would be bad) 104 | if (typeof window === 'undefined') { 105 | return createApolloClient(initialState); 106 | } 107 | 108 | // Reuse client on the client-side 109 | if (!apolloClient) { 110 | apolloClient = createApolloClient(initialState); 111 | } 112 | 113 | return apolloClient; 114 | } 115 | 116 | function createApolloClient(initialState = {}) { 117 | return new ApolloClient({ 118 | ssrMode: typeof window === 'undefined', 119 | link: new HttpLink({ 120 | uri: process.env.GRAPHQL_URL, 121 | credentials: 'same-origin', 122 | fetch 123 | }), 124 | cache: new InMemoryCache().restore(initialState) 125 | }); 126 | } 127 | -------------------------------------------------------------------------------- /components/Auth.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core'; 3 | import { 4 | Box, 5 | Button, 6 | Flex, 7 | FormControl, 8 | FormErrorMessage, 9 | FormLabel, 10 | Input, 11 | Modal, 12 | ModalBody, 13 | ModalCloseButton, 14 | ModalContent, 15 | ModalOverlay, 16 | Stack, 17 | useColorMode, 18 | useDisclosure, 19 | useToast 20 | } from '@chakra-ui/core'; 21 | import {useForm} from 'react-hook-form'; 22 | import {useRouter} from 'next/router'; 23 | 24 | import Logo from '../components/Logo'; 25 | import {useAuth} from '../utils/auth'; 26 | 27 | const AuthContent = ({register, errors, type, ...rest}) => ( 28 | 29 | 30 | 31 | 32 | 33 | Email Address 34 | 43 | {errors.email && errors.email.message} 44 | 45 | 46 | Password 47 | 55 | {errors.pass && errors.pass.message} 56 | 57 | 60 | 61 | ); 62 | 63 | const FullScreenAuth = ({type, onSubmit}) => { 64 | const {colorMode} = useColorMode(); 65 | const {handleSubmit, register, errors} = useForm(); 66 | 67 | return ( 68 | 69 | onSubmit(data))} 76 | px={8} 77 | py={12} 78 | register={register} 79 | shadow={[null, 'md']} 80 | spacing={3} 81 | type={type} 82 | w="100%" 83 | /> 84 | 85 | ); 86 | }; 87 | 88 | const AuthModal = ({isOpen, onClose, type, onSubmit}) => { 89 | const {handleSubmit, register, errors} = useForm(); 90 | 91 | return ( 92 | 93 | 94 | 95 | 96 | 97 | 98 | onSubmit(data))} 102 | px={8} 103 | py={12} 104 | register={register} 105 | spacing={3} 106 | type={type} 107 | w="100%" 108 | /> 109 | 110 | 111 | 112 | 113 | ); 114 | }; 115 | 116 | export const withAuthModal = (Component) => (props) => { 117 | const {isOpen, onOpen, onClose} = useDisclosure(); 118 | const auth = useAuth(); 119 | const toast = useToast(); 120 | 121 | const signUp = ({email, pass}) => { 122 | auth.signup(email, pass) 123 | .then(() => { 124 | toast({ 125 | title: 'Success! 🍻', 126 | description: 'Your account has been created.', 127 | status: 'success', 128 | duration: 3000, 129 | isClosable: true 130 | }); 131 | onClose(); 132 | }) 133 | .catch((error) => { 134 | toast({ 135 | title: 'An error occurred.', 136 | description: error.message, 137 | status: 'error', 138 | duration: 9000, 139 | isClosable: true 140 | }); 141 | }); 142 | }; 143 | return ( 144 | <> 145 | 146 | 147 | 148 | ); 149 | }; 150 | 151 | export const withSignInRedirect = (Component) => (props) => { 152 | const {isOpen, onOpen, onClose} = useDisclosure(); 153 | const auth = useAuth(); 154 | const toast = useToast(); 155 | const router = useRouter(); 156 | 157 | const signIn = ({email, pass}) => { 158 | auth.signin(email, pass) 159 | .then(() => { 160 | router.push('/deals'); 161 | }) 162 | .catch((error) => { 163 | toast({ 164 | title: 'An error occurred.', 165 | description: error.message, 166 | status: 'error', 167 | duration: 9000, 168 | isClosable: true 169 | }); 170 | }); 171 | }; 172 | 173 | return ( 174 | <> 175 | 176 | 177 | 178 | ); 179 | }; 180 | 181 | export default FullScreenAuth; 182 | -------------------------------------------------------------------------------- /components/AddDealModal.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useRef} from 'react'; 2 | import {useForm} from 'react-hook-form'; 3 | import {useQuery, useMutation} from '@apollo/react-hooks'; 4 | import { 5 | useDisclosure, 6 | Modal, 7 | ModalOverlay, 8 | ModalContent, 9 | ModalHeader, 10 | ModalFooter, 11 | ModalCloseButton, 12 | ModalBody, 13 | FormControl, 14 | FormErrorMessage, 15 | FormLabel, 16 | Input, 17 | Select, 18 | Button, 19 | RadioGroup, 20 | Radio, 21 | Flex 22 | } from '@chakra-ui/core'; 23 | 24 | import {GET_DEALS_QUERY, GET_LOCATIONS_QUERY} from '../graphql/queries'; 25 | import {CREATE_DEAL_MUTATION} from '../graphql/mutations'; 26 | import {useSearch} from '../utils/search'; 27 | import {withAuthModal} from './Auth'; 28 | import {useAuth} from '../utils/auth'; 29 | import WeekdayButtonGroup from './WeekdayButtonGroup'; 30 | 31 | function AddDealModal({openAuthModal}) { 32 | const {userId} = useAuth(); 33 | const initialRef = useRef(); 34 | const {isOpen, onOpen, onClose} = useDisclosure(); 35 | const {handleSubmit, register, errors} = useForm(); 36 | const {dayOfWeek} = useSearch(); 37 | const [alcoholType, setAlcoholType] = useState('BEER'); 38 | const [daysActive, setDaysActive] = useState(['Monday']); 39 | const [createPost, {loading}] = useMutation(CREATE_DEAL_MUTATION); 40 | const {data} = useQuery(GET_LOCATIONS_QUERY); 41 | 42 | const onCreateDeal = ( 43 | {alcoholType, description, locationId, startTime, endTime, dayOfWeek, daysActive}, 44 | onClose 45 | ) => { 46 | const daysActiveMap = daysActive.map((day) => ({ 47 | dayOfWeek: day, 48 | startTime, 49 | endTime 50 | })); 51 | 52 | createPost({ 53 | variables: { 54 | alcoholType, 55 | description, 56 | locationId, 57 | daysActive: daysActiveMap 58 | }, 59 | update: (cache, {data}) => { 60 | const cachedData = cache.readQuery({ 61 | query: GET_DEALS_QUERY, 62 | variables: {dayOfWeek} 63 | }); 64 | 65 | const newDeal = data['insert_deals'].returning[0]; 66 | 67 | cache.writeQuery({ 68 | query: GET_DEALS_QUERY, 69 | variables: {dayOfWeek}, 70 | data: { 71 | ...cachedData, 72 | deals: [newDeal, ...cachedData.deals] 73 | } 74 | }); 75 | } 76 | }); 77 | 78 | onClose(); 79 | }; 80 | 81 | const onOpenDealModal = () => { 82 | if (!userId) { 83 | return openAuthModal(); 84 | } 85 | 86 | onOpen(); 87 | }; 88 | 89 | return ( 90 | <> 91 | 94 | 95 | 96 | 97 | 98 |
100 | onCreateDeal( 101 | { 102 | alcoholType, 103 | dayOfWeek, 104 | daysActive, 105 | description: data.description, 106 | endTime: data.endTime, 107 | locationId: data.locationId, 108 | startTime: data.startTime 109 | }, 110 | onClose 111 | ) 112 | )} 113 | > 114 | Add Deal 115 | 116 | 117 | 118 | Alcohol Type 119 | setAlcoholType(e.target.value)} 125 | > 126 | Beer 127 | Wine 128 | Liquor 129 | Food 130 | 131 | 132 | 133 | Description 134 | 141 | {errors.description && errors.description.message} 142 | 143 | 144 | Location 145 | 160 | {errors.locationId && errors.locationId.message} 161 | 162 | 163 | Days Active 164 | 165 | {errors.daysActive && errors.daysActive.message} 166 | 167 | 168 | 169 | Start Time 170 | 177 | {errors.startTime && errors.startTime.message} 178 | 179 | 180 | End Time 181 | 188 | {errors.endTime && errors.endTime.message} 189 | 190 | 191 | 192 | 193 | 194 | 195 | 198 | 199 | 200 |
201 |
202 | 203 | ); 204 | } 205 | 206 | export default withAuthModal(AddDealModal); 207 | -------------------------------------------------------------------------------- /components/Logo.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {useColorMode, Box} from '@chakra-ui/core'; 3 | import {jsx} from '@emotion/core'; 4 | 5 | const Logo = (props) => { 6 | const {colorMode} = useColorMode(); 7 | 8 | return ( 9 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default Logo; 23 | --------------------------------------------------------------------------------