├── .env ├── .gitignore ├── src ├── styled │ ├── Flex.tsx │ ├── ProductGrid.tsx │ ├── AccountSidebar.tsx │ ├── ProductDetails.tsx │ ├── Input.tsx │ ├── AuthFormLayout.tsx │ ├── AppLayout.tsx │ ├── LoadingIndicator.tsx │ ├── Field.tsx │ ├── Button.tsx │ ├── Form.tsx │ └── AppHeader.tsx ├── react-app-env.d.ts ├── types │ ├── Omit.ts │ ├── User.ts │ └── RoutingContext.ts ├── routes │ ├── account │ │ └── details.tsx │ ├── logout.tsx │ ├── index.tsx │ ├── landing │ │ └── index.tsx │ ├── requestPasswordReset.tsx │ ├── updatePassword.tsx │ ├── login.tsx │ └── register.tsx ├── hooks │ ├── useDidChange.tsx │ └── useLastValue.tsx ├── config.ts ├── index.test.tsx ├── Firebase.tsx ├── index.tsx ├── GlobalStyle.tsx ├── controls │ ├── Gravatar.tsx │ ├── AuthLink.tsx │ ├── Form.tsx │ └── Responds.tsx ├── App.tsx ├── media │ └── logo.svg ├── utils │ └── theme.tsx └── serviceWorker.js ├── README.md ├── tsconfig.json ├── package.json └── public └── index.html /.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | .env.local -------------------------------------------------------------------------------- /src/styled/Flex.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' -------------------------------------------------------------------------------- /src/styled/ProductGrid.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' -------------------------------------------------------------------------------- /src/styled/AccountSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' -------------------------------------------------------------------------------- /src/styled/ProductDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/types/Omit.ts: -------------------------------------------------------------------------------- 1 | export type Omit = Pick> -------------------------------------------------------------------------------- /src/types/User.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | email: string, 3 | displayName: string 4 | } -------------------------------------------------------------------------------- /src/routes/account/details.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { route } from 'navi' 3 | 4 | export default route({ 5 | view: ( 6 |
7 |

Account Details

8 |

TODO

