├── .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 | 
6 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------