├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── app.json ├── gatsby-browser.js ├── gatsby-config.js ├── gatsby-node.js ├── gatsby-ssr.js ├── netlify └── functions │ ├── authenticate │ └── authenticate.js │ └── refreshToken │ └── refreshToken.js ├── package-lock.json ├── package.json ├── src ├── agents │ ├── backendAgents.js │ └── stravaAgents.js ├── components │ ├── Header │ │ ├── HeaderMenu.jsx │ │ ├── HeaderProfileButton.jsx │ │ └── header.jsx │ ├── Loader │ │ └── Loader.jsx │ ├── Map │ │ ├── Map.jsx │ │ └── Polyline.jsx │ ├── Menu │ │ ├── Blocks │ │ │ ├── ActivityTypes.jsx │ │ │ ├── Dates.jsx │ │ │ ├── Footer.jsx │ │ │ ├── MapOptions.jsx │ │ │ ├── NoActivities.jsx │ │ │ ├── RoutesOptions.jsx │ │ │ ├── Seasons.jsx │ │ │ └── Stats.jsx │ │ ├── Collapsable.jsx │ │ ├── Menu.jsx │ │ ├── MenuButton.jsx │ │ └── MenuWrapper.jsx │ ├── layout.jsx │ └── seo.jsx ├── contexts │ ├── ActivityContext.jsx │ ├── AthleteContext.jsx │ ├── MenuContext.jsx │ └── RouteContext.jsx ├── domain │ └── ActivityType.jsx ├── helpers │ ├── activityHelpers.js │ ├── dateHelpers.js │ ├── fetchHelpers.js │ ├── hooks.js │ ├── localStorageHelpers.js │ └── mathHelpers.js ├── images │ ├── bike.jpeg │ ├── branding │ │ ├── dark │ │ │ ├── dark_logo_transparent_background.png │ │ │ ├── dark_logo_white_background.jpg │ │ │ ├── logo_transparent_background.png │ │ │ ├── logo_white_background.jpg │ │ │ ├── white_logo_color_background.jpg │ │ │ ├── white_logo_dark_background.jpg │ │ │ └── white_logo_transparent_background.png │ │ ├── light │ │ │ ├── dark_logo_transparent_background.png │ │ │ ├── dark_logo_white_background.jpg │ │ │ ├── logo_transparent_background original.png │ │ │ ├── logo_transparent_background.png │ │ │ ├── logo_white_background.jpg │ │ │ ├── white_logo_color_background.jpg │ │ │ ├── white_logo_dark_background.jpg │ │ │ └── white_logo_transparent_background.png │ │ └── logo-square.png │ ├── cta-illustration.svg │ ├── demo.png │ ├── demo1.png │ ├── feature-icon-01.svg │ ├── feature-icon-02.svg │ ├── feature-icon-03.svg │ ├── feature-icon-04.svg │ ├── feature-icon-05.svg │ ├── feature-icon-06.svg │ ├── github.png │ ├── hero-back-illustration.svg │ ├── hero-top-illustration.svg │ ├── logo.svg │ ├── strava-logo.png │ ├── stravabutton.png │ └── stravapower.png ├── pages │ ├── 404.jsx │ ├── app.jsx │ ├── callback.jsx │ ├── index.jsx │ └── privacy.jsx ├── styles │ ├── _font-types.scss │ ├── _fonts.scss │ ├── blocks │ │ ├── _app.scss │ │ ├── _button.scss │ │ ├── _collapsable.scss │ │ ├── _datepicker.scss │ │ ├── _header.scss │ │ ├── _label.scss │ │ ├── _landing.scss │ │ ├── _layout.scss │ │ ├── _loader.scss │ │ ├── _map.scss │ │ ├── _menu.scss │ │ ├── _notfound.scss │ │ ├── _video-container.scss │ │ └── index.scss │ ├── fonts │ │ ├── Gidole-Regular.otf │ │ ├── Montserrat-Black.ttf │ │ ├── Montserrat-Bold.ttf │ │ ├── Montserrat-Regular.ttf │ │ └── Montserrat-SemiBold.ttf │ ├── index.scss │ ├── landing │ │ ├── _normalize.scss │ │ ├── abstracts │ │ │ ├── _functions.scss │ │ │ ├── _include-media.scss │ │ │ ├── _mixins.scss │ │ │ └── _variables.scss │ │ ├── base │ │ │ ├── _base.scss │ │ │ ├── _helpers.scss │ │ │ └── _typography.scss │ │ ├── components │ │ │ ├── _buttons.scss │ │ │ └── _forms.scss │ │ ├── layout │ │ │ ├── _cta.scss │ │ │ ├── _features.scss │ │ │ ├── _footer.scss │ │ │ ├── _header.scss │ │ │ ├── _hero.scss │ │ │ ├── _main.scss │ │ │ └── _pricing.scss │ │ └── style.scss │ └── tools │ │ ├── _colors.scss │ │ ├── _functions.scss │ │ ├── _media-queries.scss │ │ └── _reset.scss └── themes │ └── DatesTheme.js ├── static.json └── static └── images └── logo.png /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals: { 3 | __PATH_PREFIX__: true, 4 | document: true, 5 | window: true, 6 | localStorage: true, 7 | screen: true, 8 | }, 9 | extends: ['airbnb', 'prettier'], 10 | plugins: ['prettier'], 11 | overrides: [ 12 | { 13 | files: ['*.js'], 14 | rules: { 15 | 'prettier/prettier': ['error'], 16 | }, 17 | }, 18 | ], 19 | parser: 'babel-eslint', 20 | rules: { 21 | 'prettier/prettier': ['error'], 22 | 'global-require': 0, 23 | 'no-loop-func': 0, 24 | 'import/extensions': 0, 25 | 'import/no-named-as-default': 0, 26 | 'import/no-named-as-default-member': 0, 27 | 'import/prefer-default-export': 0, 28 | 'no-multi-assign': 0, 29 | 'no-restricted-globals': 0, 30 | 'react-hooks/exhaustive-deps': 0, 31 | 'react/jsx-one-expression-per-line': 0, 32 | 'react/no-unescaped-entities': 0, 33 | 'react/prop-types': 0, 34 | 'react/state-in-constructor': 0, 35 | 'no-empty': 0, 36 | 'react/destructuring-assignment': 0, 37 | 'react/no-access-state-in-setstate': 0, 38 | 'no-await-in-loop': 0, 39 | 'no-constant-condition': 0, 40 | 'react/jsx-filename-extension': 0, 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # dotenv environment variable files 55 | .env* 56 | 57 | # gatsby files 58 | .cache/ 59 | public 60 | 61 | # Mac files 62 | .DS_Store 63 | 64 | # Yarn 65 | yarn-error.log 66 | .pnp/ 67 | .pnp.js 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # Local Netlify folder 72 | .netlify 73 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | package.json 3 | package-lock.json 4 | public 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 180 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 gatsbyjs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bifurkate - https://www.bifurkate.com/ 2 | 3 | ## Find some inspiration! 4 | 5 | Like the saying goes, you need to know where you've been to know where your going. If you find yourself tired of riding the same old routes or running the same old path, don't worry, you're not alone. 6 | 7 | Bifurkate is a powerful visualization tool to analyze your past rides, runs, walks and hikes. Why? Because I needed a reasons to mess around with the Strava API and let's face it, everyone loves data! 8 | 9 | ## Commands to run the project 10 | 11 | - npm install 12 | - gatsby develop 13 | 14 | ## Environments 15 | 16 | #### Production 17 | 18 | https://www.bifurkate.com/ 19 | 20 | ✌️✌️✌️✌✌ 21 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildpacks": [ 3 | { "url": "heroku/nodejs" }, 4 | { "url": "https://github.com/heroku/heroku-buildpack-static" } 5 | ] 6 | } -------------------------------------------------------------------------------- /gatsby-browser.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './src/styles/index.scss'; 3 | import 'simplebar/dist/simplebar.css'; 4 | import 'react-input-range/lib/css/index.css'; 5 | import 'react-dates/initialize'; 6 | import 'react-dates/lib/css/_datepicker.css'; 7 | import { AthleteProvider } from './src/contexts/AthleteContext'; 8 | import { RouteProvider } from './src/contexts/RouteContext'; 9 | import { MenuProvider } from './src/contexts/MenuContext'; 10 | import { ActivityProvider } from './src/contexts/ActivityContext'; 11 | 12 | export const wrapRootElement = ({ element }) => ( 13 | 14 | 15 | 16 | {element} 17 | 18 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /gatsby-config.js: -------------------------------------------------------------------------------- 1 | require(`dotenv`).config(); 2 | 3 | module.exports = { 4 | siteMetadata: { 5 | title: `BIFURKATE`, 6 | description: `Bifurkate is a powerful and lightweight Strava powered app to visualize your cycling and running history. It displays all your activities on a map, offers a personal heat map and let you filter your activities. `, 7 | author: `@fredbegin11`, 8 | image: '/images/logo.png', 9 | siteUrl: process.env.GATSBY_CURRENT_DOMAIN || `https://www.bifurkate.com`, 10 | keywords: [ 11 | 'strava login', 12 | 'strava heat map', 13 | 'strava heatmap', 14 | 'strava heatmaps', 15 | 'strava activity viewer', 16 | 'strava analysis', 17 | 'strava mapper', 18 | 'strava personal heatmap', 19 | 'strava viewer', 20 | 'activity viewer', 21 | 'bike map', 22 | 'cycling map', 23 | 'run map', 24 | ], 25 | }, 26 | plugins: [ 27 | 'gatsby-plugin-eslint', 28 | `gatsby-plugin-sitemap`, 29 | `gatsby-plugin-react-helmet`, 30 | { 31 | resolve: `gatsby-source-filesystem`, 32 | options: { 33 | name: `images`, 34 | path: `${__dirname}/src/images`, 35 | }, 36 | }, 37 | 'gatsby-plugin-force-trailing-slashes', 38 | `gatsby-plugin-sass`, 39 | `gatsby-transformer-sharp`, 40 | `gatsby-plugin-sharp`, 41 | { 42 | resolve: `gatsby-plugin-manifest`, 43 | options: { 44 | name: `gatsby-starter-default`, 45 | short_name: `starter`, 46 | start_url: `/`, 47 | background_color: `#ff4b00`, 48 | theme_color: `#ff4b00`, 49 | display: `minimal-ui`, 50 | icon: `src/images/branding/logo-square.png`, 51 | }, 52 | }, 53 | { 54 | resolve: `gatsby-plugin-create-client-paths`, 55 | options: { prefixes: [`/activity/*`, `/segment/*`] }, 56 | }, 57 | ], 58 | }; 59 | 60 | require('dotenv').config({ 61 | path: `.env`, 62 | }); 63 | -------------------------------------------------------------------------------- /gatsby-node.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/gatsby-node.js -------------------------------------------------------------------------------- /gatsby-ssr.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implement Gatsby's SSR (Server Side Rendering) APIs in this file. 3 | * 4 | * See: https://www.gatsbyjs.org/docs/ssr-apis/ 5 | */ 6 | 7 | // You can delete this file if you're not using it 8 | -------------------------------------------------------------------------------- /netlify/functions/authenticate/authenticate.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const handler = async event => { 4 | const { code } = JSON.parse(event.body); 5 | 6 | const result = await axios.post( 7 | `https://www.strava.com/oauth/token?client_id=${process.env.CLIENT_ID}&client_secret=${process.env.CLIENT_SECRET}&code=${code}&grant_type=authorization_code`, 8 | ); 9 | 10 | return { statusCode: 200, body: JSON.stringify(result.data) }; 11 | }; 12 | 13 | module.exports = { handler }; 14 | -------------------------------------------------------------------------------- /netlify/functions/refreshToken/refreshToken.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const handler = async event => { 4 | const { refresh_token: refreshToken } = JSON.parse(event.body); 5 | 6 | const result = await axios.post( 7 | `https://www.strava.com/oauth/token?client_id=${process.env.CLIENT_ID}&client_secret=${process.env.CLIENT_SECRET}&grant_type=refresh_token&refresh_token=${refreshToken}`, 8 | ); 9 | 10 | return { statusCode: 200, body: JSON.stringify(result.data) }; 11 | }; 12 | 13 | module.exports = { handler }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bifurkate", 3 | "private": true, 4 | "description": "Tired of riding in the same three old routes? Check your ride history and let it inspire you to try new routes!", 5 | "version": "1.1.0", 6 | "author": "Frédéric Bégin ", 7 | "dependencies": { 8 | "@mapbox/polyline": "^1.1.1", 9 | "animejs": "^3.2.0", 10 | "axios": "^0.19.2", 11 | "classnames": "^2.2.6", 12 | "dotenv": "^8.2.0", 13 | "gatsby": "^2.20.12", 14 | "gatsby-image": "^2.3.1", 15 | "gatsby-plugin-create-client-paths": "^2.2.1", 16 | "gatsby-plugin-eslint": "^2.0.8", 17 | "gatsby-plugin-force-trailing-slashes": "^1.0.4", 18 | "gatsby-plugin-manifest": "^2.3.3", 19 | "gatsby-plugin-offline": "^3.1.2", 20 | "gatsby-plugin-react-helmet": "^3.2.1", 21 | "gatsby-plugin-sass": "^2.2.1", 22 | "gatsby-plugin-sharp": "^2.5.3", 23 | "gatsby-plugin-sitemap": "^2.4.11", 24 | "gatsby-source-filesystem": "^2.2.2", 25 | "gatsby-transformer-sharp": "^2.4.3", 26 | "leaflet": "^1.6.0", 27 | "lodash": "^4.17.15", 28 | "moment": "^2.24.0", 29 | "react": "^16.12.0", 30 | "react-color": "^2.18.1", 31 | "react-dates": "^21.8.0", 32 | "react-device-detect": "^1.13.1", 33 | "react-dom": "^16.12.0", 34 | "react-helmet": "^5.2.1", 35 | "react-icons": "^3.10.0", 36 | "react-input-range": "^1.3.0", 37 | "react-leaflet": "^2.7.0", 38 | "react-select": "^3.1.0", 39 | "react-transition-group": "^4.4.1", 40 | "scrollreveal": "^4.0.6", 41 | "simplebar": "^5.0.5-corejs2", 42 | "simplebar-react": "^2.0.7-corejs2", 43 | "use-position": "0.0.7" 44 | }, 45 | "devDependencies": { 46 | "babel-eslint": "^10.1.0", 47 | "eslint-config-airbnb": "^18.2.0", 48 | "eslint-config-prettier": "^6.11.0", 49 | "eslint-config-react": "^1.1.7", 50 | "eslint-config-react-app": "^5.2.1", 51 | "eslint-plugin-import": "^2.22.0", 52 | "eslint-plugin-jsx": "^0.1.0", 53 | "eslint-plugin-prettier": "^3.1.4", 54 | "eslint-plugin-react": "^7.20.3", 55 | "prettier": "^1.19.1", 56 | "sass": "^1.53.0" 57 | }, 58 | "keywords": [ 59 | "gatsby" 60 | ], 61 | "license": "MIT", 62 | "scripts": { 63 | "heroku-postbuild": "gatsby build", 64 | "build": "gatsby build", 65 | "develop": "gatsby develop", 66 | "format": "prettier --write \"**/*.{js,jsx,json,md}\"", 67 | "start": "npm run develop", 68 | "serve": "gatsby serve", 69 | "clean": "gatsby clean", 70 | "test": "echo \"Write tests! -> https://gatsby.dev/unit-testing\" && exit 1" 71 | }, 72 | "engines": { 73 | "npm": "6.14.14", 74 | "node": "14.17.5" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/agents/backendAgents.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export default { 4 | authenticate: async code => { 5 | // Hack to bypass netlify's function in dev 6 | const isDev = process.env.NODE_ENV === 'development'; 7 | const prodUrl = `${process.env.GATSBY_CURRENT_DOMAIN}/.netlify/functions/authenticate`; 8 | const devUrl = `https://www.strava.com/oauth/token?client_id=${process.env.GATSBY_CLIENT_ID}&client_secret=${process.env.GATSBY_CLIENT_SECRET}&code=${code}&grant_type=authorization_code`; 9 | 10 | const result = await axios.post(isDev ? devUrl : prodUrl, { code }); 11 | 12 | return result.data; 13 | }, 14 | refreshToken: async refreshToken => { 15 | const isDev = process.env.NODE_ENV === 'development'; 16 | const prodUrl = `${process.env.GATSBY_CURRENT_DOMAIN}/.netlify/functions/refreshtoken`; 17 | const devUrl = `https://www.strava.com/oauth/token?client_id=${process.env.GATSBY_CLIENT_ID}&client_secret=${process.env.GATSBY_CLIENT_SECRET}&grant_type=refresh_token&refresh_token=${refreshToken}`; 18 | 19 | const result = await axios.post(isDev ? devUrl : prodUrl, { refresh_token: refreshToken }); 20 | 21 | return result.data; 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/agents/stravaAgents.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { fetchAllResults } from '../helpers/fetchHelpers'; 4 | 5 | const baseUrl = 'https://www.strava.com/api/v3'; 6 | 7 | export default { 8 | getProfile: async () => { 9 | const { data } = await axios.get(`${baseUrl}/athlete`, { 10 | headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}` }, 11 | crossDomain: true, 12 | }); 13 | 14 | return data; 15 | }, 16 | getAllRoutes: athleteId => fetchAllResults(`${baseUrl}/athletes/${athleteId}/routes`), 17 | getAllActivities: () => fetchAllResults(`${baseUrl}/activities`), 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/Header/HeaderMenu.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HeaderProfileButton from './HeaderProfileButton'; 3 | 4 | const HeaderMenu = ({ profile }) =>
{profile ? :
}
; 5 | 6 | export default HeaderMenu; 7 | -------------------------------------------------------------------------------- /src/components/Header/HeaderProfileButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const HeaderProfileButton = ({ profile }) => ( 4 | <> 5 | 6 | {profile.firstname} {profile.lastname} 7 | 8 | {profile.profile && } 9 | 10 | ); 11 | 12 | export default HeaderProfileButton; 13 | -------------------------------------------------------------------------------- /src/components/Header/header.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import HeaderMenu from './HeaderMenu'; 5 | import MenuButton from '../Menu/MenuButton'; 6 | import MenuContext from '../../contexts/MenuContext'; 7 | import logo from '../../images/branding/light/logo_transparent_background.png'; 8 | import { useIsMobile } from '../../helpers/hooks'; 9 | 10 | const Header = ({ showMenu, profile }) => { 11 | const { isMenuOpen, toggleMenuOpen } = useContext(MenuContext); 12 | 13 | const isMobile = useIsMobile(); 14 | 15 | const handleMenuClick = () => toggleMenuOpen(!isMenuOpen); 16 | 17 | return ( 18 |
19 |
20 | 21 | main logo 22 |
23 | 24 | {profile && !isMobile && } 25 |
26 | ); 27 | }; 28 | 29 | Header.defaultProps = { 30 | siteTitle: ``, 31 | }; 32 | 33 | export default Header; 34 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Loader = () => ( 4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | ); 31 | 32 | const MapLoader = ({ title = '' }) => ( 33 |
34 |
35 | 36 |
37 |
38 | {title || "Hang on, we're fetching your activities!"} 39 |
40 |
41 | ); 42 | 43 | export default MapLoader; 44 | -------------------------------------------------------------------------------- /src/components/Map/Map.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from 'react'; 2 | import _ from 'lodash'; 3 | import { getMedian } from '../../helpers/mathHelpers'; 4 | import Polyline from './Polyline'; 5 | import MenuContext from '../../contexts/MenuContext'; 6 | 7 | let Leaflet; 8 | 9 | // Fix for Heroku build (Leaflet wants a window object...) 10 | if (typeof window !== 'undefined') { 11 | Leaflet = require('react-leaflet'); 12 | } 13 | 14 | const Map = ({ routes, activities, isLoading }) => { 15 | const { options } = useContext(MenuContext); 16 | const [selectedId, setSelectedId] = useState([]); 17 | const [center, setCenter] = useState([46.8139, -71.29]); 18 | 19 | useEffect(() => { 20 | if (!_.isEmpty(activities) && activities.length > 0) { 21 | const centerLat = getMedian(activities.map(x => _.get(x, 'polyline[0][0]'))); 22 | const centerLong = getMedian(activities.map(x => _.get(x, 'polyline[0][1]'))); 23 | 24 | if (centerLat && centerLong) { 25 | setCenter([centerLat, centerLong]); 26 | } 27 | } 28 | }, [isLoading]); 29 | 30 | const selected = activities.find(({ id }) => id === selectedId) || routes.find(({ id }) => id === selectedId); 31 | 32 | return ( 33 | <> 34 | {typeof window !== 'undefined' && ( 35 | setSelectedId(null)} 45 | > 46 | 50 | 51 | {options.mapConfig.showBikePaths && ( 52 | 53 | )} 54 | 55 | 56 | {options.mapConfig.showRoutes && !isLoading && ( 57 | <> 58 | {routes.map(route => ( 59 | 60 | ))} 61 | 62 | {activities.map(activity => ( 63 | 64 | ))} 65 | 66 | )} 67 | 68 | {!options.mapConfig.showRoutes && !isLoading && activities.map(activity => )} 69 | 70 | {selected && } 71 | 72 | )} 73 | 74 | ); 75 | }; 76 | 77 | export default Map; 78 | -------------------------------------------------------------------------------- /src/components/Map/Polyline.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import moment from 'moment'; 3 | import MenuContext from '../../contexts/MenuContext'; 4 | import { convertKm, convertMeters } from '../../helpers/mathHelpers'; 5 | 6 | const Polyline = ({ item, Leaflet, onClick, isRoute }) => { 7 | const { options } = useContext(MenuContext); 8 | const { unit, polylineColor, polylineWeight, heatMapMode, routesLineColor, routesLineWeight } = options.mapConfig; 9 | 10 | return ( 11 | onClick(item.id)} 13 | positions={item.polyline} 14 | color={isRoute ? routesLineColor : polylineColor} 15 | weight={isRoute ? routesLineWeight : polylineWeight} 16 | opacity={heatMapMode ? 0.3 : 1} 17 | > 18 | 19 | 25 | {isRoute ? 'Route - ' : ''} 26 | {item.name} 27 | 28 |
29 | {isRoute ? 'Creation Date' : 'Date'}: {moment(item.start_date).format('YYYY-MM-DD')} 30 |
31 | Distance: {convertKm(item.distance / 1000, unit, 2)} 32 |
33 | Elevation gain: {convertMeters(isRoute ? item.elevation_gain : item.total_elevation_gain, unit)} 34 |
35 |
36 | ); 37 | }; 38 | 39 | export default Polyline; 40 | -------------------------------------------------------------------------------- /src/components/Menu/Blocks/ActivityTypes.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FaCheck, FaTimes } from 'react-icons/fa'; 3 | import { ActivityType } from '../../../helpers/activityHelpers'; 4 | import Collapsable from '../Collapsable'; 5 | 6 | const ActivityTypes = ({ userActivityTypes, activityTypeConfig, toggleActivityTypeDisplay }) => { 7 | const handleClick = type => toggleActivityTypeDisplay(type); 8 | 9 | return ( 10 | 11 | {userActivityTypes.map(type => ( 12 | 15 | ))} 16 | 17 | ); 18 | }; 19 | 20 | export default ActivityTypes; 21 | -------------------------------------------------------------------------------- /src/components/Menu/Blocks/Dates.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { DateRangePicker } from 'react-dates'; 3 | import Collapsable from '../Collapsable'; 4 | import { useIsMobile } from '../../../helpers/hooks'; 5 | 6 | const Dates = ({ config, setDateConfig, clearConfig }) => { 7 | const [focusedInput, setFocusedInput] = useState(null); 8 | const isMobile = useIsMobile(); 9 | 10 | return ( 11 | 12 | false} 21 | openDirection="up" 22 | block 23 | numberOfMonths={isMobile ? 1 : 2} 24 | withPortal 25 | hideKeyboardShortcutsPanel 26 | readOnly 27 | noBorder 28 | displayFormat="YYYY-MM-DD" 29 | /> 30 |
31 | 34 |
35 |
36 | ); 37 | }; 38 | 39 | export default Dates; 40 | -------------------------------------------------------------------------------- /src/components/Menu/Blocks/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FaEnvelope, FaBeer, FaPowerOff } from 'react-icons/fa'; 3 | 4 | const Footer = () => { 5 | const handleLogOffClick = () => { 6 | if (typeof window !== 'undefined') { 7 | localStorage.removeItem('expires_at'); 8 | localStorage.removeItem('refresh_token'); 9 | localStorage.removeItem('access_token'); 10 | localStorage.removeItem('athlete'); 11 | localStorage.removeItem('mapConfig'); 12 | } 13 | 14 | window.location.replace('/'); 15 | }; 16 | 17 | return ( 18 | 29 | ); 30 | }; 31 | 32 | export default Footer; 33 | -------------------------------------------------------------------------------- /src/components/Menu/Blocks/MapOptions.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FaCheck, FaTimes } from 'react-icons/fa'; 3 | import { CirclePicker } from 'react-color'; 4 | import InputRange from 'react-input-range'; 5 | import classNames from 'classnames'; 6 | import Collapsable from '../Collapsable'; 7 | 8 | const MapOptions = ({ mapConfig, setMapOption }) => { 9 | const isImperial = mapConfig.unit === 'imperial'; 10 | 11 | const handleHeatmapClick = () => setMapOption({ heatMapMode: !mapConfig.heatMapMode }); 12 | 13 | const handleToggleUnit = unit => setMapOption({ unit }); 14 | 15 | const handleBikePathsClick = () => setMapOption({ showBikePaths: !mapConfig.showBikePaths }); 16 | 17 | const handleColorClick = polylineColor => setMapOption({ polylineColor }); 18 | 19 | const handleWeightClick = polylineWeight => setMapOption({ polylineWeight }); 20 | 21 | return ( 22 | 23 | 24 | Units 25 | 26 | 29 | 32 | 33 | 34 | 37 |
38 | Line Color 39 | handleColorClick(hex)} 46 | /> 47 |
48 |
49 | Line Weight 50 |
51 | handleWeightClick(polylineWeight)} formatLabel={() => ''} /> 52 |
53 |
54 | 57 | 60 |
61 | ); 62 | }; 63 | 64 | export default MapOptions; 65 | -------------------------------------------------------------------------------- /src/components/Menu/Blocks/NoActivities.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NoActivities = () => ( 4 |
5 | No Activities to display... 6 |
7 | ); 8 | 9 | export default NoActivities; 10 | -------------------------------------------------------------------------------- /src/components/Menu/Blocks/RoutesOptions.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import InputRange from 'react-input-range'; 3 | import { FaCheck, FaTimes } from 'react-icons/fa'; 4 | import { CirclePicker } from 'react-color'; 5 | 6 | import Collapsable from '../Collapsable'; 7 | 8 | const RoutesOptions = ({ mapConfig, setMapOption }) => { 9 | const handleRoutesClick = () => setMapOption({ showRoutes: !mapConfig.showRoutes }); 10 | 11 | const handleColorClick = routesLineColor => setMapOption({ routesLineColor }); 12 | 13 | const handleWeightClick = routesLineWeight => setMapOption({ routesLineWeight }); 14 | 15 | return ( 16 | 17 | 20 |
21 | Line Color 22 | handleColorClick(hex)} 29 | /> 30 |
31 |
32 | Line Weight 33 |
34 | handleWeightClick(routesLineWeight)} formatLabel={() => ''} /> 35 |
36 |
37 | 40 |
41 | ); 42 | }; 43 | 44 | export default RoutesOptions; 45 | -------------------------------------------------------------------------------- /src/components/Menu/Blocks/Seasons.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FaCheck, FaTimes } from 'react-icons/fa'; 3 | import Collapsable from '../Collapsable'; 4 | 5 | const Seasons = ({ seasonConfig, toggleSeasonDisplay }) => { 6 | const handleClick = key => toggleSeasonDisplay({ [key]: !seasonConfig[key] }); 7 | 8 | return ( 9 | 10 | {Object.keys(seasonConfig).map(key => ( 11 | 14 | ))} 15 | 16 | ); 17 | }; 18 | 19 | export default Seasons; 20 | -------------------------------------------------------------------------------- /src/components/Menu/Blocks/Stats.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Collapsable from '../Collapsable'; 3 | import { getTotalTime, getTotalDistance, getTotalElevation } from '../../../helpers/activityHelpers'; 4 | import { convertKm, convertMeters } from '../../../helpers/mathHelpers'; 5 | 6 | const Stats = ({ activities, mapConfig }) => { 7 | const distance = getTotalDistance(activities); 8 | const elevation = getTotalElevation(activities); 9 | const time = getTotalTime(activities); 10 | 11 | return ( 12 | 13 | 14 | Distance {convertKm(distance, mapConfig.unit)} 15 | 16 | 17 | Elevation {convertMeters(elevation, mapConfig.unit)} 18 | 19 | 20 | Moving Time {time} hours 21 | 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default Stats; 28 | -------------------------------------------------------------------------------- /src/components/Menu/Collapsable.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import classNames from 'classnames'; 3 | import { FaChevronUp } from 'react-icons/fa'; 4 | import { CSSTransition } from 'react-transition-group'; 5 | import { useIsMobile } from '../../helpers/hooks'; 6 | 7 | const Collapsable = ({ label, isInitiallyOpen, children }) => { 8 | const [isOpen, setIsOpen] = useState(isInitiallyOpen); 9 | const isMobile = useIsMobile(); 10 | 11 | if (!isMobile) { 12 | return ( 13 |
14 | 18 | 19 |
{children}
20 |
21 |
22 | ); 23 | } 24 | 25 | return ( 26 |
27 |
{label}
28 | 29 |
{children}
30 |
31 | ); 32 | }; 33 | 34 | export default Collapsable; 35 | -------------------------------------------------------------------------------- /src/components/Menu/Menu.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react'; 2 | import _ from 'lodash'; 3 | 4 | import ActivityTypes from './Blocks/ActivityTypes.jsx'; 5 | import Footer from './Blocks/Footer'; 6 | import MapOptions from './Blocks/MapOptions'; 7 | import MenuContext from '../../contexts/MenuContext'; 8 | import NoActivities from './Blocks/NoActivities'; 9 | import Seasons from './Blocks/Seasons'; 10 | import { getAllActivityTypes } from '../../helpers/activityHelpers'; 11 | import { usePrevious } from '../../helpers/hooks'; 12 | import MenuWrapper from './MenuWrapper'; 13 | import Dates from './Blocks/Dates'; 14 | import Stats from './Blocks/Stats'; 15 | import RoutesOptions from './Blocks/RoutesOptions.jsx'; 16 | 17 | const Menu = ({ activities, shownActivities }) => { 18 | const { initializeMenu, toggleActivityTypeDisplay, isMenuOpen, setOption, setMapOption, options, setDateConfig, toggleSeasonDisplay } = useContext(MenuContext); 19 | const userActivityTypes = getAllActivityTypes(activities); 20 | 21 | const prevActivityTypes = usePrevious(userActivityTypes); 22 | 23 | useEffect(() => { 24 | const userActivityLoaded = activities && userActivityTypes && prevActivityTypes; 25 | const userActivityTypesChanged = !_.isEqual(prevActivityTypes, userActivityTypes); 26 | 27 | if (userActivityLoaded && userActivityTypesChanged) { 28 | initializeMenu(activities, userActivityTypes); 29 | } 30 | }, [activities, userActivityTypes, prevActivityTypes, setOption, initializeMenu]); 31 | 32 | return ( 33 | 34 |
35 |
36 | {_.isEmpty(userActivityTypes) && } 37 | {!_.isEmpty(userActivityTypes) && ( 38 | <> 39 | 40 | 41 | 42 | 43 | setDateConfig(datesConfig)} 48 | clearConfig={() => setDateConfig({ datesConfig: { startDate: null, endDate: null } }, true)} 49 | /> 50 | setDateConfig(datesConfig)} 53 | clearConfig={() => setDateConfig({ datesConfig: { startDate: null, endDate: null } }, true)} 54 | /> 55 | 56 | )} 57 |
58 |
59 |
60 |
61 | ); 62 | }; 63 | 64 | export default Menu; 65 | -------------------------------------------------------------------------------- /src/components/Menu/MenuButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FaTimes, FaBars } from 'react-icons/fa'; 3 | 4 | const MenuButton = ({ onClick, isOpen, disabled }) => ( 5 | 8 | ); 9 | 10 | export default MenuButton; 11 | -------------------------------------------------------------------------------- /src/components/Menu/MenuWrapper.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SimpleBar from 'simplebar-react'; 3 | import classNames from 'classnames'; 4 | import { useIsMobile } from '../../helpers/hooks'; 5 | 6 | const MenuWrapper = ({ children, isMenuOpen }) => { 7 | const isMobile = useIsMobile(); 8 | 9 | if (isMobile) { 10 | return
{children}
; 11 | } 12 | 13 | return ( 14 | 15 | {children} 16 | 17 | ); 18 | }; 19 | 20 | export default MenuWrapper; 21 | -------------------------------------------------------------------------------- /src/components/layout.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext, useState } from 'react'; 2 | import _ from 'lodash'; 3 | import { useIsMobile } from '../helpers/hooks'; 4 | 5 | import Header from './Header/header'; 6 | import stravaAgents from '../agents/stravaAgents'; 7 | import AthleteContext from '../contexts/AthleteContext'; 8 | import backendAgents from '../agents/backendAgents'; 9 | 10 | const Layout = ({ children, showMenu }) => { 11 | const { storeHydrated, athlete, setAthlete } = useContext(AthleteContext); 12 | const [expiresAtState, setExpiresAtState] = useState(typeof window !== 'undefined' ? localStorage.getItem('expires_at') : null); 13 | const isMobile = useIsMobile(); 14 | 15 | useEffect(() => { 16 | if (isMobile && typeof window !== 'undefined') { 17 | document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`); 18 | 19 | window.addEventListener('resize', () => { 20 | document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`); 21 | }); 22 | } 23 | }, []); 24 | 25 | useEffect(() => { 26 | const expiresAt = typeof window !== 'undefined' ? localStorage.getItem('expires_at') : null; 27 | const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refresh_token') : null; 28 | const currentTime = new Date().getTime() / 1000; 29 | 30 | if (currentTime && currentTime > expiresAt) { 31 | backendAgents 32 | .refreshToken(refreshToken) 33 | .then(data => { 34 | setExpiresAtState(data.expires_at); 35 | if (typeof window !== 'undefined') { 36 | localStorage.setItem('expires_at', data.expires_at); 37 | localStorage.setItem('refresh_token', data.refresh_token); 38 | localStorage.setItem('access_token', data.access_token); 39 | } 40 | }) 41 | .catch(() => { 42 | window.location.replace(process.env.GATSBY_AUTHORIZE_URL); 43 | }); 44 | } else if (!expiresAt) { 45 | window.location.replace(process.env.GATSBY_AUTHORIZE_URL); 46 | } 47 | }); 48 | 49 | useEffect(() => { 50 | const currentTime = new Date().getTime() / 1000; 51 | if (currentTime < expiresAtState && _.isEmpty(athlete) && storeHydrated) { 52 | stravaAgents.getProfile().then(athleteProfile => setAthlete(athleteProfile)); 53 | } 54 | }, [athlete, expiresAtState, storeHydrated, setAthlete]); 55 | 56 | return ( 57 | <> 58 |
59 | 60 |
{children}
61 | 62 | ); 63 | }; 64 | 65 | export default Layout; 66 | -------------------------------------------------------------------------------- /src/components/seo.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * SEO component that queries for data with 3 | * Gatsby's useStaticQuery React hook 4 | * 5 | * See: https://www.gatsbyjs.org/docs/use-static-query/ 6 | */ 7 | 8 | import React from 'react'; 9 | import Helmet from 'react-helmet'; 10 | import { useStaticQuery, graphql } from 'gatsby'; 11 | 12 | function SEO({ description, lang, meta, title }) { 13 | const { site } = useStaticQuery( 14 | graphql` 15 | query { 16 | site { 17 | siteMetadata { 18 | title 19 | description 20 | author 21 | image 22 | keywords 23 | } 24 | } 25 | } 26 | `, 27 | ); 28 | 29 | const metaDescription = description || site.siteMetadata.description; 30 | 31 | return ( 32 | 81 | ); 82 | } 83 | 84 | SEO.defaultProps = { 85 | lang: `en`, 86 | meta: [], 87 | description: ``, 88 | }; 89 | 90 | export default SEO; 91 | -------------------------------------------------------------------------------- /src/contexts/ActivityContext.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const defaultState = { 4 | activities: [], 5 | }; 6 | 7 | const ActivityContext = React.createContext(defaultState); 8 | 9 | class ActivityProvider extends React.Component { 10 | state = defaultState; 11 | 12 | setActivities = activities => this.setState({ activities }); 13 | 14 | render() { 15 | const { children } = this.props; 16 | const { activities } = this.state; 17 | const { setActivities } = this; 18 | 19 | return {children}; 20 | } 21 | } 22 | 23 | export default ActivityContext; 24 | 25 | export { ActivityProvider }; 26 | -------------------------------------------------------------------------------- /src/contexts/AthleteContext.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { setWithExpiry, getWithExpiry } from '../helpers/localStorageHelpers'; 3 | 4 | const defaultState = { 5 | athlete: {}, 6 | setAthlete: () => {}, 7 | }; 8 | 9 | const AthleteContext = React.createContext(defaultState); 10 | 11 | class AthleteProvider extends React.Component { 12 | state = { 13 | athlete: {}, 14 | storeHydrated: false, 15 | }; 16 | 17 | componentDidMount() { 18 | if (typeof window !== 'undefined') { 19 | const athlete = getWithExpiry('athlete'); 20 | if (athlete) { 21 | this.setState({ athlete }); 22 | } 23 | } 24 | 25 | this.setState({ storeHydrated: true }); 26 | } 27 | 28 | componentDidUpdate(_prevProps, prevState) { 29 | if (prevState.athlete !== this.state.athlete) { 30 | setWithExpiry('athlete', this.state.athlete, 1000 * 60 * 60); 31 | } 32 | } 33 | 34 | setAthlete = athlete => this.setState({ athlete }); 35 | 36 | render() { 37 | const { children } = this.props; 38 | const { athlete, storeHydrated } = this.state; 39 | const { setAthlete } = this; 40 | 41 | return {children}; 42 | } 43 | } 44 | 45 | export default AthleteContext; 46 | 47 | export { AthleteProvider }; 48 | -------------------------------------------------------------------------------- /src/contexts/MenuContext.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _ from 'lodash'; 3 | import { getSeasonConfig } from '../helpers/activityHelpers'; 4 | 5 | const defaultState = { 6 | isMenuOpen: false, 7 | toggleMenuOpen: () => {}, 8 | toggleActivityTypeDisplay: () => {}, 9 | toggleSeasonDisplay: () => {}, 10 | 11 | options: { 12 | mapConfig: { 13 | heatMapMode: false, 14 | polylineColor: '#FF0000', 15 | polylineWeight: 2, 16 | showBikePaths: false, 17 | unit: 'metric', 18 | showRoutes: false, 19 | routesLineColor: '#9999A1', 20 | routesLineWeight: 2, 21 | }, 22 | activityTypeConfig: {}, 23 | seasonConfig: {}, 24 | datesConfig: {}, 25 | }, 26 | }; 27 | 28 | const MenuContext = React.createContext(defaultState); 29 | 30 | class MenuProvider extends React.Component { 31 | state = defaultState; 32 | 33 | componentDidMount() { 34 | if (typeof window !== 'undefined') { 35 | try { 36 | const localMapConfig = JSON.parse(localStorage.getItem('mapConfig')); 37 | this.setMapOption(localMapConfig); 38 | } catch (err) {} 39 | } 40 | } 41 | 42 | componentDidUpdate(_prevProps, prevState) { 43 | if (typeof window !== 'undefined' && !_.isEqual(prevState.options.mapConfig, this.state.options.mapConfig)) { 44 | localStorage.setItem('mapConfig', JSON.stringify(this.state.options.mapConfig)); 45 | } 46 | } 47 | 48 | initializeMenu = (activities, userActivityTypes) => { 49 | const activityTypeConfig = {}; 50 | userActivityTypes.forEach(x => { 51 | activityTypeConfig[x] = true; 52 | }); 53 | 54 | const seasonConfig = getSeasonConfig(activities); 55 | this.setOption({ activityTypeConfig, seasonConfig }); 56 | }; 57 | 58 | setOption = options => this.setState({ options: { ...this.state.options, ...options } }); 59 | 60 | setMapOption = options => this.setState({ options: { ...this.state.options, mapConfig: { ...this.state.options.mapConfig, ...options } } }); 61 | 62 | toggleSeasonDisplay = season => { 63 | this.setOption({ datesConfig: {}, seasonConfig: { ...this.state.options.seasonConfig, ...season } }); 64 | }; 65 | 66 | setDateConfig = (datesConfig, seasonValue = false) => { 67 | const seasonConfig = {}; 68 | const seasons = Object.keys(this.state.options.seasonConfig); 69 | seasons.forEach(x => { 70 | seasonConfig[x] = seasonValue; 71 | }); 72 | 73 | this.setOption({ datesConfig, seasonConfig }); 74 | }; 75 | 76 | toggleMenuOpen = () => this.setState({ isMenuOpen: !this.state.isMenuOpen }); 77 | 78 | toggleActivityTypeDisplay = type => { 79 | const { options } = this.state; 80 | const { activityTypeConfig } = options; 81 | 82 | this.setState({ options: { ...options, activityTypeConfig: { ...activityTypeConfig, [type]: !activityTypeConfig[type] } } }); 83 | }; 84 | 85 | render() { 86 | const { children } = this.props; 87 | const { options, isMenuOpen } = this.state; 88 | const { initializeMenu, toggleSeasonDisplay, toggleMenuOpen, setOption, toggleActivityTypeDisplay, setMapOption, setDateConfig } = this; 89 | 90 | return ( 91 | 104 | {children} 105 | 106 | ); 107 | } 108 | } 109 | 110 | export default MenuContext; 111 | 112 | export { MenuProvider }; 113 | -------------------------------------------------------------------------------- /src/contexts/RouteContext.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const defaultState = { 4 | routes: [], 5 | }; 6 | 7 | const RouteContext = React.createContext(defaultState); 8 | 9 | class RouteProvider extends React.Component { 10 | state = defaultState; 11 | 12 | setRoutes = routes => this.setState({ routes }); 13 | 14 | render() { 15 | const { children } = this.props; 16 | const { routes } = this.state; 17 | const { setRoutes } = this; 18 | 19 | return {children}; 20 | } 21 | } 22 | 23 | export default RouteContext; 24 | 25 | export { RouteProvider }; 26 | -------------------------------------------------------------------------------- /src/domain/ActivityType.jsx: -------------------------------------------------------------------------------- 1 | const ActivityType = { 2 | AlpineSki: 'Alpine Ski', 3 | BackcountrySki: 'Backcountry Ski', 4 | Canoeing: 'Canoeing', 5 | Crossfit: 'Crossfit', 6 | EBikeRide: 'EBike Ride', 7 | Elliptical: 'Elliptical', 8 | Golf: 'Golf', 9 | Handcycle: 'Handcycle', 10 | Hike: 'Hike', 11 | IceSkate: 'Ice Skate', 12 | InlineSkate: 'Inline Skate', 13 | Kayaking: 'Kayaking', 14 | Kitesurf: 'Kitesurf', 15 | NordicSki: 'Nordic Ski', 16 | Ride: 'Ride', 17 | RockClimbing: 'Rock Climbing', 18 | RollerSki: 'Roller Ski', 19 | Rowing: 'Rowing', 20 | Run: 'Run', 21 | Sail: 'Sail', 22 | Skateboard: 'Skateboard', 23 | Snowboard: 'Snowboard', 24 | Snowshoe: 'Snowshoe', 25 | Soccer: 'Soccer', 26 | StairStepper: 'Stair Stepper', 27 | StandUpPaddling: 'Stand Up Paddling', 28 | Surfing: 'Surfing', 29 | Swim: 'Swim', 30 | Velomobile: 'Velomobile', 31 | Walk: 'Walk', 32 | WeightTraining: 'Weight Training', 33 | Wheelchair: 'Wheelchair', 34 | Windsurf: 'Windsurf', 35 | Workout: 'Workout', 36 | Yoga: 'Yoga', 37 | }; 38 | 39 | export default ActivityType; 40 | -------------------------------------------------------------------------------- /src/helpers/activityHelpers.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import moment from 'moment'; 3 | import polyline from '@mapbox/polyline'; 4 | import { getFormattedDate } from './dateHelpers'; 5 | 6 | export const filterActivitiesToDisplay = (activities, options) => { 7 | const { activityTypeConfig, seasonConfig } = options; 8 | const typeKeys = Object.keys(activityTypeConfig); 9 | const seasonKeys = Object.keys(seasonConfig); 10 | const { startDate, endDate } = options.datesConfig; 11 | 12 | let filteredActivities = activities.filter(x => !!x.polyline); 13 | 14 | const selectedTypes = typeKeys.filter(key => !!activityTypeConfig[key]); 15 | filteredActivities = filteredActivities.filter(x => selectedTypes.includes(x.type)); 16 | 17 | if (startDate && endDate) { 18 | filteredActivities = filteredActivities.filter(x => { 19 | const activityDate = moment(x.start_date); 20 | return activityDate.isBefore(endDate) && activityDate.isAfter(startDate); 21 | }); 22 | } else { 23 | const selectedSeasons = seasonKeys.filter(key => !!seasonConfig[key]); 24 | filteredActivities = filteredActivities.filter(x => selectedSeasons.includes(moment(x.start_date).format('YYYY'))); 25 | } 26 | 27 | return filteredActivities; 28 | }; 29 | 30 | export const decodePolylines = activity => { 31 | let decodedPolyline = null; 32 | 33 | try { 34 | decodedPolyline = polyline.decode(activity.map.summary_polyline); 35 | } catch (err) { 36 | decodedPolyline = null; 37 | } 38 | 39 | return { ...activity, polyline: decodedPolyline }; 40 | }; 41 | 42 | export const processRoutes = data => { 43 | return data 44 | .filter(x => !!_.get(x, 'map.summary_polyline')) 45 | .map(x => decodePolylines(x)) 46 | .reverse(); 47 | }; 48 | 49 | export const processActivities = data => { 50 | return data 51 | .filter(x => !x.type.includes('Virtual') && !!_.get(x, 'map.summary_polyline')) 52 | .map(x => decodePolylines(x)) 53 | .reverse(); 54 | }; 55 | 56 | export const ActivityType = { 57 | AlpineSki: 'Alpine Ski', 58 | BackcountrySki: 'Backcountry Ski', 59 | Canoeing: 'Canoeing', 60 | Crossfit: 'Crossfit', 61 | EBikeRide: 'EBike Ride', 62 | Elliptical: 'Elliptical', 63 | Golf: 'Golf', 64 | Handcycle: 'Handcycle', 65 | Hike: 'Hike', 66 | IceSkate: 'Ice Skate', 67 | InlineSkate: 'Inline Skate', 68 | Kayaking: 'Kayaking', 69 | Kitesurf: 'Kitesurf', 70 | NordicSki: 'Nordic Ski', 71 | Ride: 'Ride', 72 | RockClimbing: 'Rock Climbing', 73 | RollerSki: 'Roller Ski', 74 | Rowing: 'Rowing', 75 | Run: 'Run', 76 | Sail: 'Sail', 77 | Skateboard: 'Skateboard', 78 | Snowboard: 'Snowboard', 79 | Snowshoe: 'Snowshoe', 80 | Soccer: 'Soccer', 81 | StairStepper: 'Stair Stepper', 82 | StandUpPaddling: 'Stand Up Paddling', 83 | Surfing: 'Surfing', 84 | Swim: 'Swim', 85 | Velomobile: 'Velomobile', 86 | Walk: 'Walk', 87 | WeightTraining: 'Weight Training', 88 | Wheelchair: 'Wheelchair', 89 | Windsurf: 'Windsurf', 90 | Workout: 'Workout', 91 | Yoga: 'Yoga', 92 | }; 93 | 94 | export const getAllActivityTypes = activities => _.uniq(activities.map(x => x.type)).sort(); 95 | 96 | export const getSeasonConfig = activities => { 97 | const seasonConfig = {}; 98 | const allSeasons = activities.map(x => moment(x.start_date).format('YYYY')); 99 | const uniqueSeasons = _.uniq(allSeasons, true); 100 | uniqueSeasons.forEach(x => { 101 | seasonConfig[x] = true; 102 | }); 103 | 104 | return seasonConfig; 105 | }; 106 | 107 | export const getTotalDistance = activities => Math.round(activities.reduce((accumulator, activity) => accumulator + activity.distance, 0) / 1000); 108 | export const getTotalTime = activities => getFormattedDate(activities.reduce((accumulator, activity) => accumulator + activity.moving_time, 0)); 109 | export const getTotalElevation = activities => Math.round(activities.reduce((accumulator, activity) => accumulator + activity.total_elevation_gain, 0)); 110 | -------------------------------------------------------------------------------- /src/helpers/dateHelpers.js: -------------------------------------------------------------------------------- 1 | export const getFormattedDate = totalSeconds => Math.floor(totalSeconds / 3600); 2 | -------------------------------------------------------------------------------- /src/helpers/fetchHelpers.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import _ from 'lodash'; 3 | 4 | export const fetchAllResults = async url => { 5 | let firstIndex = 1; 6 | const nbOfPagesPerIteration = 4; 7 | const nbOfItemsPerPages = 200; 8 | 9 | const data = []; 10 | 11 | while (true) { 12 | const promises = _.times(nbOfPagesPerIteration).map(index => 13 | axios.get(`${url}?per_page=${nbOfItemsPerPages}&page=${firstIndex + index}`, { 14 | headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}` }, 15 | crossDomain: true, 16 | }), 17 | ); 18 | 19 | const results = await Promise.all(promises); 20 | const flattenedResults = results.reduce((allData, page) => [...allData, ...page.data], []); 21 | 22 | data.push(...flattenedResults); 23 | 24 | firstIndex += nbOfPagesPerIteration; 25 | 26 | if (flattenedResults.length < nbOfItemsPerPages * nbOfPagesPerIteration) break; 27 | } 28 | 29 | return data; 30 | }; 31 | -------------------------------------------------------------------------------- /src/helpers/hooks.js: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, useState, useLayoutEffect, useContext } from 'react'; 2 | import _ from 'lodash'; 3 | import AthleteContext from '../contexts/AthleteContext'; 4 | import ActivityContext from '../contexts/ActivityContext'; 5 | import RouteContext from '../contexts/RouteContext'; 6 | import stravaAgents from '../agents/stravaAgents'; 7 | import { processActivities, processRoutes } from './activityHelpers'; 8 | import MenuContext from '../contexts/MenuContext'; 9 | 10 | export const usePrevious = value => { 11 | const ref = useRef(); 12 | 13 | useEffect(() => { 14 | ref.current = value; 15 | }); 16 | 17 | return ref.current; 18 | }; 19 | 20 | export const useIsMobile = () => { 21 | const [showMobile, setShowMobile] = useState(false); 22 | 23 | useLayoutEffect(() => { 24 | function updateIsMobile() { 25 | setShowMobile(typeof window !== 'undefined' && screen.width < 768); 26 | } 27 | 28 | window.addEventListener('resize', updateIsMobile); 29 | updateIsMobile(); 30 | 31 | return () => window.removeEventListener('resize', updateIsMobile); 32 | }, []); 33 | 34 | useEffect(() => { 35 | if (typeof window !== 'undefined') { 36 | setShowMobile(screen.width < 768); 37 | } 38 | }, [typeof window !== 'undefined' && screen.width]); 39 | 40 | return showMobile; 41 | }; 42 | 43 | export const useInitData = ({ setIsLoading }) => { 44 | const [areRoutesLoading, setAreRoutesLoading] = useState(true); 45 | const [areActivitiesLoading, setAreActivitiesLoading] = useState(true); 46 | 47 | const { options } = useContext(MenuContext); 48 | const { storeHydrated, athlete } = useContext(AthleteContext); 49 | const { activities, setActivities } = useContext(ActivityContext); 50 | const { routes, setRoutes } = useContext(RouteContext); 51 | 52 | useEffect(() => { 53 | if (storeHydrated && athlete.id && _.isEmpty(activities)) { 54 | setIsLoading(true); 55 | 56 | stravaAgents 57 | .getAllRoutes(athlete.id) 58 | .then(data => setRoutes(processRoutes(data))) 59 | .finally(() => setAreRoutesLoading(false)); 60 | 61 | stravaAgents 62 | .getAllActivities() 63 | .then(data => setActivities(processActivities(data))) 64 | .finally(() => setAreActivitiesLoading(false)); 65 | } 66 | }, [storeHydrated, athlete.id, activities, routes]); 67 | 68 | useEffect(() => { 69 | if (options.mapConfig.showRoutes && !areRoutesLoading && !areActivitiesLoading) { 70 | setIsLoading(false); 71 | } 72 | 73 | if (!options.mapConfig.showRoutes && !areActivitiesLoading) { 74 | setIsLoading(false); 75 | } 76 | }, [areRoutesLoading, areActivitiesLoading]); 77 | }; 78 | -------------------------------------------------------------------------------- /src/helpers/localStorageHelpers.js: -------------------------------------------------------------------------------- 1 | export const setWithExpiry = (key, value, ttl) => { 2 | const item = { value, expiry: new Date().getTime() + ttl }; 3 | localStorage.setItem(key, JSON.stringify(item)); 4 | }; 5 | 6 | export const getWithExpiry = key => { 7 | const itemStr = localStorage.getItem(key); 8 | if (!itemStr) return null; 9 | 10 | const item = JSON.parse(itemStr); 11 | 12 | if (new Date().getTime() > item.expiry) { 13 | localStorage.removeItem(key); 14 | return null; 15 | } 16 | 17 | return item.value; 18 | }; 19 | -------------------------------------------------------------------------------- /src/helpers/mathHelpers.js: -------------------------------------------------------------------------------- 1 | export const getMedian = values => { 2 | if (values.length === 0) return 0; 3 | 4 | values.sort((a, b) => a - b); 5 | 6 | const half = Math.floor(values.length / 2); 7 | 8 | if (values.length % 2) return values[half]; 9 | 10 | return (values[half - 1] + values[half]) / 2.0; 11 | }; 12 | 13 | export const convertMeters = (value, unit) => { 14 | if (unit === 'metric') { 15 | return `${value.toFixed(0)} m`; 16 | } 17 | 18 | return `${(value * 3.28084).toFixed(0)} ft`; 19 | }; 20 | 21 | export const convertKm = (value, unit, decimal = 0) => { 22 | if (unit === 'metric') { 23 | return `${value.toFixed(decimal)} km`; 24 | } 25 | 26 | return `${(value * 0.621371).toFixed(decimal).toLocaleString('fr')} mi`; 27 | }; 28 | -------------------------------------------------------------------------------- /src/images/bike.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/bike.jpeg -------------------------------------------------------------------------------- /src/images/branding/dark/dark_logo_transparent_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/dark/dark_logo_transparent_background.png -------------------------------------------------------------------------------- /src/images/branding/dark/dark_logo_white_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/dark/dark_logo_white_background.jpg -------------------------------------------------------------------------------- /src/images/branding/dark/logo_transparent_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/dark/logo_transparent_background.png -------------------------------------------------------------------------------- /src/images/branding/dark/logo_white_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/dark/logo_white_background.jpg -------------------------------------------------------------------------------- /src/images/branding/dark/white_logo_color_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/dark/white_logo_color_background.jpg -------------------------------------------------------------------------------- /src/images/branding/dark/white_logo_dark_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/dark/white_logo_dark_background.jpg -------------------------------------------------------------------------------- /src/images/branding/dark/white_logo_transparent_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/dark/white_logo_transparent_background.png -------------------------------------------------------------------------------- /src/images/branding/light/dark_logo_transparent_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/light/dark_logo_transparent_background.png -------------------------------------------------------------------------------- /src/images/branding/light/dark_logo_white_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/light/dark_logo_white_background.jpg -------------------------------------------------------------------------------- /src/images/branding/light/logo_transparent_background original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/light/logo_transparent_background original.png -------------------------------------------------------------------------------- /src/images/branding/light/logo_transparent_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/light/logo_transparent_background.png -------------------------------------------------------------------------------- /src/images/branding/light/logo_white_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/light/logo_white_background.jpg -------------------------------------------------------------------------------- /src/images/branding/light/white_logo_color_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/light/white_logo_color_background.jpg -------------------------------------------------------------------------------- /src/images/branding/light/white_logo_dark_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/light/white_logo_dark_background.jpg -------------------------------------------------------------------------------- /src/images/branding/light/white_logo_transparent_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/light/white_logo_transparent_background.png -------------------------------------------------------------------------------- /src/images/branding/logo-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/branding/logo-square.png -------------------------------------------------------------------------------- /src/images/cta-illustration.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/demo.png -------------------------------------------------------------------------------- /src/images/demo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/demo1.png -------------------------------------------------------------------------------- /src/images/feature-icon-01.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/feature-icon-02.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/feature-icon-03.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/feature-icon-04.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/feature-icon-05.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/feature-icon-06.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/github.png -------------------------------------------------------------------------------- /src/images/hero-back-illustration.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/hero-top-illustration.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/strava-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/strava-logo.png -------------------------------------------------------------------------------- /src/images/stravabutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/stravabutton.png -------------------------------------------------------------------------------- /src/images/stravapower.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredbegin11/bifurkate/458f5fa84c047980d66676a2098d3a4cf8c99b1e/src/images/stravapower.png -------------------------------------------------------------------------------- /src/pages/404.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Link } from 'gatsby'; 4 | import bike from '../images/bike.jpeg'; 5 | import SEO from '../components/seo'; 6 | 7 | const NotFoundPage = () => ( 8 |
9 | 10 | Bike background 11 |