9 |
10 | ) 11 | }) -------------------------------------------------------------------------------- /src/hooks/useDidChange.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export default function useDidChange(value: any): boolean { 4 | let ref = useRef() 5 | let hasChanged = ref.current !== value 6 | useEffect(() => { 7 | ref.current = value 8 | }) 9 | return hasChanged 10 | } -------------------------------------------------------------------------------- /src/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import { map, redirect } from 'navi' 2 | import { RoutingContext } from '../types/RoutingContext' 3 | 4 | const logoutRoute = map(async (request, context: RoutingContext) => { 5 | await context.firebase.auth.signOut() 6 | return redirect('/login') 7 | }) 8 | 9 | export default logoutRoute -------------------------------------------------------------------------------- /src/types/RoutingContext.ts: -------------------------------------------------------------------------------- 1 | import Firebase from '../Firebase' 2 | import { User } from './User' 3 | 4 | export interface RoutingContext { 5 | currentUser: 6 | | User 7 | | null // anonymous 8 | | undefined // app has just loaded, and we don't know the auth state, 9 | firebase: Firebase 10 | reloadUser: () => void, 11 | } -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { lazy, mount } from 'navi' 2 | 3 | const routes = mount({ 4 | '/': lazy(() => import('./landing')), 5 | '/login': lazy(() => import('./login')), 6 | '/register': lazy(() => import('./register')), 7 | '/logout': lazy(() => import('./logout')), 8 | 9 | '/account': lazy(() => import('./account/details')), 10 | }) 11 | 12 | export default routes -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | firebase: { 3 | apiKey: process.env.REACT_APP_API_KEY, 4 | authDomain: process.env.REACT_APP_AUTH_DOMAIN, 5 | databaseURL: process.env.REACT_APP_DATABASE_URL, 6 | projectId: process.env.REACT_APP_PROJECT_ID, 7 | storageBucket: process.env.REACT_APP_STORAGE_BUCKET, 8 | messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID, 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/hooks/useLastValue.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export default function useLastValue(value: T): T { 4 | let ref = useRef([] as any[]) 5 | let hasChanged = ref.current[1] !== value 6 | useEffect(() => { 7 | if (hasChanged) { 8 | ref.current[0] = ref.current[1] 9 | ref.current[1] = value 10 | } 11 | }) 12 | return hasChanged ? ref.current[1] : ref.current[0] 13 | } -------------------------------------------------------------------------------- /src/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Router } from 'react-navi'; 4 | import routes from './routes'; 5 | 6 | it('renders without crashing', () => { 7 | const div = document.createElement('div'); 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | div 13 | ); 14 | ReactDOM.unmountComponentAtNode(div); 15 | }); 16 | -------------------------------------------------------------------------------- /src/Firebase.tsx: -------------------------------------------------------------------------------- 1 | import firebase from "firebase/app"; 2 | import "firebase/auth"; 3 | import "firebase/database"; 4 | import "firebase/functions"; 5 | import config from "./config"; 6 | 7 | class Firebase { 8 | auth: firebase.auth.Auth 9 | db: firebase.database.Database 10 | 11 | constructor() { 12 | let app = firebase.initializeApp(config.firebase); 13 | 14 | this.auth = app.auth(); 15 | this.db = app.database(); 16 | } 17 | } 18 | 19 | export default Firebase -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import React from "react" 4 | import ReactDOM from "react-dom" 5 | import App from "./App" 6 | import * as serviceWorker from "./serviceWorker" 7 | 8 | ReactDOM.render( 9 | , 10 | document.getElementById('root') 11 | ) 12 | 13 | // If you want your app to work offline and load faster, you can change 14 | // unregister() to register() below. Note this comes with some pitfalls. 15 | // Learn more about service workers: http://bit.ly/CRA-PWA 16 | serviceWorker.unregister(); -------------------------------------------------------------------------------- /src/routes/landing/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { route } from 'navi' 3 | import { RoutingContext } from '../../types/RoutingContext' 4 | 5 | export default route((request, context: RoutingContext) => { 6 | if (context.currentUser === undefined) { 7 | return { view:
} 8 | } 9 | 10 | return { 11 | view: ( 12 |
13 |

Welcome, {context.currentUser ? context.currentUser.displayName : "Anonymous"}!

14 |

This app is UNDER CONSTRUCTION.

15 |
16 | ) 17 | } 18 | }) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Firebase / TypeScript / styled-components / Authenticated Routes / create-react-app / Navi / React / Oh my... 2 | 3 | You'll need to create a `.env.local` file with your firebase settings: 4 | 5 | ```text 6 | REACT_APP_API_KEY= 7 | REACT_APP_AUTH_DOMAIN= 8 | REACT_APP_DATABASE_URL= 9 | REACT_APP_PROJECT_ID= 10 | REACT_APP_STORAGE_BUCKET= 11 | REACT_APP_MESSAGING_SENDER_ID= 12 | ``` 13 | 14 | Then: 15 | 16 | ```bash 17 | # Install dependencies 18 | npm install 19 | 20 | # Start the dev server 21 | npm start 22 | 23 | # Have fun! 24 | echo "🎉🎉🎉" 25 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "preserve", 20 | "strict": true 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/GlobalStyle.tsx: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components/macro' 2 | import { base, colors } from './utils/theme' 3 | 4 | const GlobalStyle = createGlobalStyle` 5 | @import url('https://fonts.googleapis.com/css?family=Lato:300,400,400i,700,900|Inconsolata:400,700'); 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | html { 12 | font-family: Lato, sans-serif; 13 | font-size: ${base}; 14 | } 15 | 16 | body { 17 | color: ${colors.text}; 18 | margin: 0; 19 | padding: 0; 20 | font-size: 1rem; 21 | line-height: 1.5rem; 22 | } 23 | ` 24 | 25 | export default GlobalStyle -------------------------------------------------------------------------------- /src/styled/Input.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { fontSizes, radius, colors, radiuses, boxShadows, durations } from '../utils/theme'; 3 | 4 | const Input = styled.input` 5 | appearance: none; 6 | display: block; 7 | width: 100%; 8 | font-family: inherit; 9 | margin: 0; 10 | padding: 0.75rem; 11 | font-size: ${fontSizes.bodySmall1}; 12 | border-radius: ${radiuses.large}; 13 | border: 1px solid ${colors.white}; 14 | background-color: ${colors.lighterGray}; 15 | box-shadow: ${boxShadows[2]} inset; 16 | transition: border ${durations.short} linear; 17 | color: ${colors.lightBlack}; 18 | 19 | &::placeholder{ 20 | color: ${colors.borderGray}; 21 | } 22 | &:focus{ 23 | outline: none !important; 24 | border-color: ${colors.red}; 25 | } 26 | 27 | ::-ms-clear { 28 | display: none; 29 | } 30 | ` 31 | 32 | export default Input -------------------------------------------------------------------------------- /src/controls/Gravatar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import md5 from 'blueimp-md5' 3 | import { Omit } from '../types/Omit' 4 | 5 | interface GravatarOptions { 6 | email: string 7 | size: string | number 8 | defaultURL?: string 9 | } 10 | 11 | function getGravatarURL({ 12 | email, 13 | size, 14 | defaultURL = 'identicon', 15 | }: GravatarOptions) { 16 | let hash = md5(email.toLowerCase().trim()) 17 | return `https://www.gravatar.com/avatar/${hash}.jpg?s=${size}&d=${encodeURIComponent( 18 | defaultURL, 19 | )}` 20 | } 21 | 22 | export interface GravatarProps 23 | extends GravatarOptions, 24 | Omit, 'src'> {} 25 | 26 | export default function Gravatar({ 27 | email, 28 | size, 29 | defaultURL, 30 | ...imgProps 31 | }: GravatarProps) { 32 | return 33 | } 34 | -------------------------------------------------------------------------------- /src/styled/AuthFormLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { css } from 'styled-components/macro' 3 | import { colors, boxShadows } from '../utils/theme' 4 | 5 | interface AuthFormLayoutProps { 6 | children: React.ReactNode 7 | heading: string 8 | } 9 | 10 | const AuthFormLayout = (props: AuthFormLayoutProps) => 11 | <> 12 |
21 |
30 |

