├── public ├── vader.png ├── pikachu.png ├── gwen-artist.png ├── koopa-troopa.png ├── cleveland-museum.jpg ├── shopping-cart-backpack.jpg ├── shopping-cart-can-opener.jpg ├── shopping-cart-night-light.png └── shopping-cart-coffee-machine.jpg ├── src ├── components │ └── Spinner │ │ ├── index.js │ │ ├── Spinner.module.css │ │ └── Spinner.js └── app │ ├── layout.js │ ├── exercises │ ├── 01-clock │ │ ├── page.js │ │ ├── styles.css │ │ └── Clock.js │ ├── 02-checkout │ │ ├── StoreItem.js │ │ ├── data.js │ │ ├── reducer.js │ │ ├── CartTable.js │ │ ├── page.js │ │ ├── CheckoutFlow.js │ │ └── styles.css │ └── 03-interview │ │ ├── page.js │ │ ├── styles.css │ │ └── Interview.js │ ├── page.js │ └── styles.css ├── .gitignore └── .codesandbox └── tasks.json /public/vader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/victorbecerra-k/next-ssr-exercises/HEAD/public/vader.png -------------------------------------------------------------------------------- /src/components/Spinner/index.js: -------------------------------------------------------------------------------- 1 | export * from './Spinner'; 2 | export { default } from './Spinner'; 3 | -------------------------------------------------------------------------------- /public/pikachu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/victorbecerra-k/next-ssr-exercises/HEAD/public/pikachu.png -------------------------------------------------------------------------------- /public/gwen-artist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/victorbecerra-k/next-ssr-exercises/HEAD/public/gwen-artist.png -------------------------------------------------------------------------------- /public/koopa-troopa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/victorbecerra-k/next-ssr-exercises/HEAD/public/koopa-troopa.png -------------------------------------------------------------------------------- /public/cleveland-museum.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/victorbecerra-k/next-ssr-exercises/HEAD/public/cleveland-museum.jpg -------------------------------------------------------------------------------- /public/shopping-cart-backpack.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/victorbecerra-k/next-ssr-exercises/HEAD/public/shopping-cart-backpack.jpg -------------------------------------------------------------------------------- /public/shopping-cart-can-opener.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/victorbecerra-k/next-ssr-exercises/HEAD/public/shopping-cart-can-opener.jpg -------------------------------------------------------------------------------- /public/shopping-cart-night-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/victorbecerra-k/next-ssr-exercises/HEAD/public/shopping-cart-night-light.png -------------------------------------------------------------------------------- /public/shopping-cart-coffee-machine.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/victorbecerra-k/next-ssr-exercises/HEAD/public/shopping-cart-coffee-machine.jpg -------------------------------------------------------------------------------- /src/app/layout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './styles.css'; 4 | 5 | function RootLayout({ children }) { 6 | return ( 7 | 8 | {children} 9 | 10 | ); 11 | } 12 | 13 | export default RootLayout; 14 | -------------------------------------------------------------------------------- /src/app/exercises/01-clock/page.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Clock from './Clock'; 4 | import './styles.css'; 5 | 6 | function ClockExercise() { 7 | return ( 8 |
9 |

Current time:

10 | 11 |
12 | ); 13 | } 14 | 15 | export default ClockExercise; 16 | -------------------------------------------------------------------------------- /src/components/Spinner/Spinner.module.css: -------------------------------------------------------------------------------- 1 | @keyframes spin { 2 | from { 3 | transform: rotate(0deg); 4 | } 5 | 6 | to { 7 | transform: rotate(360deg); 8 | } 9 | } 10 | 11 | .wrapper { 12 | display: block; 13 | animation: spin var(--speed) linear infinite; 14 | } 15 | 16 | .wrapper svg { 17 | /* 18 | Ensure that the child is block so 19 | that it spins symmetrically 20 | */ 21 | display: block; 22 | } 23 | -------------------------------------------------------------------------------- /src/app/exercises/01-clock/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: hsl(270deg 100% 85%); 3 | background-image: url('/cleveland-museum.jpg'); 4 | background-size: cover; 5 | background-position: center right; 6 | } 7 | main { 8 | position: absolute; 9 | right: 0px; 10 | top: 16px; 11 | background: white; 12 | padding: 32px; 13 | border-radius: 8px 0px 0px 8px; 14 | text-align: center; 15 | } 16 | h1 { 17 | font-size: 2rem; 18 | } 19 | p { 20 | font-family: monospace; 21 | font-size: 1.6rem; 22 | } 23 | -------------------------------------------------------------------------------- /.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | .vscode 36 | -------------------------------------------------------------------------------- /src/app/exercises/01-clock/Clock.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React from 'react'; 3 | import format from 'date-fns/format'; 4 | 5 | function Clock() { 6 | const [time, setTime] = React.useState(new Date()); 7 | 8 | React.useEffect(() => { 9 | const intervalId = window.setInterval(() => { 10 | setTime(new Date()); 11 | }, 50); 12 | 13 | return () => { 14 | window.clearInterval(intervalId); 15 | }; 16 | }, []); 17 | 18 | return ( 19 |

{format(time, 'hh:mm:ss.S a')}

20 | ); 21 | } 22 | 23 | export default Clock; 24 | -------------------------------------------------------------------------------- /src/app/exercises/02-checkout/StoreItem.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React from 'react'; 3 | 4 | function StoreItem({ item, handleAddToCart }) { 5 | const price = new Intl.NumberFormat('en-US', { 6 | style: 'currency', 7 | currency: 'USD', 8 | }).format(item.price); 9 | 10 | return ( 11 |
12 | {item.imageAlt} 13 |

{item.title}

14 |

{price}

15 | 18 |
19 | ); 20 | } 21 | 22 | export default StoreItem; 23 | -------------------------------------------------------------------------------- /src/components/Spinner/Spinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Loader } from 'react-feather'; 3 | 4 | import styles from './Spinner.module.css'; 5 | 6 | function Spinner({ 7 | color = 'black', 8 | size = 24, 9 | opacity = 0.5, 10 | speed = 1200, 11 | }) { 12 | return ( 13 | 22 | 23 | 24 | ); 25 | } 26 | 27 | export default Spinner; 28 | -------------------------------------------------------------------------------- /src/app/page.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function Home() { 4 | return ( 5 |
6 |

7 | This repository consists of three exercises: 8 |

9 |
    10 |
  1. 11 | Clock 12 |
  2. 13 |
  3. 14 | 15 | Neighborhood Shop 16 | 17 |
  4. 18 |
  5. 19 | 20 | Artist Interview 21 | 22 |
  6. 23 |
24 |
25 | ); 26 | } 27 | 28 | export default Home; 29 | -------------------------------------------------------------------------------- /src/app/exercises/02-checkout/data.js: -------------------------------------------------------------------------------- 1 | const DATA = [ 2 | { 3 | id: 'hk123', 4 | imageSrc: '/shopping-cart-coffee-machine.jpg', 5 | imageAlt: 6 | 'A pink drip coffee machine with the “Hello Kitty” logo', 7 | title: '“Hello Coffee”', 8 | price: 89.99, 9 | }, 10 | { 11 | id: 'co999', 12 | imageSrc: '/shopping-cart-can-opener.jpg', 13 | imageAlt: 'A black can opener', 14 | title: 'Can Opener', 15 | price: 19.95, 16 | }, 17 | { 18 | id: 'cnl333', 19 | imageSrc: '/shopping-cart-night-light.png', 20 | imageAlt: 21 | 'A kid-friendly nightlight sculpted to look like a dog astronaut', 22 | title: 'Astro-pup', 23 | price: 130.0, 24 | }, 25 | ]; 26 | 27 | export default DATA; 28 | -------------------------------------------------------------------------------- /.codesandbox/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // These tasks will run in order when initializing your CodeSandbox project. 3 | "setupTasks": [ 4 | { 5 | "name": "Install Dependencies", 6 | "command": "npm install" 7 | } 8 | ], 9 | 10 | // These tasks can be run from CodeSandbox. Running one will open a log in the app. 11 | "tasks": { 12 | "dev": { 13 | "name": "dev", 14 | "command": "npm run dev", 15 | "runAtStart": true, 16 | "preview": { 17 | "port": 3000 18 | } 19 | }, 20 | "build": { 21 | "name": "build", 22 | "command": "npm run build", 23 | "runAtStart": false 24 | }, 25 | "start": { 26 | "name": "start", 27 | "command": "npm run start", 28 | "runAtStart": false 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/exercises/02-checkout/reducer.js: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | 3 | function reducer(state, action) { 4 | return produce(state, (draftState) => { 5 | switch (action.type) { 6 | case 'add-item': { 7 | const itemIndex = state.findIndex( 8 | (item) => item.id === action.item.id 9 | ); 10 | 11 | if (itemIndex !== -1) { 12 | draftState[itemIndex].quantity += 1; 13 | return; 14 | } 15 | 16 | draftState.push({ 17 | ...action.item, 18 | quantity: 1, 19 | }); 20 | return; 21 | } 22 | 23 | case 'delete-item': { 24 | const itemIndex = state.findIndex( 25 | (item) => item.id === action.item.id 26 | ); 27 | 28 | draftState.splice(itemIndex, 1); 29 | return; 30 | } 31 | } 32 | }); 33 | } 34 | 35 | export default reducer; 36 | -------------------------------------------------------------------------------- /src/app/exercises/02-checkout/CartTable.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React from 'react'; 3 | 4 | function CartTable({ items, handleDeleteItem }) { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {items.map((item) => ( 17 | 18 | 19 | 20 | 21 | 28 | 29 | ))} 30 | 31 |
TitlePrice#
{item.title}${item.price}{item.quantity} 22 | 27 |
32 | ); 33 | } 34 | 35 | export default CartTable; 36 | -------------------------------------------------------------------------------- /src/app/styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | Josh's Custom CSS Reset 3 | https://www.joshwcomeau.com/css/custom-css-reset/ 4 | */ 5 | *, 6 | *::before, 7 | *::after { 8 | box-sizing: border-box; 9 | } 10 | * { 11 | margin: 0; 12 | } 13 | html, 14 | body { 15 | height: 100%; 16 | } 17 | body { 18 | line-height: 1.5; 19 | -webkit-font-smoothing: antialiased; 20 | } 21 | img, 22 | picture, 23 | video, 24 | canvas, 25 | svg { 26 | display: block; 27 | max-width: 100%; 28 | } 29 | input, 30 | button, 31 | textarea, 32 | select { 33 | font: inherit; 34 | } 35 | p, 36 | h1, 37 | h2, 38 | h3, 39 | h4, 40 | h5, 41 | h6 { 42 | overflow-wrap: break-word; 43 | } 44 | #root, 45 | #__next { 46 | isolation: isolate; 47 | } 48 | 49 | /* Global styles */ 50 | body { 51 | font-family: sans-serif; 52 | } 53 | 54 | /* Cosmetic homepage styles */ 55 | .homepage-wrapper { 56 | padding: 32px; 57 | } 58 | 59 | .homepage-wrapper p { 60 | margin-bottom: 1em; 61 | } 62 | -------------------------------------------------------------------------------- /src/app/exercises/03-interview/page.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React from 'react'; 3 | import { useMediaQuery } from 'react-responsive'; 4 | 5 | import Interview from './Interview'; 6 | import './styles.css'; 7 | 8 | function InterviewExercise() { 9 | const isDesktop = useMediaQuery({ 10 | query: '(min-width: 500px)', 11 | }); 12 | 13 | return ( 14 |
15 | 16 | {isDesktop && ( 17 | 32 | )} 33 |
34 | ); 35 | } 36 | 37 | export default InterviewExercise; 38 | -------------------------------------------------------------------------------- /src/app/exercises/02-checkout/page.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React from 'react'; 3 | 4 | import DATA from './data'; 5 | import reducer from './reducer'; 6 | import StoreItem from './StoreItem'; 7 | import CheckoutFlow from './CheckoutFlow'; 8 | import './styles.css'; 9 | 10 | function CheckoutExercise() { 11 | const [items, dispatch] = React.useReducer( 12 | reducer, 13 | [] 14 | ); 15 | 16 | return ( 17 | <> 18 |

Neighborhood Shop

19 | 20 |
21 |
22 | {DATA.map((item) => ( 23 | { 27 | dispatch({ 28 | type: 'add-item', 29 | item, 30 | }); 31 | }} 32 | /> 33 | ))} 34 |
35 | 36 | 40 | dispatch({ 41 | type: 'delete-item', 42 | item, 43 | }) 44 | } 45 | /> 46 |
47 | 48 | ); 49 | } 50 | 51 | export default CheckoutExercise; 52 | -------------------------------------------------------------------------------- /src/app/exercises/02-checkout/CheckoutFlow.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React from 'react'; 3 | 4 | import CartTable from './CartTable'; 5 | 6 | function CheckoutFlow({ 7 | items, 8 | taxRate, 9 | handleDeleteItem, 10 | }) { 11 | if (items.length === 0) { 12 | return ( 13 |
14 |

Your Cart is Empty

15 |
16 | ); 17 | } 18 | 19 | const priceFormatter = new Intl.NumberFormat('en-US', { 20 | style: 'currency', 21 | currency: 'USD', 22 | }); 23 | 24 | const subtotal = calculateSubtotal(items); 25 | const taxes = subtotal * taxRate; 26 | const total = subtotal + taxes; 27 | 28 | return ( 29 |
30 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
Subtotal{priceFormatter.format(subtotal)}
Taxes{priceFormatter.format(taxes)}
Total{priceFormatter.format(total)}
51 |
52 | ); 53 | } 54 | 55 | function calculateSubtotal(items) { 56 | let subtotal = 0; 57 | 58 | items.forEach((item) => { 59 | subtotal += item.price * item.quantity; 60 | }); 61 | 62 | return subtotal; 63 | } 64 | 65 | export default CheckoutFlow; 66 | -------------------------------------------------------------------------------- /src/app/exercises/03-interview/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: black; 3 | padding: 16px; 4 | font-family: sans-serif; 5 | } 6 | h1 { 7 | font-size: 2rem; 8 | text-align: center; 9 | margin-bottom: 16px; 10 | } 11 | p { 12 | margin-bottom: 1em; 13 | } 14 | main { 15 | display: flex; 16 | gap: 16px; 17 | max-width: 900px; 18 | margin: 0 auto; 19 | } 20 | article { 21 | flex: 1; 22 | padding: 16px; 23 | background: hsl(220deg 20% 94%); 24 | border-radius: 24px; 25 | } 26 | .hero-img { 27 | display: block; 28 | width: 100%; 29 | border-radius: 8px 8px 2px 2px; 30 | margin-bottom: 32px; 31 | aspect-ratio: 1 / 1; 32 | } 33 | aside { 34 | align-self: flex-start; 35 | position: sticky; 36 | top: 16px; 37 | width: 250px; 38 | color: white; 39 | padding: 16px; 40 | border: 1px solid hsl(0deg 0% 30%); 41 | border-radius: 24px 24px 8px 8px; 42 | } 43 | aside h2 { 44 | width: fit-content; 45 | margin: 1em auto 0.5em; 46 | padding-bottom: 0; 47 | border-bottom: 2px dotted hsl(50deg 100% 80%); 48 | font-family: cursive; 49 | font-size: 1.5rem; 50 | font-weight: 400; 51 | color: hsl(50deg 100% 80%); 52 | } 53 | aside p { 54 | margin-bottom: 0; 55 | font-size: 0.875rem; 56 | hyphens: auto; 57 | } 58 | aside img { 59 | display: block; 60 | width: 100%; 61 | border-radius: 8px 8px 2px 2px; 62 | } 63 | 64 | .row { 65 | display: flex; 66 | gap: 16px; 67 | border-top: 3px dotted hsl(220deg 30% 80%); 68 | padding-top: 32px; 69 | margin-top: 32px; 70 | } 71 | .row img { 72 | flex: 1; 73 | min-width: 0px; 74 | border-radius: 2px 2px 8px 8px; 75 | aspect-ratio: 1 / 1; 76 | } 77 | -------------------------------------------------------------------------------- /src/app/exercises/02-checkout/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 16px; 3 | } 4 | h1 { 5 | margin: 0 0 1rem; 6 | text-align: center; 7 | } 8 | 9 | main { 10 | display: flex; 11 | gap: 16px; 12 | flex-wrap: wrap; 13 | } 14 | 15 | .items { 16 | flex: 1; 17 | display: flex; 18 | gap: 16px; 19 | flex-wrap: wrap; 20 | min-width: 170px; 21 | } 22 | .items article { 23 | flex: 1; 24 | display: flex; 25 | flex-direction: column; 26 | min-width: 170px; 27 | border: 1px solid hsl(0deg 0% 80%); 28 | padding: 16px; 29 | text-align: center; 30 | } 31 | .items article img { 32 | margin-bottom: 8px; 33 | } 34 | .items article p { 35 | margin-top: -4px; 36 | margin-bottom: 16px; 37 | } 38 | 39 | .checkout-flow { 40 | flex: 1; 41 | min-width: 400px; 42 | border: 1px solid hsl(0deg 0% 80%); 43 | padding: 16px; 44 | } 45 | 46 | .checkout-flow.empty { 47 | display: flex; 48 | flex-direction: column; 49 | align-items: center; 50 | } 51 | .checkout-flow.empty p { 52 | padding: 32px 0px; 53 | font-size: 1.5rem; 54 | color: hsl(0deg 0% 33%); 55 | text-align: center; 56 | } 57 | 58 | .shopping-cart { 59 | width: 100%; 60 | --image-width: 64px; 61 | border-bottom: 3px solid hsl(0deg 0% 25%); 62 | margin-bottom: 16px; 63 | } 64 | 65 | .cart-row td { 66 | padding: 8px 0px; 67 | } 68 | .cart-row td:first-of-type { 69 | width: 150px; 70 | } 71 | .cart-row td:last-of-type { 72 | width: 75px; 73 | } 74 | .shopping-cart th { 75 | text-align: left; 76 | } 77 | 78 | .product-thumb { 79 | width: var(--image-width); 80 | } 81 | 82 | .checkout-totals { 83 | width: 100%; 84 | } 85 | .checkout-totals th { 86 | text-align: right; 87 | } 88 | .checkout-totals td { 89 | width: 100px; 90 | } 91 | .checkout-totals td, 92 | .checkout-totals th { 93 | padding: 8px 16px; 94 | background: hsl(0deg 0% 95%); 95 | } 96 | -------------------------------------------------------------------------------- /src/app/exercises/03-interview/Interview.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function Interview() { 4 | return ( 5 |
6 | AI-generated oil painting of Pikachu 11 | 12 |

A Fireside Chat with Gwen Altaria

13 | 14 |

15 | Interviewer: It's a pleasure to 16 | have you with us today. Your work has recently been 17 | making waves on social media. Can you start by 18 | telling us a little about yourself and your journey 19 | into the world of art? 20 |

21 | 22 |

23 | Gwen: I've always had a fondness 24 | for both traditional art and popular culture. I 25 | studied fine art in college, but found that I kept 26 | coming back to the characters and stories that I 27 | grew up with. Combining the two felt natural, a way 28 | to bridge the gap between high art and everyday pop 29 | culture. 30 |

31 | 32 |

33 | Interviewer: What inspired you to 34 | combine traditional oil painting with modern pop 35 | culture references? 36 |

37 | 38 |

39 | Gwen: I think there's a certain 40 | beauty in taking something modern, something often 41 | seen as lowbrow or transient, and giving it a 42 | timeless quality through the medium of oil painting. 43 | There's also an element of nostalgia and joy in 44 | these characters that I think resonates with a lot 45 | of people, myself included. 46 |

47 | 48 |

49 | Interviewer: Your style is very 50 | unique. How did you develop this approach? 51 |

52 | 53 |

54 | Gwen: It was a process of trial and 55 | error, really. Initially, I tried to keep the pop 56 | culture and traditional elements separate, but it 57 | felt disjointed. I started experimenting with 58 | blending the two more seamlessly, incorporating 59 | elements from each into a cohesive whole. I wanted 60 | each painting to feel like it could hang in a 61 | museum, but also fit right in at a comic convention. 62 |

63 | 64 |

65 | Interviewer: What message, if any, 66 | do you hope people take away from your artwork? 67 |

68 | 69 |

70 | Gwen: I hope people see that art 71 | can be fun, and that it doesn't have to fit into a 72 | traditional box to be valuable or meaningful. I also 73 | hope that it encourages people to embrace the things 74 | that they love, even if they might seem silly or 75 | inconsequential to others. 76 |

77 | 78 |

79 | Interviewer: Fascinating. Lastly, 80 | what's next for you? Can we expect more of these 81 | incredible crossovers in the future? 82 |

83 | 84 |

85 | Gwen: I definitely plan on 86 | continuing this series, there's still so many 87 | characters I want to explore. I'm also considering 88 | branching out into other mediums, maybe even some 3D 89 | work. As for what's next, well, you'll just have to 90 | wait and see. 91 |

92 | 93 |
94 | AI-generated oil painting of a Koopa Troopa from Super Mario 98 | AI-generated oil painting of a pixellated Darth Vader 102 |
103 |
104 | ); 105 | } 106 | 107 | export default Interview; 108 | --------------------------------------------------------------------------------