Page not found

12 |

13 | You just hit a route that doesn't exist... The sadness.{' '} 14 | 15 | 😢 16 | 17 |

18 | Back to safety 19 |
20 | ); 21 | 22 | export default NotFoundPage; 23 | -------------------------------------------------------------------------------- /src/pages/app.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react'; 2 | 3 | import Layout from '../components/layout'; 4 | import MapLoader from '../components/Loader/Loader'; 5 | import SEO from '../components/seo'; 6 | import Menu from '../components/Menu/Menu'; 7 | import MenuContext from '../contexts/MenuContext'; 8 | import { filterActivitiesToDisplay } from '../helpers/activityHelpers'; 9 | import ActivityContext from '../contexts/ActivityContext'; 10 | 11 | import { useInitData } from '../helpers/hooks'; 12 | import Map from '../components/Map/Map'; 13 | import RouteContext from '../contexts/RouteContext'; 14 | 15 | const App = () => { 16 | const { activities } = useContext(ActivityContext); 17 | const { routes } = useContext(RouteContext); 18 | const { options } = useContext(MenuContext); 19 | const [isLoading, setIsLoading] = useState(false); 20 | 21 | useInitData({ setIsLoading }); 22 | 23 | const activitiesToShow = filterActivitiesToDisplay(activities, options); 24 | 25 | return ( 26 | <> 27 | {isLoading && } 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default App; 38 | -------------------------------------------------------------------------------- /src/pages/callback.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { navigate } from 'gatsby'; 3 | 4 | import Loader from '../components/Loader/Loader'; 5 | import backendAgents from '../agents/backendAgents'; 6 | 7 | const Callback = ({ location }) => { 8 | useEffect(() => { 9 | const params = new URLSearchParams(location.search); 10 | const code = params.get('code'); 11 | const error = params.get('error'); 12 | 13 | if (!error) { 14 | backendAgents 15 | .authenticate(code) 16 | .then(data => { 17 | if (typeof window !== 'undefined') { 18 | localStorage.setItem('expires_at', data.expires_at); 19 | localStorage.setItem('refresh_token', data.refresh_token); 20 | localStorage.setItem('access_token', data.access_token); 21 | } 22 | 23 | navigate('/app/'); 24 | }) 25 | .catch(() => {}); 26 | } else if (typeof window !== 'undefined') { 27 | window.location.replace(process.env.GATSBY_AUTHORIZE_URL); 28 | } 29 | }, []); 30 | 31 | return ; 32 | }; 33 | 34 | export default Callback; 35 | -------------------------------------------------------------------------------- /src/pages/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import anime from 'animejs/lib/anime.es.js'; 3 | import { Link } from 'gatsby'; 4 | 5 | import demo from '../images/demo.png'; 6 | import demo1 from '../images/demo1.png'; 7 | import logo from '../images/branding/light/logo_transparent_background.png'; 8 | import featureIcon1 from '../images/feature-icon-01.svg'; 9 | import featureIcon2 from '../images/feature-icon-02.svg'; 10 | import featureIcon3 from '../images/feature-icon-03.svg'; 11 | import github from '../images/github.png'; 12 | 13 | import SEO from '../components/seo'; 14 | import stravaButton from '../images/stravabutton.png'; 15 | import stravaPower from '../images/stravapower.png'; 16 | 17 | const IndexPage = () => { 18 | useEffect(() => { 19 | if (typeof window !== 'undefined') { 20 | const ScrollReveal = require('scrollreveal'); 21 | const doc = document.documentElement; 22 | doc.classList.remove('no-js'); 23 | doc.classList.add('js'); 24 | 25 | const sr = (window.sr = ScrollReveal.default()); 26 | 27 | sr.reveal('.feature', { 28 | duration: 600, 29 | distance: '20px', 30 | easing: 'cubic-bezier(0.5, -0.01, 0, 1.005)', 31 | origin: 'bottom', 32 | interval: 100, 33 | }); 34 | 35 | doc.classList.add('anime-ready'); 36 | anime 37 | .timeline({ 38 | targets: '.hero-figure-box-05', 39 | }) 40 | .add({ 41 | duration: 400, 42 | easing: 'easeInOutExpo', 43 | scaleX: [0.05, 0.05], 44 | scaleY: [0, 1], 45 | perspective: '500px', 46 | delay: anime.random(0, 400), 47 | }) 48 | .add({ 49 | duration: 400, 50 | easing: 'easeInOutExpo', 51 | scaleX: 1, 52 | }) 53 | .add({ 54 | duration: 800, 55 | rotateY: '-15deg', 56 | rotateX: '8deg', 57 | rotateZ: '-1deg', 58 | }); 59 | 60 | anime 61 | .timeline({ 62 | targets: '.hero-figure-box-06, .hero-figure-box-07', 63 | }) 64 | .add({ 65 | duration: 400, 66 | easing: 'easeInOutExpo', 67 | scaleX: [0.05, 0.05], 68 | scaleY: [0, 1], 69 | perspective: '500px', 70 | delay: anime.random(0, 400), 71 | }) 72 | .add({ 73 | duration: 400, 74 | easing: 'easeInOutExpo', 75 | scaleX: 1, 76 | }) 77 | .add({ 78 | duration: 800, 79 | rotateZ: '20deg', 80 | }); 81 | 82 | anime({ 83 | targets: '.hero-figure-box-01, .hero-figure-box-02, .hero-figure-box-03, .hero-figure-box-04, .hero-figure-box-08, .hero-figure-box-09, .hero-figure-box-10', 84 | duration: anime.random(600, 800), 85 | delay: anime.random(600, 800), 86 | rotate: [anime.random(-360, 360), el => el.getAttribute('data-rotation')], 87 | scale: [0.7, 1], 88 | opacity: [0, 1], 89 | easing: 'easeInOutExpo', 90 | }); 91 | } 92 | }, []); 93 | 94 | return ( 95 | <> 96 | 97 |
98 |
99 |
100 |
101 |
102 |
103 |