{props.heading}

31 | {props.children} 32 |
33 | 34 | 35 | export default AuthFormLayout -------------------------------------------------------------------------------- /src/controls/AuthLink.tsx: -------------------------------------------------------------------------------- 1 | import { createURLDescriptor } from 'navi' 2 | import React from 'react' 3 | import { Link, useCurrentRoute } from 'react-navi' 4 | import { Omit } from '../types/Omit' 5 | 6 | export interface AuthLinkProps extends Omit { 7 | href?: string 8 | } 9 | 10 | export type AuthLinkRendererProps = Link.RendererProps 11 | 12 | /** 13 | * A link that passes through any auth-related URL parameters. 14 | * 15 | * Defaults to pointing to the "login" screen unless the user is currently 16 | * viewing the "login" screen, in which case it points to the "register" 17 | * screen. 18 | */ 19 | function AuthLink(props: AuthLinkProps) { 20 | let currentRoute = useCurrentRoute() 21 | let url = createURLDescriptor(props.href || '/login') 22 | 23 | Object.assign(url.query, currentRoute.url.query) 24 | 25 | return ( 26 | 27 | ) 28 | } 29 | 30 | export default AuthLink -------------------------------------------------------------------------------- /src/styled/AppLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components/macro' 3 | import AppHeader from './AppHeader' 4 | import LoadingIndicator from './LoadingIndicator' 5 | import { NotFoundBoundary } from 'react-navi'; 6 | import { User } from '../types/User'; 7 | 8 | const Main = styled.main` 9 | margin: 3rem 1.5rem; 10 | ` 11 | 12 | interface LayoutProps { 13 | children: React.ReactNode 14 | isLoading?: boolean 15 | user?: User | null 16 | } 17 | 18 | function Layout({ children, isLoading, user }: LayoutProps) { 19 | return ( 20 | <> 21 | 22 | 23 |
24 | }> 25 | {children} 26 | 27 |
28 | 29 | ) 30 | } 31 | 32 | function NotFound() { 33 | return ( 34 |

This page is not available right now, but your call is important to us, so please leave a message.

35 | ) 36 | } 37 | 38 | export default Layout -------------------------------------------------------------------------------- /src/styled/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components/macro' 2 | 3 | const animation = keyframes` 4 | 0% { 5 | background-position: 0 0; 6 | } 7 | 100% { 8 | background-position: -35px -35px; 9 | } 10 | ` 11 | 12 | const LoadingIndicator = styled.div<{ active?: boolean }>` 13 | position: fixed; 14 | height: 4px; 15 | top: 0; 16 | left: 0; 17 | right: 0; 18 | background-color: #1cde78; 19 | background-image: linear-gradient(-45deg, rgba(255,255,255,0.25) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.25) 50%, rgba(255,255,255,0.25) 75%, transparent 75%, transparent); 20 | background-size: 35px 35px; 21 | z-index: 1000; 22 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2) inset; 23 | transform: translateY(-4px); 24 | transition: transform ease-in 300ms, opacity ease-in 300ms; 25 | transition-delay: 0; 26 | animation: ${animation} 2s cubic-bezier(.4,.45,.6,.55) infinite; 27 | opacity: 0; 28 | 29 | ${({ active }) => !active ? '' : ` 30 | transition-delay: 100ms; 31 | transform: translateY(0); 32 | opacity: 1; 33 | `} 34 | ` 35 | 36 | export default LoadingIndicator 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/blueimp-md5": "^2.7.0", 7 | "@types/lodash-es": "^4.17.3", 8 | "@types/node": "^11.10.5", 9 | "@types/ramda": "^0.25.51", 10 | "@types/react": "^16.8.7", 11 | "@types/react-dom": "^16.8.2", 12 | "@types/styled-components": "^4.1.11", 13 | "blueimp-md5": "^2.10.0", 14 | "final-form": "4.11.1", 15 | "firebase": "^5.8.4", 16 | "navi": "0.12.0-alpha.0", 17 | "navi-scripts": "0.12.0-alpha.0", 18 | "polished": "^3.0.3", 19 | "ramda": "^0.26.1", 20 | "react": "^16.8.4", 21 | "react-dom": "^16.8.4", 22 | "react-final-form": "4.0.2", 23 | "react-navi": "0.12.0-alpha.1", 24 | "react-scripts": "2.1.8", 25 | "styled-components": "^4.1.3", 26 | "typescript": "^3.2.1" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject" 33 | }, 34 | "eslintConfig": { 35 | "extends": "react-app" 36 | }, 37 | "browserslist": [ 38 | ">0.2%", 39 | "not dead", 40 | "not ie <= 11", 41 | "not op_mini all" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /src/routes/requestPasswordReset.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { map, redirect, route } from 'navi' 3 | import Form from '../styled/Form' 4 | import { RoutingContext } from '../types/RoutingContext' 5 | 6 | export default map(async (request, context: RoutingContext) => { 7 | if (context.currentUser) { 8 | return redirect('/') 9 | } 10 | 11 | if (request.method === 'post') { 12 | let { email, password } = request.body 13 | try { 14 | await context.firebase.auth.signInWithEmailAndPassword(email, password); 15 | return redirect('/') 16 | } 17 | catch (error) { 18 | return route({ 19 | error, 20 | view: 21 | }) 22 | } 23 | } 24 | 25 | return route({ 26 | view: 27 | }) 28 | }) 29 | 30 | function Login() { 31 | return ( 32 |
33 |

