├── .gitignore ├── .new-component-config.json ├── LICENSE.md ├── README.md ├── docs ├── ex1-solution.gif ├── ex2-solution.gif ├── ex3-solution.gif └── original-desktop.png ├── package-lock.json ├── package.json ├── public ├── assets │ ├── flyknit.jpg │ ├── joyride.jpg │ ├── lebron.jpg │ ├── legend-academy.jpg │ ├── metcon-5.jpg │ ├── pegasus.jpg │ ├── phantom-flyknit.jpg │ ├── phantom.jpg │ ├── react-infinity.jpg │ ├── react-vision.jpg │ ├── stefan-janoski.jpg │ └── tech-challenge.jpg ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt └── src ├── components ├── App │ ├── App.js │ └── index.js ├── Breadcrumbs │ ├── Breadcrumbs.js │ └── index.js ├── GlobalStyles │ ├── GlobalStyles.js │ └── index.js ├── Header │ ├── Header.js │ └── index.js ├── Icon │ ├── Icon.js │ └── index.js ├── Logo │ ├── Logo.js │ └── index.js ├── MobileMenu │ ├── MobileMenu.js │ └── index.js ├── SearchInput │ ├── SearchInput.js │ └── index.js ├── Select │ ├── Select.js │ └── index.js ├── ShoeCard │ ├── ShoeCard.js │ └── index.js ├── ShoeGrid │ ├── ShoeGrid.js │ └── index.js ├── ShoeIndex │ ├── ShoeIndex.js │ └── index.js ├── ShoeSidebar │ ├── ShoeSidebar.js │ └── index.js ├── Spacer │ ├── Spacer.js │ └── index.js ├── SuperHeader │ ├── SuperHeader.js │ └── index.js ├── UnstyledButton │ ├── UnstyledButton.js │ └── index.js └── VisuallyHidden │ ├── VisuallyHidden.js │ └── index.js ├── constants.js ├── data.js ├── index.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .eslintcache 26 | -------------------------------------------------------------------------------- /.new-component-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "functional", 3 | "prettierConfig": { 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "es5" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Josh's Course Materials License 2 | 3 | Version 1, November 2020 4 | Copyright (c) Josh Comeau, 2020 5 | 6 | The files in this repository are meant to be used as part of a paid course, and are not intended for public distribution. They're open-source because it's the simplest form of distribution, and provides the best experience for students enrolled in the course. 7 | 8 | All are welcome to create personal copies of this repository, and modify its contents for educational use. Please experiment with the code, and see what you can build! 9 | 10 | It is forbidden to use these contents in any sort of commercial endeavour, including but not limited to: 11 | 12 | • Reselling its contents as part of a different course 13 | • Incorporating the code into a pre-existing business or project 14 | • Selling your solution to students enrolled in the course 15 | 16 | Exemptions can be made, on a case-by-case basis. Contact Josh Comeau (me@joshwcomeau.com) for more information. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sole&Ankle, Animated — Module 8 workshop 2 | 3 | Once again, we're working on the sneaker store! 4 | 5 | ![A screenshot of the original Flexbox module workshop](./docs/original-desktop.png) 6 | 7 | In this workshop, we're going to use our newly-acquired animation skills to breathe some life into this application. 8 | 9 | **Some parts of this workshop are unguided.** Each exercise will challenge you to go beyond the stated goal, to come up with your own twist on the interaction. 10 | 11 | Also, **don't forget about accessibility.** Significant motion should be disabled by default, and only enabled based on the `prefers-reduced-motion` media query. 12 | 13 | ## Troubleshooting 14 | 15 | If you run into problems running a local development server, check out our [Troubleshooting Guide](https://courses.joshwcomeau.com/troubleshooting) on the course platform. 16 | 17 | This guide addresses the common `Digital Envelope Routine` error you may have seen. 18 | 19 | --- 20 | 21 | ## Exercise 1: Sneaker Zoom 22 | 23 | Add a hover/focus interaction to the sneakers so that the image zooms in slightly: 24 | 25 | ![Exercise 1 solution](./docs/ex1-solution.gif) 26 | 27 | This might seem like a small task, but there are lots of little details that make it tricky. Pay close attention to the GIF. Some things to watch out for: 28 | 29 | - The enter transition should be faster than the exit transition 30 | - The "flags" for new releases and sales should hang over the edge of the photo, as they do initially. 31 | - The corners should remain perfectly round at all times. 32 | - The shoes aren't centered within the photos, so if you zoom into the center of the photo, the shoe will appear to drop lower. Tweak the animation so that it zooms in on the shoe. 33 | 34 | The relevant component is `ShoeCard.js`. 35 | 36 | ### Stretch Goal 37 | 38 | Once you've matched the GIF above, it's time to get creative. Change or extend the animation. Experiment with different techniques and properties! 39 | 40 | Here are some ideas: 41 | 42 | - In addition to the photo zoom, tweak the new/sale flags in some way. 43 | - Use a CSS filter on the photo. 44 | 45 | --- 46 | 47 | ## Exercise 2: Navigation link flip-up 48 | 49 | When hovering over the navigation links on desktop, they should "flip up", revealing a bold copy underneath: 50 | 51 | ![Exercise 2 solution](./docs/ex2-solution.gif) 52 | 53 | In order to accomplish this challenge, **you'll need to tweak the JSX.** There's no way to solve this problem in CSS alone. In particular, you'll need to duplicate the text inside each navigation link. 54 | 55 | The relevant component is `Header.js`. You may wish to create a new `NavLink` component, though it isn't required. 56 | 57 | ### Stretch Goal 58 | 59 | Here's a list of over a dozen link hover animations: https://tympanus.net/Development/CreativeLinkEffects/ 60 | 61 | Try and implement another effect from the list! 62 | 63 | --- 64 | 65 | ## Exercise 3: Modal enter animation 66 | 67 | On mobile, add the following animations to the hamburger menu: 68 | 69 | ![Exercise 3 solution](./docs/ex3-solution.gif) 70 | 71 | This effect consists of 3 individual animations: 72 | 73 | 1. The backdrop fades in. 74 | 2. The drawer slides in from the right. 75 | 3. The drawer's contents fade in. 76 | 77 | For bonus points, use a custom easing curve on the slide-in animation. You can configure one using this tool: https://cubic-bezier.com. 78 | 79 | Don't worry about the exit animation; exit animations are difficult in React, and require a library like [React transition group](https://reactcommunity.org/react-transition-group/). 80 | 81 | The relevant component is `MobileMenu.js`. 82 | 83 | ### Stretch Goal 84 | 85 | Here are some ideas: 86 | 87 | - Experiment with different orchestrations, animating different elements at different times 88 | - Use a 3D transform on the drawer so that it swings in like a door closing rather than sliding in from offscreen 89 | - Instead of fading in all of the drawer's contents at once, add a staggered fade to the individual navigation links so that they fade in one by one, from the top down 90 | -------------------------------------------------------------------------------- /docs/ex1-solution.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/docs/ex1-solution.gif -------------------------------------------------------------------------------- /docs/ex2-solution.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/docs/ex2-solution.gif -------------------------------------------------------------------------------- /docs/ex3-solution.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/docs/ex3-solution.gif -------------------------------------------------------------------------------- /docs/original-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/docs/original-desktop.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sole-and-ankle-animated", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@reach/dialog": "0.15.0", 7 | "date-fns": "2.16.1", 8 | "react": "17.0.1", 9 | "react-dom": "17.0.1", 10 | "react-feather": "2.0.9", 11 | "react-scripts": "4.0.1", 12 | "styled-components": "5.2.1" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": [ 22 | "react-app", 23 | "react-app/jest" 24 | ] 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/assets/flyknit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/assets/flyknit.jpg -------------------------------------------------------------------------------- /public/assets/joyride.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/assets/joyride.jpg -------------------------------------------------------------------------------- /public/assets/lebron.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/assets/lebron.jpg -------------------------------------------------------------------------------- /public/assets/legend-academy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/assets/legend-academy.jpg -------------------------------------------------------------------------------- /public/assets/metcon-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/assets/metcon-5.jpg -------------------------------------------------------------------------------- /public/assets/pegasus.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/assets/pegasus.jpg -------------------------------------------------------------------------------- /public/assets/phantom-flyknit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/assets/phantom-flyknit.jpg -------------------------------------------------------------------------------- /public/assets/phantom.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/assets/phantom.jpg -------------------------------------------------------------------------------- /public/assets/react-infinity.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/assets/react-infinity.jpg -------------------------------------------------------------------------------- /public/assets/react-vision.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/assets/react-vision.jpg -------------------------------------------------------------------------------- /public/assets/stefan-janoski.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/assets/stefan-janoski.jpg -------------------------------------------------------------------------------- /public/assets/tech-challenge.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/assets/tech-challenge.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 17 | 18 | 19 | 23 | 24 | Sole&Ankle — CSS for JavaScript Developers 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/css-for-js/sole-and-ankle-animated/d0debedce9c4101b6f32955e54dda1ca3d1d706e/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/components/App/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | import { QUERIES } from '../../constants'; 4 | 5 | import Header from '../Header'; 6 | import ShoeIndex from '../ShoeIndex'; 7 | 8 | const App = () => { 9 | const [sortId, setSortId] = React.useState('newest'); 10 | 11 | return ( 12 | <> 13 |
14 |
15 | 16 |
17 | 18 | ); 19 | }; 20 | 21 | const Main = styled.main` 22 | padding: 64px 32px; 23 | 24 | @media ${QUERIES.tabletAndSmaller} { 25 | padding: 48px 32px; 26 | } 27 | @media ${QUERIES.phoneAndSmaller} { 28 | padding: 48px 16px; 29 | } 30 | `; 31 | 32 | export default App; 33 | -------------------------------------------------------------------------------- /src/components/App/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './App'; 2 | -------------------------------------------------------------------------------- /src/components/Breadcrumbs/Breadcrumbs.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | 4 | const Breadcrumbs = ({ children }) => { 5 | return {children}; 6 | }; 7 | 8 | Breadcrumbs.Crumb = ({ href, children, delegated }) => { 9 | return ( 10 | 11 | 12 | {children} 13 | 14 | 15 | ); 16 | }; 17 | 18 | const CrumbWrapper = styled.div` 19 | &:not(:first-of-type) { 20 | margin-left: 8px; 21 | 22 | &::before { 23 | content: '/'; 24 | margin-right: 8px; 25 | color: var(--color-gray-300); 26 | } 27 | } 28 | `; 29 | 30 | const CrumbLink = styled.a` 31 | color: var(--color-gray-700); 32 | text-decoration: none; 33 | 34 | &:hover { 35 | color: var(--color-gray-900); 36 | } 37 | `; 38 | 39 | const Wrapper = styled.nav` 40 | display: flex; 41 | font-size: 0.875rem; 42 | `; 43 | export default Breadcrumbs; 44 | -------------------------------------------------------------------------------- /src/components/Breadcrumbs/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Breadcrumbs'; 2 | -------------------------------------------------------------------------------- /src/components/GlobalStyles/GlobalStyles.js: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components/macro'; 2 | import { COLORS } from '../../constants'; 3 | 4 | const GlobalStyles = createGlobalStyle` 5 | /* http://meyerweb.com/eric/tools/css/reset/ 6 | v2.0 | 20110126 7 | License: none (public domain) 8 | */ 9 | html, body, div, span, applet, object, iframe, 10 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 11 | a, abbr, acronym, address, big, cite, code, 12 | del, dfn, em, img, ins, kbd, q, s, samp, 13 | small, strike, strong, sub, sup, tt, var, 14 | b, u, i, center, 15 | dl, dt, dd, ol, ul, li, 16 | fieldset, form, label, legend, 17 | table, caption, tbody, tfoot, thead, tr, th, td, 18 | article, aside, canvas, details, embed, 19 | figure, figcaption, footer, header, hgroup, 20 | menu, nav, output, ruby, section, summary, 21 | time, mark, audio, video { 22 | margin: 0; 23 | padding: 0; 24 | border: 0; 25 | font-size: 100%; 26 | vertical-align: baseline; 27 | } 28 | /* HTML5 display-role reset for older browsers */ 29 | article, aside, details, figcaption, figure, 30 | footer, header, hgroup, menu, nav, section { 31 | display: block; 32 | } 33 | ol, ul { 34 | list-style: none; 35 | } 36 | blockquote, q { 37 | quotes: none; 38 | } 39 | blockquote:before, blockquote:after, 40 | q:before, q:after { 41 | content: ''; 42 | content: none; 43 | } 44 | table { 45 | border-collapse: collapse; 46 | border-spacing: 0; 47 | } 48 | 49 | 50 | /* GLOBAL STYLES */ 51 | *, 52 | *:before, 53 | *:after { 54 | box-sizing: border-box; 55 | line-height: 1.5; 56 | font-family: 'Raleway', sans-serif; 57 | -webkit-font-smoothing: antialiased; 58 | -moz-osx-font-smoothing: auto; 59 | } 60 | 61 | #root { 62 | /* 63 | Create a stacking context, without a z-index. 64 | This ensures that all portal content (modals and tooltips) will 65 | float above the app. 66 | */ 67 | isolation: isolate; 68 | } 69 | 70 | html { 71 | --color-white: hsl(${COLORS.white}); 72 | --color-primary: hsl(${COLORS.primary}); 73 | --color-secondary: hsl(${COLORS.secondary}); 74 | --color-gray-100: hsl(${COLORS.gray[100]}); 75 | --color-gray-300: hsl(${COLORS.gray[300]}); 76 | --color-gray-500: hsl(${COLORS.gray[500]}); 77 | --color-gray-700: hsl(${COLORS.gray[700]}); 78 | --color-gray-900: hsl(${COLORS.gray[900]}); 79 | 80 | --color-backdrop: hsl(${COLORS.gray[700]} / 0.8); 81 | 82 | /* 83 | Silence the warning about missing Reach Dialog styles 84 | */ 85 | --reach-dialog: 1; 86 | } 87 | 88 | html, body, #root { 89 | height: 100%; 90 | } 91 | `; 92 | 93 | export default GlobalStyles; 94 | -------------------------------------------------------------------------------- /src/components/GlobalStyles/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './GlobalStyles'; 2 | -------------------------------------------------------------------------------- /src/components/Header/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | 4 | import { QUERIES, WEIGHTS } from '../../constants'; 5 | import Logo from '../Logo'; 6 | import Icon from '../Icon'; 7 | import UnstyledButton from '../UnstyledButton'; 8 | import SuperHeader from '../SuperHeader'; 9 | import MobileMenu from '../MobileMenu'; 10 | import VisuallyHidden from '../VisuallyHidden'; 11 | 12 | const Header = () => { 13 | const [showMobileMenu, setShowMobileMenu] = React.useState(false); 14 | 15 | return ( 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | Sale 24 | New Releases 25 | Men 26 | Women 27 | Kids 28 | Collections 29 | 30 | 31 | 32 | 33 | Open cart 34 | 35 | 36 | 37 | Search 38 | 39 | setShowMobileMenu(true)}> 40 | 41 | Open menu 42 | 43 | 44 | 45 | 46 | 47 | setShowMobileMenu(false)} 50 | /> 51 |
52 | ); 53 | }; 54 | 55 | const MainHeader = styled.div` 56 | display: flex; 57 | align-items: baseline; 58 | padding: 18px 32px; 59 | border-bottom: 1px solid var(--color-gray-300); 60 | overflow: auto; 61 | 62 | @media ${QUERIES.tabletAndSmaller} { 63 | justify-content: space-between; 64 | align-items: center; 65 | border-top: 4px solid var(--color-gray-900); 66 | } 67 | 68 | @media ${QUERIES.phoneAndSmaller} { 69 | padding-left: 16px; 70 | padding-right: 16px; 71 | } 72 | `; 73 | 74 | const DesktopNav = styled.nav` 75 | display: flex; 76 | gap: clamp(1rem, 9.2vw - 4.5rem, 3.5rem); 77 | margin: 0px 48px; 78 | 79 | @media ${QUERIES.tabletAndSmaller} { 80 | display: none; 81 | } 82 | `; 83 | 84 | const MobileActions = styled.div` 85 | display: none; 86 | 87 | @media ${QUERIES.tabletAndSmaller} { 88 | gap: 32px; 89 | display: flex; 90 | } 91 | 92 | @media ${QUERIES.phoneAndSmaller} { 93 | gap: 16px; 94 | } 95 | `; 96 | 97 | const LogoWrapper = styled.div` 98 | flex: 1; 99 | 100 | @media ${QUERIES.tabletAndSmaller} { 101 | flex: revert; 102 | } 103 | `; 104 | 105 | const ShoppingBagButton = styled(UnstyledButton)` 106 | transform: translateX(-2px); 107 | `; 108 | 109 | const Filler = styled.div` 110 | flex: 1; 111 | 112 | @media ${QUERIES.tabletAndSmaller} { 113 | display: none; 114 | } 115 | `; 116 | 117 | const NavLink = styled.a` 118 | font-size: 1.125rem; 119 | text-transform: uppercase; 120 | text-decoration: none; 121 | color: var(--color-gray-900); 122 | font-weight: ${WEIGHTS.medium}; 123 | 124 | &:first-of-type { 125 | color: var(--color-secondary); 126 | } 127 | `; 128 | 129 | export default Header; 130 | -------------------------------------------------------------------------------- /src/components/Header/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Header'; 2 | -------------------------------------------------------------------------------- /src/components/Icon/Icon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | import { 4 | Search, 5 | Menu, 6 | ShoppingBag, 7 | ChevronDown, 8 | X, 9 | } from 'react-feather'; 10 | 11 | const icons = { 12 | search: Search, 13 | menu: Menu, 14 | 'shopping-bag': ShoppingBag, 15 | 'chevron-down': ChevronDown, 16 | close: X, 17 | }; 18 | 19 | const Icon = ({ id, color, size, strokeWidth, ...delegated }) => { 20 | const Component = icons[id]; 21 | 22 | if (!Component) { 23 | throw new Error(`No icon found for ID: ${id}`); 24 | } 25 | 26 | return ( 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | const Wrapper = styled.div` 34 | & > svg { 35 | display: block; 36 | stroke-width: ${(p) => 37 | p.strokeWidth !== undefined ? p.strokeWidth + 'px' : undefined}; 38 | } 39 | `; 40 | 41 | export default Icon; 42 | -------------------------------------------------------------------------------- /src/components/Icon/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Icon'; 2 | -------------------------------------------------------------------------------- /src/components/Logo/Logo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | import { WEIGHTS } from '../../constants'; 4 | 5 | const Logo = (props) => { 6 | return ( 7 | 8 | Sole&Ankle 9 | 10 | ); 11 | }; 12 | 13 | const Link = styled.a` 14 | text-decoration: none; 15 | color: inherit; 16 | `; 17 | 18 | const Wrapper = styled.h1` 19 | font-size: 1.5rem; 20 | font-weight: ${WEIGHTS.bold}; 21 | `; 22 | 23 | export default Logo; 24 | -------------------------------------------------------------------------------- /src/components/Logo/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Logo'; 2 | -------------------------------------------------------------------------------- /src/components/MobileMenu/MobileMenu.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import React from 'react'; 3 | import styled from 'styled-components/macro'; 4 | import { DialogOverlay, DialogContent } from '@reach/dialog'; 5 | 6 | import { QUERIES, WEIGHTS } from '../../constants'; 7 | 8 | import UnstyledButton from '../UnstyledButton'; 9 | import Icon from '../Icon'; 10 | import VisuallyHidden from '../VisuallyHidden'; 11 | 12 | const MobileMenu = ({ isOpen, onDismiss }) => { 13 | return ( 14 | 15 | 16 | 17 | 18 | Dismiss menu 19 | 20 | 21 | 29 |
30 | Terms and Conditions 31 | Privacy Policy 32 | Contact Us 33 |
34 |
35 |
36 | ); 37 | }; 38 | 39 | const Overlay = styled(DialogOverlay)` 40 | position: fixed; 41 | top: 0; 42 | left: 0; 43 | right: 0; 44 | bottom: 0; 45 | background: var(--color-backdrop); 46 | display: flex; 47 | justify-content: flex-end; 48 | `; 49 | 50 | const Content = styled(DialogContent)` 51 | background: white; 52 | width: 300px; 53 | height: 100%; 54 | padding: 24px 32px; 55 | display: flex; 56 | flex-direction: column; 57 | `; 58 | 59 | const CloseButton = styled(UnstyledButton)` 60 | position: absolute; 61 | top: 10px; 62 | right: 0; 63 | padding: 16px; 64 | `; 65 | 66 | const Nav = styled.nav` 67 | display: flex; 68 | flex-direction: column; 69 | gap: 16px; 70 | `; 71 | 72 | const NavLink = styled.a` 73 | color: var(--color-gray-900); 74 | font-weight: ${WEIGHTS.medium}; 75 | text-decoration: none; 76 | font-size: 1.125rem; 77 | text-transform: uppercase; 78 | 79 | &:first-of-type { 80 | color: var(--color-secondary); 81 | } 82 | `; 83 | 84 | const Filler = styled.div` 85 | flex: 1; 86 | `; 87 | const Footer = styled.footer` 88 | flex: 1; 89 | display: flex; 90 | flex-direction: column; 91 | gap: 14px; 92 | justify-content: flex-end; 93 | `; 94 | 95 | const SubLink = styled.a` 96 | color: var(--color-gray-700); 97 | font-size: 0.875rem; 98 | text-decoration: none; 99 | `; 100 | 101 | export default MobileMenu; 102 | -------------------------------------------------------------------------------- /src/components/MobileMenu/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './MobileMenu'; 2 | -------------------------------------------------------------------------------- /src/components/SearchInput/SearchInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | 4 | import VisuallyHidden from '../VisuallyHidden'; 5 | import Icon from '../Icon'; 6 | 7 | const SearchInput = ({ label, ...delegated }) => { 8 | return ( 9 | 14 | ); 15 | }; 16 | 17 | const Label = styled.label` 18 | position: relative; 19 | `; 20 | 21 | const Input = styled.input` 22 | border: none; 23 | background: transparent; 24 | border-bottom: 1px solid var(--color-gray-300); 25 | padding-left: 24px; 26 | font-size: 0.875rem; 27 | color: var(--color-gray-100); 28 | outline-offset: 4px; 29 | 30 | &::placeholder { 31 | color: var(--color-gray-500); 32 | } 33 | `; 34 | 35 | const SearchIcon = styled(Icon)` 36 | position: absolute; 37 | top: 0; 38 | left: 0; 39 | bottom: 0; 40 | margin: auto; 41 | width: 16px; 42 | height: 16px; 43 | `; 44 | 45 | export default SearchInput; 46 | -------------------------------------------------------------------------------- /src/components/SearchInput/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './SearchInput'; 2 | -------------------------------------------------------------------------------- /src/components/Select/Select.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | 4 | import { WEIGHTS } from '../../constants'; 5 | 6 | import Icon from '../Icon'; 7 | 8 | const Select = ({ label, value, children, ...delegated }) => { 9 | const childArray = React.Children.toArray(children); 10 | const selectedChild = childArray.find( 11 | (child) => child.props.value === value 12 | ); 13 | 14 | const displayedValue = selectedChild.props.children; 15 | 16 | return ( 17 | 18 | {label} 19 | 20 | 21 | {children} 22 | 23 | 24 | {displayedValue} 25 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | const Wrapper = styled.label` 37 | display: flex; 38 | align-items: baseline; 39 | `; 40 | 41 | const VisibleLabel = styled.span` 42 | color: var(--color-gray-700); 43 | margin-right: 16px; 44 | `; 45 | 46 | const SelectWrapper = styled.div` 47 | position: relative; 48 | `; 49 | 50 | const NativeSelect = styled.select` 51 | opacity: 0; 52 | position: absolute; 53 | top: 0; 54 | left: 0; 55 | right: 0; 56 | bottom: 0; 57 | width: 100%; 58 | height: 100%; 59 | cursor: pointer; 60 | `; 61 | 62 | const DisplayedBit = styled.span` 63 | display: block; 64 | background: var(--color-gray-100); 65 | font-size: 1rem; 66 | font-weight: ${WEIGHTS.medium}; 67 | color: var(--color-gray-900); 68 | padding: 12px 42px 12px 16px; 69 | border-radius: 8px; 70 | pointer-events: none; 71 | 72 | ${NativeSelect}:focus ~ & { 73 | outline: 1px dotted #212121; 74 | outline: 5px auto -webkit-focus-ring-color; 75 | } 76 | `; 77 | 78 | const ChevronIcon = styled(Icon)` 79 | position: absolute; 80 | top: 0; 81 | right: 9px; 82 | bottom: 0; 83 | margin: auto; 84 | width: 24px; 85 | height: 24px; 86 | `; 87 | 88 | export default Select; 89 | -------------------------------------------------------------------------------- /src/components/Select/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Select'; 2 | -------------------------------------------------------------------------------- /src/components/ShoeCard/ShoeCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | 4 | import { WEIGHTS } from '../../constants'; 5 | import { formatPrice, pluralize, isNewShoe } from '../../utils'; 6 | import Spacer from '../Spacer'; 7 | 8 | const ShoeCard = ({ 9 | slug, 10 | name, 11 | imageSrc, 12 | price, 13 | salePrice, 14 | releaseDate, 15 | numOfColors, 16 | }) => { 17 | // There are 3 variants possible, based on the props: 18 | // - new-release 19 | // - on-sale 20 | // - default 21 | // 22 | // Any shoe released in the last month will be considered 23 | // `new-release`. Any shoe with a `salePrice` will be 24 | // on-sale. In theory, it is possible for a shoe to be 25 | // both on-sale and new-release, but in this case, `on-sale` 26 | // will triumph and be the variant used. 27 | // prettier-ignore 28 | const variant = typeof salePrice === 'number' 29 | ? 'on-sale' 30 | : isNewShoe(releaseDate) 31 | ? 'new-release' 32 | : 'default' 33 | 34 | return ( 35 | 36 | 37 | 38 | 39 | {variant === 'on-sale' && Sale} 40 | {variant === 'new-release' && ( 41 | Just released! 42 | )} 43 | 44 | 45 | 46 | {name} 47 | 57 | {formatPrice(price)} 58 | 59 | 60 | 61 | {pluralize('Color', numOfColors)} 62 | {variant === 'on-sale' ? ( 63 | {formatPrice(salePrice)} 64 | ) : undefined} 65 | 66 | 67 | 68 | ); 69 | }; 70 | 71 | const Link = styled.a` 72 | text-decoration: none; 73 | color: inherit; 74 | `; 75 | 76 | const Wrapper = styled.article``; 77 | 78 | const ImageWrapper = styled.div` 79 | position: relative; 80 | `; 81 | 82 | const Image = styled.img` 83 | width: 100%; 84 | border-radius: 16px 16px 4px 4px; 85 | `; 86 | 87 | const Row = styled.div` 88 | font-size: 1rem; 89 | display: flex; 90 | justify-content: space-between; 91 | `; 92 | 93 | const Name = styled.h3` 94 | font-weight: ${WEIGHTS.medium}; 95 | color: var(--color-gray-900); 96 | `; 97 | 98 | const Price = styled.span` 99 | color: var(--color); 100 | text-decoration: var(--text-decoration); 101 | `; 102 | 103 | const ColorInfo = styled.p` 104 | color: var(--color-gray-700); 105 | `; 106 | 107 | const SalePrice = styled.span` 108 | font-weight: ${WEIGHTS.medium}; 109 | color: var(--color-primary); 110 | `; 111 | 112 | const Flag = styled.div` 113 | position: absolute; 114 | top: 12px; 115 | right: -4px; 116 | background: red; 117 | height: 32px; 118 | line-height: 32px; 119 | padding: 0 10px; 120 | font-size: ${14 / 18}rem; 121 | font-weight: ${WEIGHTS.bold}; 122 | color: var(--color-white); 123 | border-radius: 2px; 124 | `; 125 | 126 | const SaleFlag = styled(Flag)` 127 | background-color: var(--color-primary); 128 | `; 129 | const NewFlag = styled(Flag)` 130 | background-color: var(--color-secondary); 131 | `; 132 | 133 | export default ShoeCard; 134 | -------------------------------------------------------------------------------- /src/components/ShoeCard/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ShoeCard'; 2 | -------------------------------------------------------------------------------- /src/components/ShoeGrid/ShoeGrid.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | 4 | import SHOES from '../../data'; 5 | import ShoeCard from '../ShoeCard'; 6 | 7 | const ShoeGrid = () => { 8 | return ( 9 | 10 | {SHOES.map((shoe) => ( 11 | 12 | 13 | 14 | ))} 15 | 16 | ); 17 | }; 18 | 19 | const Wrapper = styled.div` 20 | display: flex; 21 | flex-wrap: wrap; 22 | gap: 32px; 23 | /* 24 | Alternatively, if we can't use 'gap', we could set: 25 | 26 | margin: -16px; 27 | 28 | We'd also need to set this on the ShoeWrapper: 29 | 30 | margin: 16px; 31 | */ 32 | `; 33 | 34 | const ShoeWrapper = styled.div` 35 | min-width: 275px; 36 | flex: 1; 37 | `; 38 | 39 | export default ShoeGrid; 40 | -------------------------------------------------------------------------------- /src/components/ShoeGrid/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ShoeGrid'; 2 | -------------------------------------------------------------------------------- /src/components/ShoeIndex/ShoeIndex.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | 4 | import { QUERIES, WEIGHTS } from '../../constants'; 5 | 6 | import Breadcrumbs from '../Breadcrumbs'; 7 | import Select from '../Select'; 8 | import Spacer from '../Spacer'; 9 | import ShoeSidebar from '../ShoeSidebar'; 10 | import ShoeGrid from '../ShoeGrid'; 11 | 12 | const ShoeBreadcrumbs = () => { 13 | return ( 14 | 15 | Home 16 | Sale 17 | Shoes 18 | 19 | ); 20 | }; 21 | 22 | const ShoeIndex = ({ sortId, setSortId }) => { 23 | return ( 24 | 25 | 26 |
27 |
28 | 29 | 30 | 31 | Running 32 |
33 | 34 | 35 | 43 | 44 |
45 | 46 | 47 |
48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
56 | ); 57 | }; 58 | 59 | const Wrapper = styled.div` 60 | display: flex; 61 | flex-direction: row-reverse; 62 | align-items: baseline; 63 | gap: 32px; 64 | `; 65 | 66 | const LeftColumn = styled.div` 67 | flex-basis: 248px; 68 | 69 | @media ${QUERIES.tabletAndSmaller} { 70 | display: none; 71 | } 72 | `; 73 | 74 | const MainColumn = styled.div` 75 | flex: 1; 76 | `; 77 | 78 | const Header = styled.header` 79 | display: flex; 80 | justify-content: space-between; 81 | align-items: baseline; 82 | 83 | @media ${QUERIES.tabletAndSmaller} { 84 | align-items: flex-end; 85 | } 86 | `; 87 | 88 | const Title = styled.h2` 89 | font-size: 1.5rem; 90 | font-weight: ${WEIGHTS.medium}; 91 | `; 92 | 93 | const DesktopBreadcrumbs = styled.div` 94 | @media ${QUERIES.tabletAndSmaller} { 95 | display: none; 96 | } 97 | `; 98 | const MobileBreadcrumbs = styled.div` 99 | display: none; 100 | 101 | @media ${QUERIES.tabletAndSmaller} { 102 | display: revert; 103 | } 104 | `; 105 | 106 | const SortFilterWrapper = styled.div` 107 | @media ${QUERIES.phoneAndSmaller} { 108 | display: none; 109 | } 110 | `; 111 | 112 | export default ShoeIndex; 113 | -------------------------------------------------------------------------------- /src/components/ShoeIndex/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ShoeIndex'; 2 | -------------------------------------------------------------------------------- /src/components/ShoeSidebar/ShoeSidebar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | 4 | import { WEIGHTS } from '../../constants'; 5 | 6 | const Sidebar = () => { 7 | return ( 8 | 9 | Lifestyle 10 | Jordan 11 | Running 12 | Basketball 13 | Training & Gym 14 | Football 15 | Skateboarding 16 | American Football 17 | Baseball 18 | Golf 19 | Tennis 20 | Athletics 21 | Walking 22 | 23 | ); 24 | }; 25 | 26 | const Wrapper = styled.aside``; 27 | 28 | const Link = styled.a` 29 | display: block; 30 | text-decoration: none; 31 | font-weight: ${WEIGHTS.medium}; 32 | color: var(--color-gray-900); 33 | line-height: 2; 34 | `; 35 | 36 | const ActiveLink = styled(Link)` 37 | color: var(--color-primary); 38 | `; 39 | 40 | export default Sidebar; 41 | -------------------------------------------------------------------------------- /src/components/ShoeSidebar/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ShoeSidebar'; 2 | -------------------------------------------------------------------------------- /src/components/Spacer/Spacer.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/macro'; 2 | 3 | function getHeight({ axis, size }) { 4 | return axis === 'horizontal' ? 1 : size; 5 | } 6 | function getWidth({ axis, size }) { 7 | return axis === 'vertical' ? 1 : size; 8 | } 9 | 10 | const Spacer = styled.span` 11 | display: block; 12 | width: ${getWidth}px; 13 | min-width: ${getWidth}px; 14 | height: ${getHeight}px; 15 | min-height: ${getHeight}px; 16 | `; 17 | 18 | export default Spacer; 19 | -------------------------------------------------------------------------------- /src/components/Spacer/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Spacer'; 2 | -------------------------------------------------------------------------------- /src/components/SuperHeader/SuperHeader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | 4 | import { QUERIES } from '../../constants'; 5 | 6 | import SearchInput from '../SearchInput'; 7 | import UnstyledButton from '../UnstyledButton'; 8 | import Icon from '../Icon'; 9 | 10 | const SuperHeader = () => { 11 | return ( 12 | 13 | 14 | Free shipping on domestic orders over $75! 15 | 16 | 17 | Help 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | const Wrapper = styled.div` 26 | display: flex; 27 | align-items: center; 28 | gap: 24px; 29 | font-size: 0.875rem; 30 | color: var(--color-gray-300); 31 | background-color: var(--color-gray-900); 32 | height: 40px; 33 | padding-left: 32px; 34 | padding-right: 32px; 35 | 36 | @media ${QUERIES.tabletAndSmaller} { 37 | display: none; 38 | } 39 | `; 40 | 41 | const MarketingMessage = styled.span` 42 | color: var(--color-white); 43 | margin-right: auto; 44 | `; 45 | 46 | const HelpLink = styled.a` 47 | color: inherit; 48 | text-decoration: none; 49 | outline-offset: 2px; 50 | 51 | &:not(:focus-visible) { 52 | outline: none; 53 | } 54 | `; 55 | 56 | export default SuperHeader; 57 | -------------------------------------------------------------------------------- /src/components/SuperHeader/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './SuperHeader'; 2 | -------------------------------------------------------------------------------- /src/components/UnstyledButton/UnstyledButton.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/macro'; 2 | 3 | export default styled.button` 4 | display: ${(props) => props.display || 'block'}; 5 | margin: 0; 6 | padding: 0; 7 | border: none; 8 | background: transparent; 9 | cursor: pointer; 10 | text-align: left; 11 | font: inherit; 12 | color: inherit; 13 | 14 | &:focus { 15 | outline-offset: 2px; 16 | } 17 | 18 | &:focus:not(:focus-visible) { 19 | outline: none; 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /src/components/UnstyledButton/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './UnstyledButton'; 2 | -------------------------------------------------------------------------------- /src/components/VisuallyHidden/VisuallyHidden.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | 4 | const VisuallyHidden = ({ children, ...delegated }) => { 5 | const [forceShow, setForceShow] = React.useState(false); 6 | 7 | React.useEffect(() => { 8 | if (process.env.NODE_ENV !== 'production') { 9 | const handleKeyDown = (ev) => { 10 | if (ev.key === 'Alt') { 11 | setForceShow(true); 12 | } 13 | }; 14 | 15 | const handleKeyUp = () => { 16 | setForceShow(false); 17 | }; 18 | 19 | window.addEventListener('keydown', handleKeyDown); 20 | window.addEventListener('keyup', handleKeyUp); 21 | 22 | return () => { 23 | window.removeEventListener('keydown', handleKeyDown); 24 | window.removeEventListener('keyup', handleKeyUp); 25 | }; 26 | } 27 | }, []); 28 | 29 | if (forceShow) { 30 | return children; 31 | } 32 | 33 | return {children}; 34 | }; 35 | 36 | const Wrapper = styled.div` 37 | position: absolute; 38 | overflow: hidden; 39 | clip: rect(0 0 0 0); 40 | height: 1px; 41 | width: 1px; 42 | margin: -1px; 43 | padding: 0; 44 | border: 0; 45 | `; 46 | 47 | export default VisuallyHidden; 48 | -------------------------------------------------------------------------------- /src/components/VisuallyHidden/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './VisuallyHidden'; 2 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const COLORS = { 2 | white: '0deg 0% 100%', 3 | gray: { 4 | 100: '185deg 5% 95%', 5 | 300: '190deg 5% 80%', 6 | 500: '196deg 4% 60%', 7 | 700: '220deg 5% 40%', 8 | 900: '220deg 3% 20%', 9 | }, 10 | primary: '340deg 65% 47%', 11 | secondary: '240deg 60% 63%', 12 | }; 13 | 14 | export const WEIGHTS = { 15 | normal: 500, 16 | medium: 600, 17 | bold: 800, 18 | }; 19 | 20 | export const BREAKPOINTS = { 21 | phone: 600, 22 | tablet: 950, 23 | laptop: 1300, 24 | }; 25 | 26 | export const QUERIES = { 27 | phoneAndSmaller: `(max-width: ${BREAKPOINTS.phone / 16}rem)`, 28 | tabletAndSmaller: `(max-width: ${BREAKPOINTS.tablet / 16}rem)`, 29 | laptopAndSmaller: `(max-width: ${BREAKPOINTS.laptop / 16}rem)`, 30 | }; 31 | -------------------------------------------------------------------------------- /src/data.js: -------------------------------------------------------------------------------- 1 | /** 2 | * In a real app, this data would likely live in a database, 3 | * and be fetched from an API, either at runtime or at 4 | * compile-time. 5 | * 6 | * Keep in mind, this workshop is focused on CSS. In order 7 | * to make it easy to focus on the styling, we're cutting 8 | * some corners when it comes to our data management, and 9 | * our JavaScript in general. Please don't try to glean 10 | * any best-practices from stuff like this data file! 11 | */ 12 | 13 | const SHOES = [ 14 | { 15 | slug: 'tech-challenge', 16 | name: 'NikeCourt Tech Challenge 20', 17 | imageSrc: '/assets/tech-challenge.jpg', 18 | price: 16500, 19 | salePrice: null, 20 | // 1 hour ago! 🔥 21 | releaseDate: Date.now() - 1000 * 60 * 60 * 1, 22 | numOfColors: 2, 23 | }, 24 | { 25 | slug: 'metcon-5', 26 | name: 'Nike Metcon 5 AMP', 27 | imageSrc: '/assets/metcon-5.jpg', 28 | price: 16500, 29 | salePrice: null, 30 | releaseDate: Date.now() - 1000 * 60 * 60 * 24 * 2, 31 | numOfColors: 1, 32 | }, 33 | { 34 | slug: 'phantom', 35 | name: 'Nike Phantom Vision', 36 | imageSrc: '/assets/phantom.jpg', 37 | price: 16500, 38 | salePrice: null, 39 | releaseDate: Date.now() - 1000 * 60 * 60 * 24 * 4, 40 | numOfColors: 4, 41 | }, 42 | { 43 | slug: 'pegasus', 44 | name: 'Nike Air Zoom Pegasus', 45 | imageSrc: '/assets/pegasus.jpg', 46 | price: 16500, 47 | salePrice: null, 48 | releaseDate: Date.now() - 1000 * 60 * 60 * 24 * 16, 49 | numOfColors: 1, 50 | }, 51 | { 52 | slug: 'joyride', 53 | name: 'Nike Joyride Dual Run', 54 | imageSrc: '/assets/joyride.jpg', 55 | price: 17000, 56 | salePrice: null, 57 | releaseDate: Date.now() - 1000 * 60 * 60 * 24 * 40, 58 | numOfColors: 2, 59 | }, 60 | { 61 | slug: 'legend-academy', 62 | name: 'Nike Tiempo Legend 8', 63 | imageSrc: '/assets/legend-academy.jpg', 64 | price: 16500, 65 | salePrice: 12500, 66 | releaseDate: Date.now() - 1000 * 60 * 60 * 24 * 50, 67 | numOfColors: 8, 68 | }, 69 | { 70 | slug: 'react-infinity', 71 | name: 'Nike React Infinity Pro', 72 | imageSrc: '/assets/react-infinity.jpg', 73 | price: 16000, 74 | salePrice: 14500, 75 | releaseDate: Date.now() - 1000 * 60 * 60 * 24 * 75, 76 | numOfColors: 1, 77 | }, 78 | { 79 | slug: 'phantom-flyknit', 80 | name: 'Nike React Phantom Run Flyknit 2', 81 | imageSrc: '/assets/phantom-flyknit.jpg', 82 | price: 18500, 83 | salePrice: 16000, 84 | releaseDate: Date.now() - 1000 * 60 * 60 * 24 * 100, 85 | numOfColors: 4, 86 | }, 87 | { 88 | slug: 'lebron', 89 | name: 'LeBron 17', 90 | imageSrc: '/assets/lebron.jpg', 91 | price: 26000, 92 | salePrice: null, 93 | releaseDate: Date.now() - 1000 * 60 * 60 * 24 * 120, 94 | numOfColors: 1, 95 | }, 96 | ]; 97 | 98 | export default SHOES; 99 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import App from './components/App'; 5 | import GlobalStyles from './components/GlobalStyles'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | 11 | , 12 | document.getElementById('root') 13 | ); 14 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Several little utilities for this project. 3 | * 4 | * NOTE: These are NOT generic, and should not be copied 5 | * to other projects. They're quick imperfect implementations 6 | * for the known, fixed data we work with here. 7 | */ 8 | import differenceInDays from 'date-fns/differenceInDays'; 9 | 10 | export function formatPrice(price) { 11 | return `$${price / 100}`; 12 | } 13 | 14 | export function pluralize(string, num) { 15 | return num === 1 ? `1 ${string}` : `${num} ${string}s`; 16 | } 17 | 18 | export function isNewShoe(releaseDate) { 19 | return differenceInDays(new Date(), releaseDate) < 30; 20 | } 21 | --------------------------------------------------------------------------------