104 | 105 | 106 | 107 |

108 |
109 |
110 |
111 |
112 | 113 |
114 |
115 |
116 |
117 |
118 |

Find some inspiration!

119 |

120 | Like the saying goes, you need to know where you've been to know where you're going. If you find yourself tired of riding the same old routes or running the 121 | same old path, don't worry, you're not alone. 122 |
123 |
124 | Login with the button right below to visualize where you tend to go and let it inspire you to go somewhere new! 125 |

126 | 127 |
128 | 129 | 130 | 131 |
132 |
133 |
134 | 135 | 136 | 137 |
138 |
139 |
140 |
141 |
142 | 143 |
144 |
145 | 146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 | 156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 | Feature 01 164 |
165 |

Find some inspiration

166 |

Tired of riding in the same three old routes? Check your ride history and let it inspire you to try new routes!

167 |
168 |
169 |
170 |
171 |
172 | Feature 02 173 |
174 |

Visualize your activities

175 |

A powerful visualization tool to analyze your past rides, runs, walks and hikes. Why? Because everyone loves data!

176 |
177 |
178 |
179 |
180 |
181 | Feature 03 182 |
183 |

Compare your seasons

184 |

Want to see if your riding habit has changed between years? You can filter your rides by seasons!

185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 | 193 |
194 |
195 |
196 |