Need a new Password?

34 | 35 | 39 | value === '' ? 'Please enter your email.' : undefined 40 | } 41 | /> 42 | 43 | Request a new Password 44 | 45 | 46 | ) 47 | } -------------------------------------------------------------------------------- /src/styled/Field.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { css } from 'styled-components/macro' 3 | import { fontSizes, colors } from '../utils/theme' 4 | 5 | export interface FieldProps { 6 | children: React.ReactNode 7 | description?: string 8 | label: string 9 | error?: string 10 | } 11 | 12 | const Field = (props: FieldProps) => ( 13 | 46 | ) 47 | 48 | export default Field 49 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 16 | React App 17 | 18 | 19 | 22 |
23 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/routes/updatePassword.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { map, redirect, route } from 'navi' 3 | import Form from '../styled/Form' 4 | import { RoutingContext } from '../types/RoutingContext' 5 | 6 | export default map(async (request, context: RoutingContext) => { 7 | if (request.method === 'post') { 8 | let { password } = request.body 9 | try { 10 | await context.firebase.auth.currentUser!.updatePassword(password); 11 | return redirect('/') 12 | } 13 | catch (error) { 14 | return route({ 15 | error, 16 | view: 17 | }) 18 | } 19 | } 20 | 21 | return route({ 22 | view: 23 | }) 24 | }) 25 | 26 | function UpdatePassword() { 27 | return ( 28 |
29 |

Need a new Password?

30 | 31 | 36 | value === '' ? 'Please enter a password.' : undefined 37 | } 38 | /> 39 | 44 | value !== allValues.password ? 'This should match your password.' : undefined 45 | } 46 | /> 47 | 48 | Request a new Password 49 | 50 | 51 | ) 52 | } 53 | 54 | -------------------------------------------------------------------------------- /src/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { map, redirect, route } from 'navi' 3 | import Form from '../styled/Form' 4 | import { RoutingContext } from '../types/RoutingContext' 5 | import AuthFormLayout from '../styled/AuthFormLayout' 6 | import { colors } from '../utils/theme' 7 | 8 | export default map(async (request, context: RoutingContext) => { 9 | if (context.currentUser) { 10 | return redirect('/') 11 | } 12 | 13 | if (request.method === 'post') { 14 | let { email, password } = request.body 15 | try { 16 | await context.firebase.auth.signInWithEmailAndPassword(email, password); 17 | return redirect('/') 18 | } 19 | catch (error) { 20 | return route({ 21 | error, 22 | view: 23 | }) 24 | } 25 | } 26 | 27 | return route({ 28 | view: 29 | }) 30 | }) 31 | 32 | function Login() { 33 | return ( 34 | 35 |
36 | 37 | 41 | value === '' ? 'Please enter your email.' : undefined 42 | } 43 | /> 44 | 49 | value === '' ? 'Please enter your password.' : undefined 50 | } 51 | /> 52 | 53 | Login 54 | 55 | 56 |
57 | ) 58 | } -------------------------------------------------------------------------------- /src/styled/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { css, keyframes } from 'styled-components/macro' 3 | import { colors, fontSizes } from '../utils/theme' 4 | import { lighten } from 'polished'; 5 | 6 | export interface ButtonProps extends React.ButtonHTMLAttributes { 7 | active?: boolean 8 | busy?: boolean 9 | 10 | bgcolor?: string 11 | color?: string 12 | } 13 | 14 | const boxShadow = `0 4px 16px -4px rgba(146, 146, 186, 0.5)`; 15 | 16 | const animation = keyframes` 17 | 0% { 18 | box-shadow: 0px 0px 16px 8px rgba(255, 255, 255,0.32) inset, ${boxShadow}; 19 | } 20 | 50% { 21 | box-shadow: 0px 0px 16px 8px rgba(255, 255, 255,0.12) inset, ${boxShadow}; 22 | } 23 | 100% { 24 | box-shadow: 0px 0px 16px 8px rgba(255, 255, 255,0) inset, ${boxShadow}; 25 | } 26 | ` 27 | 28 | const Button = ({ bgcolor, color, busy, children, ...buttonProps }: ButtonProps) => { 29 | if (!bgcolor) { 30 | color = colors.darkerGray 31 | bgcolor = colors.lightGray 32 | } 33 | else if (!color) { 34 | color = colors.white 35 | } 36 | 37 | return ( 38 | 64 | ) 65 | } 66 | 67 | export default Button 68 | -------------------------------------------------------------------------------- /src/routes/register.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { map, redirect, route } from 'navi' 3 | import Form from '../styled/Form' 4 | import { RoutingContext } from '../types/RoutingContext' 5 | import AuthFormLayout from '../styled/AuthFormLayout' 6 | import { colors } from '../utils/theme'; 7 | 8 | export default map(async (request, context: RoutingContext) => { 9 | if (context.currentUser) { 10 | return redirect('/') 11 | } 12 | 13 | if (request.method === 'post') { 14 | let { email, name, password } = request.body 15 | try { 16 | let auth = await context.firebase.auth.createUserWithEmailAndPassword(email, password); 17 | await auth.user!.updateProfile({ 18 | displayName: name, 19 | }) 20 | 21 | // Updating the profile does not cause Firebase to emit any events, so 22 | // we'll need to manually reload the user. 23 | await context.reloadUser() 24 | 25 | return redirect('/?welcome') 26 | } 27 | catch (error) { 28 | console.log(error, 'error') 29 | return route({ 30 | error, 31 | view: 32 | }) 33 | } 34 | } 35 | 36 | return route({ 37 | view: 38 | }) 39 | }) 40 | 41 | function Register() { 42 | return ( 43 | 44 |
45 | 46 | 50 | value === '' ? 'Please enter your name.' : undefined 51 | } 52 | /> 53 | 57 | value === '' ? 'Please enter your email.' : undefined 58 | } 59 | /> 60 | 65 | value === '' ? 'Please enter your password.' : undefined 66 | } 67 | /> 68 | 69 | Sign Up 70 | 71 | 72 |
73 | ) 74 | } -------------------------------------------------------------------------------- /src/styled/Form.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components/macro' 3 | import { FieldProps as FormControlFieldProps } from 'react-final-form' 4 | import FormControl, { 5 | FormProps as FormControlProps, 6 | FormSubmitButtonProps as FormControlSubmitButtonProps 7 | } from '../controls/Form' 8 | import Button, { ButtonProps } from './Button' 9 | import Field, { FieldProps } from './Field' 10 | import Input from './Input' 11 | import { Omit } from '../types/Omit' 12 | 13 | 14 | const StyledForm = styled(FormControl)` 15 | 16 | ` 17 | 18 | 19 | interface StyledFormErrorsProps { 20 | active: boolean 21 | } 22 | 23 | const StyledFormErrors = styled.div` 24 | 25 | ` 26 | 27 | 28 | function Form(props: FormControlProps) { 29 | return ( 30 | 31 | ) 32 | } 33 | 34 | function FormErrors({ defaultMessage = '' }) { 35 | return ( 36 | 39 | 40 | {error} 41 | 42 | } 43 | /> 44 | ) 45 | } 46 | 47 | interface FormFieldProps extends FormControlFieldProps, Omit { 48 | type?: string 49 | } 50 | 51 | function FormField({ label, type, ...props }: FormFieldProps) { 52 | return ( 53 | 54 | 55 | 56 | 57 | } /> 58 | ) 59 | } 60 | 61 | 62 | interface FormSubmitButtonProps extends Omit, ButtonProps {} 63 | 64 | function FormSubmitButton({ children='Save', ...submitButtonProps }: FormSubmitButtonProps) { 65 | return ( 66 | 67 | 70 | } /> 71 | ) 72 | } 73 | 74 | 75 | export default Object.assign(Form, { 76 | Errors: FormErrors, 77 | Field: FormField, 78 | SubmitButton: FormSubmitButton, 79 | }) 80 | 81 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, useState, useEffect, useMemo } from 'react' 2 | import { withView, compose } from 'navi' 3 | import { Router, View } from 'react-navi' 4 | import firebase from 'firebase/app' 5 | import Firebase from './Firebase' 6 | import GlobalStyle from './GlobalStyle' 7 | import routes from './routes' 8 | import Layout from './styled/AppLayout' 9 | import { RoutingContext } from './types/RoutingContext' 10 | 11 | const routesWithLayout = compose( 12 | // Instead of rendering the latest `currentUser`, we render its value at the 13 | // time that the route was generated. Given that changing the user rebuilds 14 | // the route, it will always be up-to-date -- and doing it this way helps 15 | // avoid flashes of content when navigating between auth actions. 16 | withView((request, context: RoutingContext) => 17 | 18 | 19 | 20 | 21 | 22 | ), 23 | routes 24 | ) 25 | 26 | function App() { 27 | let [firebase] = useState(() => new Firebase()) 28 | let [currentUser, setCurrentUser] = useState(undefined) 29 | useEffect(() => firebase.auth.onAuthStateChanged(user => { 30 | // Skip the initial change after registration where displayName is null. 31 | if (!user || user.displayName) { 32 | setCurrentUser(user) 33 | } 34 | }), []) 35 | let { displayName, email } = currentUser || { displayName: currentUser, email: currentUser } 36 | 37 | // We need to pass in a new object when the values update, or Navi won't 38 | // notice the change and re-render. 39 | let user = useMemo(() => ( 40 | (displayName && email) ? { displayName, email } : (currentUser as any) 41 | ), [displayName, email]) 42 | 43 | console.log(user) 44 | 45 | let context: RoutingContext = { 46 | currentUser: user, 47 | firebase, 48 | 49 | // Firebase doesn't emit events when the user's profile changes, so this 50 | // can be used to manually refresh the user. 51 | reloadUser: () => firebase.auth.currentUser!.reload().then(() => { 52 | setCurrentUser(firebase.auth.currentUser) 53 | }) 54 | } 55 | 56 | return ( 57 | 58 | 59 | 60 | 61 | ) 62 | } 63 | 64 | export default App -------------------------------------------------------------------------------- /src/media/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 12 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/utils/theme.tsx: -------------------------------------------------------------------------------- 1 | import { css } from 'styled-components/macro' 2 | import { 3 | desaturate, 4 | lighten, 5 | } from 'polished' 6 | 7 | const createMediaQuery = (minWidth: number) => 8 | (...args: any[]) => 9 | css` 10 | @media screen and (min-width: ${minWidth}rem) { 11 | ${css.apply(null, args as any)} 12 | } 13 | ` 14 | 15 | export const base = '16px' 16 | 17 | export const breakpoints = { 18 | mediumPlus: 720, 19 | largePlus: 1104, 20 | } 21 | export const media = { 22 | mediumPlus: createMediaQuery(breakpoints.mediumPlus), 23 | largePlus: createMediaQuery(breakpoints.largePlus), 24 | } 25 | 26 | export const fontFamily = `Lato,'Helvetica Neue',Helvetica,Arial,sans-serif` 27 | 28 | const rem = (x: number) => x+'rem' 29 | 30 | export const fontSizes = { 31 | bodySmall2: rem(3/4), 32 | bodySmall1: rem(7/8), 33 | body: rem(1), 34 | bodyLarge1: rem(5/4), 35 | 36 | displaySmall1: rem(3/2), 37 | display: rem(2), 38 | displayLarge1: rem(5/2), 39 | displayLarge2: rem(7/2), 40 | displayLarge3: rem(9/2) 41 | } 42 | 43 | export const fontWeights = { 44 | standard: 500, 45 | bold: 700, 46 | } 47 | export const lineHeights = { 48 | body: 1.5, 49 | display: 1.25, 50 | } 51 | 52 | const baseBodyTextStyle = { 53 | fontWeight: fontWeights.standard, 54 | lineHeight: lineHeights.body 55 | } 56 | const baseDisplayTextStyle = { 57 | fontWeight: fontWeights.bold, 58 | lineHeight: lineHeights.display 59 | } 60 | 61 | export const textStyles = { 62 | bodySmall2: { 63 | fontSize: fontSizes.bodySmall2, 64 | ...baseBodyTextStyle 65 | }, 66 | bodySmall1: { 67 | fontSize: fontSizes.bodySmall1, 68 | ...baseBodyTextStyle 69 | }, 70 | body: { 71 | fontSize: fontSizes.body, 72 | ...baseBodyTextStyle 73 | }, 74 | bodyLarge1: { 75 | fontSize: fontSizes.bodyLarge1, 76 | ...baseBodyTextStyle 77 | }, 78 | 79 | displaySmall1: { 80 | fontSize: fontSizes.displaySmall1, 81 | ...baseDisplayTextStyle 82 | }, 83 | display: { 84 | fontSize: fontSizes.display, 85 | ...baseDisplayTextStyle 86 | }, 87 | displayLarge1: { 88 | fontSize: fontSizes.displayLarge1, 89 | ...baseDisplayTextStyle 90 | }, 91 | displayLarge2: { 92 | fontSize: fontSizes.displayLarge2, 93 | ...baseDisplayTextStyle 94 | }, 95 | } 96 | 97 | const white = '#fff' 98 | const black = '#0f0035' 99 | const lightBlack = '#342656' 100 | const text = '#0f0035' 101 | const borderGray = '#dae1f2' 102 | const primary = 'aquamarine' 103 | const lightPrimary = desaturate(0.15, lighten(0.15, primary)) 104 | const red = '#dd3c6f' 105 | const lightRed = '#f54391' 106 | const green = '#12c8ba' 107 | const lightGreen = desaturate(0.15, lighten(0.2, green)) 108 | const lighterGray = '#f0f4fc' 109 | const lightGray = '#dae1f2' 110 | const gray = '#a9a9c9' 111 | const darkGray = '#8a8ab5' 112 | const darkerGray = '#7272a3' 113 | 114 | export const colors = { 115 | white, 116 | black, 117 | lightBlack, 118 | text, 119 | borderGray, 120 | primary, 121 | lightPrimary, 122 | red, 123 | lightRed, 124 | green, 125 | lightGreen, 126 | lighterGray, 127 | lightGray, 128 | gray, 129 | darkGray, 130 | darkerGray, 131 | } 132 | 133 | export const radius = '3px' 134 | export const radiuses = { 135 | standard: '2px', 136 | large: '6px', 137 | } 138 | 139 | export const boxShadows = [ 140 | `0 0 2px 0 rgba(0,0,0,.04),0 1px 4px 0 rgba(0,0,0,.08)`, 141 | `0 0 2px 0 rgba(0,0,0,.04),0 2px 8px 0 rgba(0,0,0,.08)`, 142 | `0 0 2px 0 rgba(0,0,0,.04),0 4px 16px 0 rgba(0,0,0,.08)`, 143 | `0 0 2px 0 rgba(0,0,0,.04),0 8px 32px 0 rgba(0,0,0,.08)` 144 | ] 145 | 146 | export const timingFunctions = { 147 | easeInOut: `cubic-bezier(0.770, 0.000, 0.175, 1.000)`, 148 | easeOut: `cubic-bezier(0.165, 0.840, 0.440, 1.000)`, 149 | easeIn: `cubic-bezier(0.895, 0.030, 0.685, 0.220)` 150 | } 151 | 152 | export const durations = { 153 | short: `120ms`, 154 | medium: `280ms`, 155 | long: `600ms`, 156 | } -------------------------------------------------------------------------------- /src/styled/AppHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link, useCurrentRoute } from 'react-navi' 3 | import { css } from 'styled-components/macro' 4 | import AuthLink, { AuthLinkProps } from '../controls/AuthLink' 5 | import Gravatar from '../controls/Gravatar' 6 | import Responds from '../controls/Responds' 7 | import { colors, durations, timingFunctions, media, fontSizes, boxShadows, radius } from '../utils/theme' 8 | import { User } from '../types/User' 9 | 10 | interface AppHeaderProps { 11 | user?: User | null 12 | } 13 | 14 | const height = 3 15 | const verticalPadding = 1/2 16 | const contentHeight = height - verticalPadding*2 17 | 18 | const NavLink = (props: Link.Props) => 19 | 34 | 35 | const StyledAuthLink = (props: AuthLinkProps) => { 36 | let { url } = useCurrentRoute() 37 | let isViewingLogin = url.pathname.startsWith('/login') 38 | let href = isViewingLogin ? '/register' : '/login' 39 | let color = isViewingLogin ? colors.green : colors.lightGray 40 | return ( 41 | 56 | {isViewingLogin ? 'Sign Up' : 'Login'} 57 | 58 | ) 59 | } 60 | 61 | const Identity = React.forwardRef(({ user, tabIndex }: { user: User, tabIndex?: number }, ref: React.Ref) => { 62 | let { displayName, email } = user 63 | 64 | return ( 65 |
74 | {displayName} 75 | 85 |
86 | ) 87 | }) 88 | 89 | const IdentityMenu = React.forwardRef(({ visible }: { visible: boolean }, ref: React.Ref) => { 90 | return ( 91 |
a { 104 | color: ${colors.lightGray}; 105 | display: block; 106 | text-decoration: none; 107 | padding: 0 1rem; 108 | } 109 | `}> 110 | Account Details 111 | Logout 112 |
113 | ) 114 | }) 115 | 116 | function UserControls({ user }: { user?: User | null }) { 117 | if (user === undefined) { 118 | // Wait until we know what the user's state is before showing anything 119 | return null 120 | } 121 | 122 | return ( 123 | 124 |
127 | {user ? ( 128 | 129 | 130 | 131 | ) : ( 132 | 133 | )} 134 | 135 | {({ active }) => } 136 | 137 |
138 |
139 | ) 140 | } 141 | 142 | 143 | 144 | function AppHeader({ user }: AppHeaderProps) { 145 | return ( 146 |
160 | 179 | 180 |
181 | ) 182 | } 183 | 184 | export default AppHeader 185 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read http://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit http://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/controls/Form.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useCallback } from 'react' 2 | import { useAction } from 'react-navi' 3 | import { FORM_ERROR, FormApi, FormState } from 'final-form' 4 | import { 5 | Form as FinalForm, 6 | FormProps as FinalFormProps, 7 | FormSpy, 8 | Field 9 | } from 'react-final-form' 10 | import { Omit } from '../types/Omit' 11 | 12 | 13 | export type FormErrors = Partial> 14 | 15 | 16 | export interface FormProps extends Omit { 17 | action?: string 18 | children: React.ReactNode 19 | className?: string 20 | component?: string | React.ElementType 21 | initialValues?: Schema 22 | method?: string 23 | style?: React.CSSProperties 24 | submitError?: string | FormErrors 25 | onSubmit?: ( 26 | value: Schema, 27 | form: FormApi, 28 | ) => undefined | FormErrors | Promise>, 29 | validate?: (value: Schema) => undefined | FormErrors | Promise> 30 | } 31 | 32 | export function Form({ 33 | action, 34 | children, 35 | className, 36 | component: Component = 'form', 37 | method, 38 | style, 39 | submitError, 40 | onSubmit, 41 | validate, 42 | ...props 43 | }: FormProps) { 44 | // Keep props in a ref, so that latest errors are accessible from the 45 | // submit and validate callbacks. 46 | let propsRef = useRef({ 47 | submitError, 48 | onSubmit, 49 | validate, 50 | }) 51 | propsRef.current = { 52 | submitError, 53 | onSubmit, 54 | validate, 55 | } 56 | 57 | let submit = useAction(method || 'post', action) 58 | 59 | let onSubmitFinalForm = useCallback(async (value, form) => { 60 | let props = propsRef.current 61 | 62 | if (props.onSubmit) { 63 | let errors = await props.onSubmit(value, form) 64 | if (errors) { 65 | return errors 66 | } 67 | } 68 | 69 | if (method) { 70 | let route = await submit(value) 71 | if (route.type === 'error') { 72 | let error = route.error 73 | if (error instanceof Error) { 74 | error = error.message 75 | } 76 | return typeof error === 'string' ? { [FORM_ERROR]: error } : error 77 | } 78 | } 79 | }, [method, submit]) 80 | 81 | let validateFinalForm = useCallback(async (values: Schema) => { 82 | let props = propsRef.current 83 | let validateErrors = await (props.validate && props.validate(values)) 84 | let submitErrors = props.submitError 85 | return combineErrors(validateErrors, submitErrors) 86 | }, []) 87 | 88 | return ( 89 | 94 | {({ handleSubmit }) => 95 | React.createElement(Component, { 96 | action, 97 | children, 98 | className, 99 | method, 100 | onSubmit: handleSubmit, 101 | style, 102 | }) 103 | } 104 | 105 | ) 106 | } 107 | 108 | 109 | export interface FormErrorsProps { 110 | /** 111 | * When there has been an error submitting the form, the `message` property 112 | * will have a string value. 113 | */ 114 | render: (message?: string) => React.ReactElement, 115 | 116 | /** 117 | * Allows you to set the default error message that is shown when the form 118 | * couldn't be submitted, and there's no submit error message. 119 | */ 120 | defaultMessage?: string 121 | } 122 | 123 | export function FormErrors(props: FormErrorsProps) { 124 | let { 125 | defaultMessage = "Your data couldn't be saved.", 126 | render, 127 | } = props 128 | 129 | return ( 130 | { 131 | let error = 132 | state.submitFailed ? (state.submitError || defaultMessage) : undefined 133 | 134 | return render(error) 135 | }} /> 136 | ) 137 | } 138 | 139 | 140 | function defaultFormSubmitButtonRender(props: FormSubmitRenderProps) { 141 | return