├── .eslintignore
├── .gitignore
├── README.md
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── projects
├── 01-twitter-follow-card
│ ├── .gitignore
│ ├── index.html
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.css
│ │ ├── App.jsx
│ │ ├── TwitterFollowCard.jsx
│ │ ├── index.css
│ │ └── main.jsx
│ └── vite.config.js
├── 02-tic-tac-toe
│ ├── .gitignore
│ ├── index.html
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.css
│ │ ├── App.jsx
│ │ ├── components
│ │ │ ├── Square.jsx
│ │ │ └── WinnerModal.jsx
│ │ ├── constants.js
│ │ ├── index.css
│ │ ├── logic
│ │ │ ├── board.js
│ │ │ └── storage
│ │ │ │ └── index.js
│ │ └── main.jsx
│ └── vite.config.js
├── 03-mouse-follower
│ ├── .gitignore
│ ├── index.html
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.css
│ │ ├── App.jsx
│ │ ├── index.css
│ │ └── main.jsx
│ └── vite.config.js
├── 04-react-prueba-tecnica
│ ├── .gitignore
│ ├── README.md
│ ├── counter.js
│ ├── index.html
│ ├── javascript.svg
│ ├── main.jsx
│ ├── package.json
│ ├── playwright.config.cjs
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.css
│ │ ├── App.jsx
│ │ ├── Components
│ │ │ └── Otro.jsx
│ │ ├── hooks
│ │ │ ├── useCatFact.js
│ │ │ └── useCatImage.js
│ │ └── services
│ │ │ └── facts.js
│ ├── style.css
│ ├── tests
│ │ └── example.spec.js
│ └── vite.config.js
├── 05-react-buscador-peliculas
│ ├── .gitignore
│ ├── README.md
│ ├── index.html
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.css
│ │ ├── App.jsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ └── Movies.jsx
│ │ ├── hooks
│ │ │ └── useMovies.js
│ │ ├── index.css
│ │ ├── main.jsx
│ │ ├── mocks
│ │ │ ├── no-results.json
│ │ │ └── with-results.json
│ │ └── services
│ │ │ └── movies.js
│ └── vite.config.js
├── 06-shopping-cart
│ ├── .gitignore
│ ├── README.md
│ ├── index.html
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.jsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ ├── Cart.css
│ │ │ ├── Cart.jsx
│ │ │ ├── Filters.css
│ │ │ ├── Filters.jsx
│ │ │ ├── Footer.css
│ │ │ ├── Footer.jsx
│ │ │ ├── Header.jsx
│ │ │ ├── Icons.jsx
│ │ │ ├── Products.css
│ │ │ └── Products.jsx
│ │ ├── config.js
│ │ ├── context
│ │ │ ├── cart.jsx
│ │ │ └── filters.jsx
│ │ ├── hooks
│ │ │ ├── useCart.js
│ │ │ └── useFilters.js
│ │ ├── index.css
│ │ ├── main.jsx
│ │ ├── mocks
│ │ │ └── products.json
│ │ └── reducers
│ │ │ └── cart.js
│ └── vite.config.js
├── 07-midu-router
│ ├── .npmignore
│ ├── .swcrc
│ ├── README.md
│ ├── index.html
│ ├── lib
│ │ ├── Link.js
│ │ ├── Route.js
│ │ ├── Router.js
│ │ └── index.js
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.css
│ │ ├── App.jsx
│ │ ├── Router.test.jsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ ├── Link.jsx
│ │ │ ├── Route.jsx
│ │ │ └── Router.jsx
│ │ ├── index.css
│ │ ├── index.jsx
│ │ ├── main.jsx
│ │ ├── pages
│ │ │ ├── 404.jsx
│ │ │ ├── About.jsx
│ │ │ ├── Home.jsx
│ │ │ └── Search.jsx
│ │ └── utils
│ │ │ ├── consts.js
│ │ │ └── getCurrentPath.js
│ └── vite.config.js
├── 08-todo-app-typescript
│ ├── README.md
│ ├── index.html
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ ├── Copyright.css
│ │ │ ├── Copyright.tsx
│ │ │ ├── CreateTodo.tsx
│ │ │ ├── Filters.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── Header.tsx
│ │ │ ├── Todo.tsx
│ │ │ └── Todos.tsx
│ │ ├── consts.ts
│ │ ├── hooks
│ │ │ ├── useTodoFirst.ts
│ │ │ └── useTodos.ts
│ │ ├── index.css
│ │ ├── main.tsx
│ │ ├── mocks
│ │ │ └── todos.ts
│ │ ├── services
│ │ │ └── todos.ts
│ │ ├── types.d.ts
│ │ └── vite-env.d.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── 09-google-translate-clone
│ ├── .env
│ ├── .eslintrc.cjs
│ ├── .gitignore
│ ├── index.html
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.css
│ │ ├── App.test.tsx
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ ├── Icons.tsx
│ │ │ ├── LanguageSelector.tsx
│ │ │ └── TextArea.tsx
│ │ ├── constants.ts
│ │ ├── hooks
│ │ │ ├── useDebounce.ts
│ │ │ └── useStore.ts
│ │ ├── index.css
│ │ ├── main.tsx
│ │ ├── services
│ │ │ └── translate.ts
│ │ ├── types.d.ts
│ │ └── vite-env.d.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── 10-crud-redux
│ ├── .gitignore
│ ├── index.html
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── postcss.config.js
│ ├── public
│ │ └── vite.svg
│ ├── rome.json
│ ├── src
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ ├── CreateNewUser.tsx
│ │ │ └── ListOfUsers.tsx
│ │ ├── hooks
│ │ │ ├── store.ts
│ │ │ └── useUserActions.ts
│ │ ├── index.css
│ │ ├── main.tsx
│ │ ├── store
│ │ │ ├── index.ts
│ │ │ └── users
│ │ │ │ └── slice.ts
│ │ └── vite-env.d.ts
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── 11-typescript-prueba-tecnica
│ ├── README.md
│ ├── index.html
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ └── UsersList.tsx
│ │ ├── index.css
│ │ ├── main.tsx
│ │ ├── types.d.ts
│ │ └── vite-env.d.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── 11b-typescript-prueba-tecnica-with-react-query
│ ├── README.md
│ ├── index.html
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ ├── Results.tsx
│ │ │ └── UsersList.tsx
│ │ ├── hooks
│ │ │ └── useUsers.ts
│ │ ├── index.css
│ │ ├── main.tsx
│ │ ├── services
│ │ │ └── users.ts
│ │ ├── types.d.ts
│ │ └── vite-env.d.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── 12-comments-react-query
│ ├── index.html
│ ├── package.json
│ ├── postcss.config.js
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── components
│ │ │ ├── Form.tsx
│ │ │ └── Results.tsx
│ │ ├── index.css
│ │ ├── main.tsx
│ │ ├── service
│ │ │ └── comments.ts
│ │ └── vite-env.d.ts
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── 13-javascript-quiz-con-zustand
│ ├── .eslintrc.cjs
│ ├── index.html
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── public
│ │ ├── data.json
│ │ └── vite.svg
│ ├── src
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── Footer.tsx
│ │ ├── Game.tsx
│ │ ├── JavaScriptLogo.tsx
│ │ ├── Results.tsx
│ │ ├── Start.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── hooks
│ │ │ └── useQuestionsData.ts
│ │ ├── index.css
│ │ ├── main.tsx
│ │ ├── services
│ │ │ └── questions.ts
│ │ ├── store
│ │ │ └── questions.ts
│ │ ├── types.d.ts
│ │ └── vite-env.d.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
└── 14-hacker-news-prueba-tecnica
│ ├── .gitignore
│ ├── README.md
│ ├── index.html
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── public
│ ├── logo.gif
│ └── vite.svg
│ ├── src
│ ├── App.css
│ ├── App.tsx
│ ├── assets
│ │ └── react.svg
│ ├── components
│ │ ├── CommentLoader.tsx
│ │ ├── Header.css.ts
│ │ ├── Header.tsx
│ │ ├── ListOfComments.tsx
│ │ ├── Story.css.ts
│ │ ├── Story.tsx
│ │ └── StoryLoader.tsx
│ ├── index.css
│ ├── main.tsx
│ ├── pages
│ │ ├── Detail.tsx
│ │ └── TopStories.tsx
│ ├── services
│ │ └── hacker-news.ts
│ ├── utils
│ │ └── getRelativeTime.ts
│ └── vite-env.d.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
└── tsconfig.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | package-lock.json
2 | node_modules
3 | .DS_Store
4 | projects/**/.DS_Store
5 | projects/**/dist
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "aprendiendo-react",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "workspaces": [
10 | "projects/*"
11 | ],
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/midudev/aprendiendo-react.git"
15 | },
16 | "keywords": [],
17 | "author": "",
18 | "license": "ISC",
19 | "bugs": {
20 | "url": "https://github.com/midudev/aprendiendo-react/issues"
21 | },
22 | "homepage": "https://github.com/midudev/aprendiendo-react#readme",
23 | "devDependencies": {
24 | "standard": "17.0.0"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | # todos los proyectos dentro de projects son paquetes
3 | - 'projects/**'
--------------------------------------------------------------------------------
/projects/01-twitter-follow-card/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 |
27 | package-lock.json
--------------------------------------------------------------------------------
/projects/01-twitter-follow-card/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/projects/01-twitter-follow-card/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "01-twitter-follow-card",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "react": "^18.2.0",
13 | "react-dom": "^18.2.0"
14 | },
15 | "devDependencies": {
16 | "@types/react": "^18.0.26",
17 | "@types/react-dom": "^18.0.9",
18 | "@vitejs/plugin-react-swc": "^3.0.0",
19 | "vite": "^4.0.0"
20 | }
21 | }
--------------------------------------------------------------------------------
/projects/01-twitter-follow-card/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/01-twitter-follow-card/src/App.css:
--------------------------------------------------------------------------------
1 | .tw-followCard {
2 | display: flex;
3 | align-items: center;
4 | color: #fff;
5 | font-size: .8rem;
6 | justify-content: space-between;
7 | }
8 |
9 | .tw-followCard-header {
10 | display: flex;
11 | align-items: center;
12 | gap: 4px
13 | }
14 |
15 | .tw-followCard-info {
16 | display: flex;
17 | flex-direction: column;
18 | }
19 |
20 | .tw-followCard-infoUserName {
21 | opacity: .6;
22 | }
23 |
24 | .tw-followCard-avatar {
25 | width: 48px;
26 | height: 48px;
27 | border-radius: 1000px;
28 | }
29 |
30 | .tw-followCard-button {
31 | cursor: pointer;
32 | margin-left: 16px;
33 | border: 0;
34 | border-radius: 999px;
35 | padding: 6px 16px;
36 | font-weight: bold;
37 | border: 1px solid #000;
38 | transition: .3s ease background-color;
39 | }
40 |
41 | .tw-followCard-button:hover {
42 | opacity: .8;
43 | }
44 |
45 | .tw-followCard-text {
46 | display: block;
47 | }
48 |
49 | .tw-followCard-button.is-following {
50 | border: 1px solid #bbb;
51 | background: transparent;
52 | color: #fff;
53 | width: 140px;
54 | }
55 |
56 | .tw-followCard-button.is-following:hover {
57 | background-color: rgba(255, 0, 0, 0.178);
58 | color: red;
59 | border: 1px solid red;
60 | transition: .3s ease all;
61 | opacity: 1;
62 | }
63 |
64 | .tw-followCard-button.is-following:hover .tw-followCard-text {
65 | display: none;
66 | }
67 |
68 | .tw-followCard-button.is-following:hover .tw-followCard-stopFollow {
69 | display: block;
70 | }
71 |
72 | .tw-followCard-stopFollow {
73 | display: none;
74 | }
75 |
76 |
--------------------------------------------------------------------------------
/projects/01-twitter-follow-card/src/App.jsx:
--------------------------------------------------------------------------------
1 | import './App.css'
2 | import { TwitterFollowCard } from './TwitterFollowCard.jsx'
3 |
4 | const users = [
5 | {
6 | userName: 'midudev',
7 | name: 'Miguel Ángel Durán',
8 | isFollowing: true
9 | },
10 | {
11 | userName: 'pheralb',
12 | name: 'Pablo H.',
13 | isFollowing: false
14 | },
15 | {
16 | userName: 'PacoHdezs',
17 | name: 'Paco Hdez',
18 | isFollowing: true
19 | },
20 | {
21 | userName: 'TMChein',
22 | name: 'Tomas',
23 | isFollowing: false
24 | }
25 | ]
26 |
27 | export function App () {
28 | return (
29 |
30 | {
31 | users.map(({ userName, name, isFollowing }) => (
32 |
37 | {name}
38 |
39 | ))
40 | }
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/projects/01-twitter-follow-card/src/TwitterFollowCard.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | export function TwitterFollowCard ({ children, userName, initialIsFollowing }) {
4 | const [isFollowing, setIsFollowing] = useState(initialIsFollowing)
5 |
6 | console.log('[TwitterFollowCard] render with userName: ', userName)
7 |
8 | const text = isFollowing ? 'Siguiendo' : 'Seguir'
9 | const buttonClassName = isFollowing
10 | ? 'tw-followCard-button is-following'
11 | : 'tw-followCard-button'
12 |
13 | const handleClick = () => {
14 | setIsFollowing(!isFollowing)
15 | }
16 |
17 | return (
18 |
19 |
30 |
31 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/projects/01-twitter-follow-card/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | background: #222;
4 | font-family: system-ui;
5 | display: grid;
6 | place-content: center;
7 | min-height: 100vh;
8 | }
9 |
10 | .App {
11 | display: flex;
12 | flex-direction: column;
13 | gap: 8px;
14 | }
--------------------------------------------------------------------------------
/projects/01-twitter-follow-card/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import { App } from './App.jsx'
4 | import './index.css'
5 |
6 | const root = ReactDOM.createRoot(document.getElementById('root'))
7 |
8 | root.render(
9 |
10 | )
11 |
--------------------------------------------------------------------------------
/projects/01-twitter-follow-card/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()]
7 | })
8 |
--------------------------------------------------------------------------------
/projects/02-tic-tac-toe/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/projects/02-tic-tac-toe/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/projects/02-tic-tac-toe/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "02-tic-tac-toe",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "canvas-confetti": "1.6.0",
13 | "react": "^18.2.0",
14 | "react-dom": "^18.2.0"
15 | },
16 | "devDependencies": {
17 | "@types/react": "^18.0.26",
18 | "@types/react-dom": "^18.0.9",
19 | "@vitejs/plugin-react-swc": "^3.0.0",
20 | "vite": "^4.0.0"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/projects/02-tic-tac-toe/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/02-tic-tac-toe/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | }
13 | .logo:hover {
14 | filter: drop-shadow(0 0 2em #646cffaa);
15 | }
16 | .logo.react:hover {
17 | filter: drop-shadow(0 0 2em #61dafbaa);
18 | }
19 |
20 | @keyframes logo-spin {
21 | from {
22 | transform: rotate(0deg);
23 | }
24 | to {
25 | transform: rotate(360deg);
26 | }
27 | }
28 |
29 | @media (prefers-reduced-motion: no-preference) {
30 | a:nth-of-type(2) .logo {
31 | animation: logo-spin infinite 20s linear;
32 | }
33 | }
34 |
35 | .card {
36 | padding: 2em;
37 | }
38 |
39 | .read-the-docs {
40 | color: #888;
41 | }
42 |
--------------------------------------------------------------------------------
/projects/02-tic-tac-toe/src/components/Square.jsx:
--------------------------------------------------------------------------------
1 | export const Square = ({ children, isSelected, updateBoard, index }) => {
2 | const className = `square ${isSelected ? 'is-selected' : ''}`
3 |
4 | const handleClick = () => {
5 | updateBoard(index)
6 | }
7 |
8 | return (
9 |
10 | {children}
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/projects/02-tic-tac-toe/src/components/WinnerModal.jsx:
--------------------------------------------------------------------------------
1 | import { Square } from './Square.jsx'
2 |
3 | export function WinnerModal ({ winner, resetGame }) {
4 | if (winner === null) return null
5 |
6 | const winnerText = winner === false ? 'Empate' : 'Ganó:'
7 |
8 | return (
9 |
10 |
11 |
{winnerText}
12 |
13 |
14 | {winner && {winner}}
15 |
16 |
17 |
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/projects/02-tic-tac-toe/src/constants.js:
--------------------------------------------------------------------------------
1 | export const TURNS = { // turnos
2 | X: '❌',
3 | O: '⚪'
4 | }
5 |
6 | export const WINNER_COMBOS = [
7 | [0, 1, 2],
8 | [3, 4, 5],
9 | [6, 7, 8],
10 | [0, 3, 6],
11 | [1, 4, 7],
12 | [2, 5, 8],
13 | [0, 4, 8],
14 | [2, 4, 6]
15 | ]
16 |
--------------------------------------------------------------------------------
/projects/02-tic-tac-toe/src/logic/board.js:
--------------------------------------------------------------------------------
1 | import { WINNER_COMBOS } from '../constants.js'
2 |
3 | export const checkWinnerFrom = (boardToCheck) => {
4 | // revisamos todas las combinaciones ganadoras
5 | // para ver si X u O ganó
6 | for (const combo of WINNER_COMBOS) {
7 | const [a, b, c] = combo
8 | if (
9 | boardToCheck[a] &&
10 | boardToCheck[a] === boardToCheck[b] &&
11 | boardToCheck[a] === boardToCheck[c]
12 | ) {
13 | return boardToCheck[a]
14 | }
15 | }
16 | // si no hay ganador
17 | return null
18 | }
19 |
20 | export const checkEndGame = (newBoard) => {
21 | // revisamos si hay un empate
22 | // si no hay más espacios vacíos
23 | // en el tablero
24 | return newBoard.every((square) => square !== null)
25 | }
26 |
--------------------------------------------------------------------------------
/projects/02-tic-tac-toe/src/logic/storage/index.js:
--------------------------------------------------------------------------------
1 | export const saveGameToStorage = ({ board, turn }) => {
2 | // guardar aqui partida
3 | window.localStorage.setItem('board', JSON.stringify(board))
4 | window.localStorage.setItem('turn', turn)
5 | }
6 |
7 | export const resetGameStorage = () => {
8 | window.localStorage.removeItem('board')
9 | window.localStorage.removeItem('turn')
10 | }
11 |
--------------------------------------------------------------------------------
/projects/02-tic-tac-toe/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App'
4 | import './index.css'
5 |
6 | ReactDOM.createRoot(document.getElementById('root')).render(
7 |
8 |
9 |
10 | )
11 |
--------------------------------------------------------------------------------
/projects/02-tic-tac-toe/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()]
7 | })
8 |
--------------------------------------------------------------------------------
/projects/03-mouse-follower/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/projects/03-mouse-follower/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/projects/03-mouse-follower/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "03-mouse-follower",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "react": "^18.2.0",
13 | "react-dom": "^18.2.0"
14 | },
15 | "devDependencies": {
16 | "@types/react": "^18.0.26",
17 | "@types/react-dom": "^18.0.9",
18 | "@vitejs/plugin-react-swc": "^3.0.0",
19 | "vite": "^4.0.0"
20 | }
21 | }
--------------------------------------------------------------------------------
/projects/03-mouse-follower/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/03-mouse-follower/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | }
13 | .logo:hover {
14 | filter: drop-shadow(0 0 2em #646cffaa);
15 | }
16 | .logo.react:hover {
17 | filter: drop-shadow(0 0 2em #61dafbaa);
18 | }
19 |
20 | @keyframes logo-spin {
21 | from {
22 | transform: rotate(0deg);
23 | }
24 | to {
25 | transform: rotate(360deg);
26 | }
27 | }
28 |
29 | @media (prefers-reduced-motion: no-preference) {
30 | a:nth-of-type(2) .logo {
31 | animation: logo-spin infinite 20s linear;
32 | }
33 | }
34 |
35 | .card {
36 | padding: 2em;
37 | }
38 |
39 | .read-the-docs {
40 | color: #888;
41 | }
42 |
--------------------------------------------------------------------------------
/projects/03-mouse-follower/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | const FollowMouse = () => {
4 | const [enabled, setEnabled] = useState(false)
5 | const [position, setPosition] = useState({ x: 0, y: 0 })
6 |
7 | // pointer move
8 | useEffect(() => {
9 | console.log('effect ', { enabled })
10 |
11 | const handleMove = (event) => {
12 | const { clientX, clientY } = event
13 | setPosition({ x: clientX, y: clientY })
14 | }
15 |
16 | if (enabled) {
17 | window.addEventListener('pointermove', handleMove)
18 | }
19 |
20 | // cleanup:
21 | // -> cuando el componente se desmonta
22 | // -> cuando cambian las dependencias, antes de ejecutar
23 | // el efecto de nuevo
24 | return () => { // cleanup method
25 | console.log('cleanup')
26 | window.removeEventListener('pointermove', handleMove)
27 | }
28 | }, [enabled])
29 |
30 | // [] -> solo se ejecuta una vez cuando se monta el componente
31 | // [enabled] -> se ejecuta cuando cambia enabled y cuando se monta el componente
32 | // undefined -> se ejecuta cada vez que se renderiza el componente
33 |
34 | // change body className
35 | useEffect(() => {
36 | document.body.classList.toggle('no-cursor', enabled)
37 |
38 | return () => {
39 | document.body.classList.remove('no-cursor')
40 | }
41 | }, [enabled])
42 |
43 | return (
44 | <>
45 |
59 |
62 | >
63 | )
64 | }
65 |
66 | function App () {
67 | return (
68 |
69 |
70 |
71 | )
72 | }
73 |
74 | export default App
75 |
--------------------------------------------------------------------------------
/projects/03-mouse-follower/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
3 | font-size: 16px;
4 | line-height: 24px;
5 | font-weight: 400;
6 |
7 | color-scheme: light dark;
8 | color: rgba(255, 255, 255, 0.87);
9 | background-color: #242424;
10 |
11 | font-synthesis: none;
12 | text-rendering: optimizeLegibility;
13 | -webkit-font-smoothing: antialiased;
14 | -moz-osx-font-smoothing: grayscale;
15 | -webkit-text-size-adjust: 100%;
16 | }
17 |
18 | a {
19 | font-weight: 500;
20 | color: #646cff;
21 | text-decoration: inherit;
22 | }
23 |
24 | a:hover {
25 | color: #535bf2;
26 | }
27 |
28 | body {
29 | margin: 0;
30 | display: grid;
31 | place-items: center;
32 | min-width: 320px;
33 | min-height: 100vh;
34 | }
35 |
36 | h1 {
37 | font-size: 3.2em;
38 | line-height: 1.1;
39 | }
40 |
41 | button {
42 | border-radius: 8px;
43 | border: 1px solid transparent;
44 | padding: 0.6em 1.2em;
45 | font-size: 1em;
46 | font-weight: 500;
47 | font-family: inherit;
48 | background-color: #1a1a1a;
49 | cursor: pointer;
50 | transition: border-color 0.25s;
51 | }
52 | button:hover {
53 | border-color: #646cff;
54 | }
55 | button:focus,
56 | button:focus-visible {
57 | outline: 4px auto -webkit-focus-ring-color;
58 | }
59 |
60 | @media (prefers-color-scheme: light) {
61 | :root {
62 | color: #213547;
63 | background-color: #ffffff;
64 | }
65 | a:hover {
66 | color: #747bff;
67 | }
68 | button {
69 | background-color: #f9f9f9;
70 | }
71 | }
72 |
73 | body.no-cursor {
74 | cursor: none;
75 | }
76 |
--------------------------------------------------------------------------------
/projects/03-mouse-follower/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App'
4 | import './index.css'
5 |
6 | ReactDOM.createRoot(document.getElementById('root')).render(
7 |
8 |
9 |
10 | )
11 |
--------------------------------------------------------------------------------
/projects/03-mouse-follower/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()]
7 | })
8 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 | /test-results/
26 | /playwright-report/
27 | /playwright/.cache/
28 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/README.md:
--------------------------------------------------------------------------------
1 | # Prueba técnica para Juniors y Trainees de React en Live Coding.
2 |
3 | APIs:
4 |
5 | - Facts Random: https://catfact.ninja/fact
6 | - Imagen random: https://cataas.com/cat/says/hello
7 |
8 | - Recupera un hecho aleatorio de gatos de la primera API
9 | - Recuperar la primera palabra del hecho
10 | - Muestra una imagen de un gato con la primera palabra.
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/counter.js:
--------------------------------------------------------------------------------
1 | export function setupCounter (element) {
2 | let counter = 0
3 | const setCounter = (count) => {
4 | counter = count
5 | element.innerHTML = `count is ${counter}`
6 | }
7 | element.addEventListener('click', () => setCounter(counter + 1))
8 | setCounter(0)
9 | }
10 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite App
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/javascript.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/main.jsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client'
2 | import { App } from './src/App.jsx'
3 |
4 | const root = createRoot(document.getElementById('app'))
5 |
6 | root.render()
7 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-prueba-tecnica",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview"
10 | },
11 | "devDependencies": {
12 | "@playwright/test": "^1.30.0",
13 | "standard": "^17.0.0",
14 | "vite": "^4.0.0"
15 | },
16 | "dependencies": {
17 | "@vitejs/plugin-react": "3.0.1",
18 | "react": "18.2.0",
19 | "react-dom": "18.2.0"
20 | },
21 | "eslintConfig": {
22 | "extends": "./node_modules/standard/eslintrc.json"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/src/App.css:
--------------------------------------------------------------------------------
1 | main {
2 | display: flex;
3 | flex-direction: column;
4 | place-items: center;
5 | max-width: 800px;
6 | margin: 0 auto;
7 | font-family: system-ui;
8 | }
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/src/App.jsx:
--------------------------------------------------------------------------------
1 | import './App.css'
2 | import { useCatImage } from './hooks/useCatImage.js'
3 | import { useCatFact } from './hooks/useCatFact.js'
4 |
5 | export function App () {
6 | const { fact, refreshFact } = useCatFact()
7 | const { imageUrl } = useCatImage({ fact })
8 |
9 | const handleClick = async () => {
10 | refreshFact()
11 | }
12 |
13 | return (
14 |
15 | App de gatitos
16 |
17 |
18 |
19 | {fact && {fact}
}
20 | {imageUrl &&
}
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/src/Components/Otro.jsx:
--------------------------------------------------------------------------------
1 | import { useCatImage } from '../hooks/useCatImage.js'
2 |
3 | export function Otro () {
4 | const { imageUrl } = useCatImage({ fact: 'cat' })
5 | console.log(imageUrl)
6 |
7 | return (
8 | <>
9 | {imageUrl &&
}
10 | >
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/src/hooks/useCatFact.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import { getRandomFact } from '../services/facts.js'
3 |
4 | export function useCatFact () {
5 | const [fact, setFact] = useState()
6 |
7 | const refreshFact = () => {
8 | getRandomFact().then(newFact => setFact(newFact))
9 | }
10 |
11 | // para recuperar la cita al cargar la página
12 | useEffect(refreshFact, [])
13 |
14 | return { fact, refreshFact }
15 | }
16 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/src/hooks/useCatImage.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | const CAT_PREFIX_IMAGE_URL = 'https://cataas.com'
4 |
5 | export function useCatImage ({ fact }) {
6 | const [imageUrl, setImageUrl] = useState()
7 |
8 | // para recuperar la imagen cada vez que tenemos una cita nueva
9 | useEffect(() => {
10 | if (!fact) return
11 |
12 | const threeFirstWords = fact.split(' ', 3).join(' ')
13 |
14 | fetch(`https://cataas.com/cat/says/${threeFirstWords}?size=50&color=red&json=true`)
15 | .then(res => res.json())
16 | .then(response => {
17 | const { _id } = response
18 | const url = `/cat/${_id}/says/${threeFirstWords}`
19 | setImageUrl(url)
20 | })
21 | }, [fact])
22 |
23 | return { imageUrl: `${CAT_PREFIX_IMAGE_URL}${imageUrl}` }
24 | }
25 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/src/services/facts.js:
--------------------------------------------------------------------------------
1 | const CAT_ENDPOINT_RANDOM_FACT = 'https://catfact.ninja/fact'
2 |
3 | export const getRandomFact = async () => {
4 | const res = await fetch(CAT_ENDPOINT_RANDOM_FACT)
5 | const data = await res.json()
6 | const { fact } = data
7 | return fact
8 | }
9 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
3 | font-size: 16px;
4 | line-height: 24px;
5 | font-weight: 400;
6 |
7 | color-scheme: light dark;
8 | color: rgba(255, 255, 255, 0.87);
9 | background-color: #242424;
10 |
11 | font-synthesis: none;
12 | text-rendering: optimizeLegibility;
13 | -webkit-font-smoothing: antialiased;
14 | -moz-osx-font-smoothing: grayscale;
15 | -webkit-text-size-adjust: 100%;
16 | }
17 |
18 | a {
19 | font-weight: 500;
20 | color: #646cff;
21 | text-decoration: inherit;
22 | }
23 | a:hover {
24 | color: #535bf2;
25 | }
26 |
27 | body {
28 | margin: 0;
29 | display: flex;
30 | place-items: center;
31 | min-width: 320px;
32 | min-height: 100vh;
33 | }
34 |
35 | h1 {
36 | font-size: 3.2em;
37 | line-height: 1.1;
38 | }
39 |
40 | #app {
41 | max-width: 1280px;
42 | margin: 0 auto;
43 | padding: 2rem;
44 | text-align: center;
45 | }
46 |
47 | .logo {
48 | height: 6em;
49 | padding: 1.5em;
50 | will-change: filter;
51 | }
52 | .logo:hover {
53 | filter: drop-shadow(0 0 2em #646cffaa);
54 | }
55 | .logo.vanilla:hover {
56 | filter: drop-shadow(0 0 2em #f7df1eaa);
57 | }
58 |
59 | .card {
60 | padding: 2em;
61 | }
62 |
63 | .read-the-docs {
64 | color: #888;
65 | }
66 |
67 | button {
68 | border-radius: 8px;
69 | border: 1px solid transparent;
70 | padding: 0.6em 1.2em;
71 | font-size: 1em;
72 | font-weight: 500;
73 | font-family: inherit;
74 | background-color: #1a1a1a;
75 | cursor: pointer;
76 | transition: border-color 0.25s;
77 | }
78 | button:hover {
79 | border-color: #646cff;
80 | }
81 | button:focus,
82 | button:focus-visible {
83 | outline: 4px auto -webkit-focus-ring-color;
84 | }
85 |
86 | @media (prefers-color-scheme: light) {
87 | :root {
88 | color: #213547;
89 | background-color: #ffffff;
90 | }
91 | a:hover {
92 | color: #747bff;
93 | }
94 | button {
95 | background-color: #f9f9f9;
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/tests/example.spec.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | import { test, expect } from '@playwright/test'
3 |
4 | const CAT_PREFIX_IMAGE_URL = 'https://cataas.com'
5 | const LOCALHOST_URL = 'http://localhost:5173/'
6 |
7 | test('app shows random fact and image', async ({ page }) => {
8 | await page.goto(LOCALHOST_URL)
9 |
10 | const text = await page.getByRole('paragraph')
11 | const image = await page.getByRole('img')
12 |
13 | const textContent = await text.textContent()
14 | const imageSrc = await image.getAttribute('src')
15 |
16 | await expect(textContent?.length).toBeGreaterThan(0)
17 | await expect(imageSrc?.startsWith(CAT_PREFIX_IMAGE_URL)).toBeTruthy()
18 | })
19 |
--------------------------------------------------------------------------------
/projects/04-react-prueba-tecnica/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | export default defineConfig({
5 | plugins: [react()]
6 | })
7 |
--------------------------------------------------------------------------------
/projects/05-react-buscador-peliculas/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/projects/05-react-buscador-peliculas/README.md:
--------------------------------------------------------------------------------
1 | ## Enunciado
2 |
3 | Crea una aplicación para buscar películas
4 |
5 | API a usar: - https://www.omdbapi.com/
6 | Consigue la API Key en la propia página web registrando tu email.
7 |
8 | Requerimientos:
9 |
10 | ✅ Necesita mostrar un input para buscar la película y un botón para buscar.
11 |
12 | ✅ Lista las películas y muestra el título, año y poster.
13 |
14 | ✅ Que el formulario funcione
15 |
16 | ✅ Haz que las películas se muestren en un grid responsive.
17 |
18 | ✅ Hacer el fetching de datos a la API
19 |
20 | Primera iteración:
21 |
22 | ✅ Evitar que se haga la misma búsqueda dos veces seguidas.
23 |
24 | ✅ Haz que la búsqueda se haga automáticamente al escribir.
25 |
26 | ✅ Evita que se haga la búsqueda continuamente al escribir (debounce)
27 |
--------------------------------------------------------------------------------
/projects/05-react-buscador-peliculas/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/projects/05-react-buscador-peliculas/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "05-react-buscador-peliculas",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "just-debounce-it": "3.2.0",
13 | "react": "^18.2.0",
14 | "react-dom": "^18.2.0"
15 | },
16 | "devDependencies": {
17 | "@types/react": "^18.0.27",
18 | "@types/react-dom": "^18.0.10",
19 | "@vitejs/plugin-react-swc": "^3.0.0",
20 | "vite": "^4.1.0"
21 | }
22 | }
--------------------------------------------------------------------------------
/projects/05-react-buscador-peliculas/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/05-react-buscador-peliculas/src/App.css:
--------------------------------------------------------------------------------
1 | .page {
2 | display: flex;
3 | flex-direction: column;
4 | justify-content: center;
5 | align-items: center;
6 | width: 100%;
7 | max-width: 800px;
8 | }
9 |
10 | main {
11 | display: flex;
12 | justify-content: center;
13 | width: 100%;
14 | }
15 |
16 | form {
17 | align-items: center;
18 | display: flex;
19 | justify-content: center;
20 | }
21 |
22 | .movies {
23 | list-style: none;
24 | margin: 0;
25 | padding: 0;
26 |
27 | display: grid;
28 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
29 |
30 | width: 100%;
31 | gap: 32px;
32 | }
33 |
34 | .movie {
35 | text-align: center;
36 | }
37 |
38 | .movie h3, .movie p {
39 | margin: 0;
40 | }
41 |
42 | .movie img {
43 | border-radius: 8px;
44 | margin-top: 16px;
45 | }
--------------------------------------------------------------------------------
/projects/05-react-buscador-peliculas/src/components/Movies.jsx:
--------------------------------------------------------------------------------
1 | function ListOfMovies ({ movies }) {
2 | return (
3 |
4 | {
5 | movies.map(movie => (
6 | -
7 |
{movie.title}
8 | {movie.year}
9 |
10 |
11 | ))
12 | }
13 |
14 | )
15 | }
16 |
17 | function NoMoviesResults () {
18 | return (
19 | No se encontraron películas para esta búsqueda
20 | )
21 | }
22 |
23 | export function Movies ({ movies }) {
24 | const hasMovies = movies?.length > 0
25 |
26 | return (
27 | hasMovies
28 | ?
29 | :
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/projects/05-react-buscador-peliculas/src/hooks/useMovies.js:
--------------------------------------------------------------------------------
1 | import { useRef, useState, useMemo, useCallback } from 'react'
2 | import { searchMovies } from '../services/movies.js'
3 |
4 | export function useMovies ({ search, sort }) {
5 | const [movies, setMovies] = useState([])
6 | const [loading, setLoading] = useState(false)
7 | // el error no se usa pero puedes implementarlo
8 | // si quieres:
9 | const [, setError] = useState(null)
10 | const previousSearch = useRef(search)
11 |
12 | const getMovies = useCallback(async ({ search }) => {
13 | if (search === previousSearch.current) return
14 | // search es ''
15 |
16 | try {
17 | setLoading(true)
18 | setError(null)
19 | previousSearch.current = search
20 | const newMovies = await searchMovies({ search })
21 | setMovies(newMovies)
22 | } catch (e) {
23 | setError(e.message)
24 | } finally {
25 | // tanto en el try como en el catch
26 | setLoading(false)
27 | }
28 | }, [])
29 |
30 | const sortedMovies = useMemo(() => {
31 | if (!movies) return;
32 | return sort
33 | ? [...movies].sort((a, b) => a.title.localeCompare(b.title))
34 | : movies
35 | }, [sort, movies])
36 |
37 | return { movies: sortedMovies, getMovies, loading }
38 | }
39 |
--------------------------------------------------------------------------------
/projects/05-react-buscador-peliculas/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App'
4 | import './index.css'
5 |
6 | ReactDOM.createRoot(document.getElementById('root')).render(
7 |
8 | )
9 |
--------------------------------------------------------------------------------
/projects/05-react-buscador-peliculas/src/mocks/no-results.json:
--------------------------------------------------------------------------------
1 | {
2 | "Response": "False",
3 | "Error": "Movie not found!"
4 | }
--------------------------------------------------------------------------------
/projects/05-react-buscador-peliculas/src/mocks/with-results.json:
--------------------------------------------------------------------------------
1 | {"Search":[{"Title":"The Avengers","Year":"2012","imdbID":"tt0848228","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BNDYxNjQyMjAtNTdiOS00NGYwLWFmNTAtNThmYjU5ZGI2YTI1XkEyXkFqcGdeQXVyMTMxODk2OTU@._V1_SX300.jpg"},{"Title":"Avengers: Endgame","Year":"2019","imdbID":"tt4154796","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BMTc5MDE2ODcwNV5BMl5BanBnXkFtZTgwMzI2NzQ2NzM@._V1_SX300.jpg"},{"Title":"Avengers: Infinity War","Year":"2018","imdbID":"tt4154756","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BMjMxNjY2MDU1OV5BMl5BanBnXkFtZTgwNzY1MTUwNTM@._V1_SX300.jpg"},{"Title":"Avengers: Age of Ultron","Year":"2015","imdbID":"tt2395427","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BMTM4OGJmNWMtOTM4Ni00NTE3LTg3MDItZmQxYjc4N2JhNmUxXkEyXkFqcGdeQXVyNTgzMDMzMTg@._V1_SX300.jpg"},{"Title":"The Avengers","Year":"1998","imdbID":"tt0118661","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BYWE1NTdjOWQtYTQ2Ny00Nzc5LWExYzMtNmRlOThmOTE2N2I4XkEyXkFqcGdeQXVyNjUwNzk3NDc@._V1_SX300.jpg"},{"Title":"The Avengers: Earth's Mightiest Heroes","Year":"2010–2012","imdbID":"tt1626038","Type":"series","Poster":"https://m.media-amazon.com/images/M/MV5BYzA4ZjVhYzctZmI0NC00ZmIxLWFmYTgtOGIxMDYxODhmMGQ2XkEyXkFqcGdeQXVyNjExODE1MDc@._V1_SX300.jpg"},{"Title":"Ultimate Avengers: The Movie","Year":"2006","imdbID":"tt0491703","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BMTYyMjk0NTMwMl5BMl5BanBnXkFtZTgwNzY0NjAwNzE@._V1_SX300.jpg"},{"Title":"Ultimate Avengers II","Year":"2006","imdbID":"tt0803093","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BZjI3MTI5ZTYtZmNmNy00OGZmLTlhNWMtNjZiYmYzNDhlOGRkL2ltYWdlL2ltYWdlXkEyXkFqcGdeQXVyNTAyODkwOQ@@._V1_SX300.jpg"},{"Title":"The Avengers","Year":"1961–1969","imdbID":"tt0054518","Type":"series","Poster":"https://m.media-amazon.com/images/M/MV5BZWQwZTdjMDUtNTY1YS00MDI0LWFkNjYtZDA4MDdmZjdlMDRlXkEyXkFqcGdeQXVyNjUwNzk3NDc@._V1_SX300.jpg"},{"Title":"Avengers Assemble","Year":"2012–2019","imdbID":"tt2455546","Type":"series","Poster":"https://m.media-amazon.com/images/M/MV5BMTY0NTUyMDQwOV5BMl5BanBnXkFtZTgwNjAwMTA0MDE@._V1_SX300.jpg"}],"totalResults":"144","Response":"True"}
--------------------------------------------------------------------------------
/projects/05-react-buscador-peliculas/src/services/movies.js:
--------------------------------------------------------------------------------
1 | const API_KEY = '4287ad07'
2 |
3 | export const searchMovies = async ({ search }) => {
4 | if (search === '') return null
5 |
6 | try {
7 | const response = await fetch(`https://www.omdbapi.com/?apikey=${API_KEY}&s=${search}`)
8 | const json = await response.json()
9 |
10 | const movies = json.Search
11 |
12 | return movies?.map(movie => ({
13 | id: movie.imdbID,
14 | title: movie.Title,
15 | year: movie.Year,
16 | image: movie.Poster
17 | }))
18 | } catch (e) {
19 | throw new Error('Error searching movies')
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/projects/05-react-buscador-peliculas/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()]
7 | })
8 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/README.md:
--------------------------------------------------------------------------------
1 | # Enunciado
2 |
3 | Ecommerce
4 |
5 | - [x] Muestra una lista de productos que vienen de un JSON
6 | - [x] Añade un filtro por categoría
7 | - [x] Añade un filtro por precio
8 |
9 | Haz uso de useContext para evitar pasar props innecesarias.
10 |
11 | Carrito:
12 |
13 | - [x] Haz que se puedan añadir los productos a un carrito.
14 | - [x] Haz que se puedan eliminar los productos del carrito.
15 | - [x] Haz que se puedan modificar la cantidad de productos del carrito.
16 | - [x] Sincroniza los cambios del carrito con la lista de productos.
17 | - [x] Guarda en un localStorage el carrito para que se recupere al recargar la página. (da puntos)
18 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "06-shopping-cart",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "react": "^18.2.0",
13 | "react-dom": "^18.2.0"
14 | },
15 | "devDependencies": {
16 | "@types/react": "^18.0.27",
17 | "@types/react-dom": "^18.0.10",
18 | "@vitejs/plugin-react-swc": "^3.0.0",
19 | "vite": "^4.1.0"
20 | }
21 | }
--------------------------------------------------------------------------------
/projects/06-shopping-cart/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { products as initialProducts } from './mocks/products.json'
2 | import { Products } from './components/Products.jsx'
3 | import { Header } from './components/Header.jsx'
4 | import { Footer } from './components/Footer.jsx'
5 | import { IS_DEVELOPMENT } from './config.js'
6 | import { useFilters } from './hooks/useFilters.js'
7 | import { Cart } from './components/Cart.jsx'
8 | import { CartProvider } from './context/cart.jsx'
9 |
10 | function App () {
11 | const { filterProducts } = useFilters()
12 |
13 | const filteredProducts = filterProducts(initialProducts)
14 |
15 | return (
16 |
17 |
18 |
19 |
20 | {IS_DEVELOPMENT && }
21 |
22 | )
23 | }
24 |
25 | export default App
26 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/components/Cart.css:
--------------------------------------------------------------------------------
1 | .cart {
2 | background: #000;
3 | display: none;
4 | padding: 32px;
5 | position: fixed;
6 | right: 0px;
7 | top: 0px;
8 | width: 200px;
9 | }
10 |
11 | .cart img {
12 | aspect-ratio: 16/9;
13 | width: 100%;
14 | }
15 |
16 | .cart li {
17 | border-bottom: 1px solid #444;
18 | padding-bottom: 16px;
19 | }
20 |
21 | .cart footer {
22 | display: flex;
23 | gap: 8px;
24 | justify-content: center;
25 | align-items: center;
26 | }
27 |
28 | .cart footer button {
29 | padding: 8px;
30 | }
31 |
32 | .cart-button {
33 | align-items: center;
34 | background: #09f;
35 | border-radius: 9999px;
36 | cursor: pointer;
37 | display: flex;
38 | height: 32px;
39 | justify-content: center;
40 | padding: 4px;
41 | position: absolute;
42 | right: 8px;
43 | top: 8px;
44 | transition: all .3s ease;
45 | width: 32px;
46 | z-index: 9999;
47 | }
48 |
49 | .cart-button:hover {
50 | scale: 1.1;
51 | }
52 |
53 | .cart-button ~ input:checked ~ .cart {
54 | height: 100%;
55 | display: block;
56 | }
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/components/Cart.jsx:
--------------------------------------------------------------------------------
1 | import './Cart.css'
2 |
3 | import { useId } from 'react'
4 | import { CartIcon, ClearCartIcon } from './Icons.jsx'
5 | import { useCart } from '../hooks/useCart.js'
6 |
7 | function CartItem ({ thumbnail, price, title, quantity, addToCart }) {
8 | return (
9 |
10 |
14 |
15 | {title} - ${price}
16 |
17 |
18 |
24 |
25 | )
26 | }
27 |
28 | export function Cart () {
29 | const cartCheckboxId = useId()
30 | const { cart, clearCart, addToCart } = useCart()
31 |
32 | return (
33 | <>
34 |
37 |
38 |
39 |
54 | >
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/components/Filters.css:
--------------------------------------------------------------------------------
1 | .filters {
2 | display: flex;
3 | align-items: center;
4 | justify-content: space-between;
5 | font-size: 14px;
6 | font-weight: 700;
7 | }
8 |
9 | .filters > div {
10 | display: flex;
11 | gap: 1rem;
12 | }
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/components/Filters.jsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react'
2 | import { useFilters } from '../hooks/useFilters.js'
3 | import './Filters.css'
4 |
5 | export function Filters () {
6 | const { filters, setFilters } = useFilters()
7 |
8 | const minPriceFilterId = useId()
9 | const categoryFilterId = useId()
10 |
11 | const handleChangeMinPrice = (event) => {
12 | setFilters(prevState => ({
13 | ...prevState,
14 | minPrice: event.target.value
15 | }))
16 | }
17 |
18 | const handleChangeCategory = (event) => {
19 | // ⬇️ ESTO HUELE MAL
20 | // estamos pasando la función de actualizar estado
21 | // nativa de React a un componente hijo
22 | setFilters(prevState => ({
23 | ...prevState,
24 | category: event.target.value
25 | }))
26 | }
27 |
28 | return (
29 |
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/components/Footer.css:
--------------------------------------------------------------------------------
1 | .footer {
2 | position: fixed;
3 | left: 16px;
4 | bottom: 16px;
5 | text-align: left;
6 | background: rgba(0, 0, 0, .7);
7 | padding: 8px 24px;
8 | border-radius: 32px;
9 | opacity: .95;
10 | backdrop-filter: blur(8px);
11 | }
12 |
13 | .footer span {
14 | font-size: 14px;
15 | color: #09f;
16 | opacity: .8;
17 | }
18 |
19 | .footer h4, .footer h5 {
20 | margin: 0;
21 | display: flex;
22 | }
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/components/Footer.jsx:
--------------------------------------------------------------------------------
1 | import './Footer.css'
2 |
3 | export function Footer () {
4 | // const { filters } = useFilters()
5 |
6 | return (
7 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/components/Header.jsx:
--------------------------------------------------------------------------------
1 | import { Filters } from './Filters.jsx'
2 |
3 | export function Header () {
4 | return (
5 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/components/Products.css:
--------------------------------------------------------------------------------
1 | .products {
2 | width: 100%;
3 | display: flex;
4 | justify-content: center;
5 | align-items: center;
6 | }
7 |
8 | .products ul {
9 | display: grid;
10 | grid-template-columns: repeat(
11 | auto-fit,
12 | minmax(
13 | 200px,
14 | 1fr
15 | )
16 | );
17 | gap: 1rem;
18 | }
19 |
20 | .products li {
21 | display: flex;
22 | flex-direction: column;
23 | gap: 1rem;
24 | box-shadow: 0 0 10px 10px rgba(0, 0, 0, .1);
25 | border-radius: 4px;
26 | background: #111;
27 | color: #fff;
28 | padding: 1rem;
29 | }
30 |
31 | .products h3 {
32 | margin: 0;
33 | }
34 |
35 | .products span {
36 | font-size: 1rem;
37 | opacity: .9;
38 | }
39 |
40 | .products img {
41 | border-radius: 4px;
42 | width: 100%;
43 | aspect-ratio: 16/9;
44 | display: block;
45 | object-fit: cover;
46 | background: #fff;
47 | }
48 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/components/Products.jsx:
--------------------------------------------------------------------------------
1 | import './Products.css'
2 | import { AddToCartIcon, RemoveFromCartIcon } from './Icons.jsx'
3 | import { useCart } from '../hooks/useCart.js'
4 |
5 | export function Products ({ products }) {
6 | const { addToCart, removeFromCart, cart } = useCart()
7 |
8 | const checkProductInCart = product => {
9 | return cart.some(item => item.id === product.id)
10 | }
11 |
12 | return (
13 |
14 |
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/config.js:
--------------------------------------------------------------------------------
1 | export const IS_DEVELOPMENT = process.env.NODE_ENV !== 'production'
2 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/context/cart.jsx:
--------------------------------------------------------------------------------
1 | import { useReducer, createContext } from 'react'
2 | import { cartReducer, cartInitialState } from '../reducers/cart.js'
3 |
4 | export const CartContext = createContext()
5 |
6 | function useCartReducer () {
7 | const [state, dispatch] = useReducer(cartReducer, cartInitialState)
8 |
9 | const addToCart = product => dispatch({
10 | type: 'ADD_TO_CART',
11 | payload: product
12 | })
13 |
14 | const removeFromCart = product => dispatch({
15 | type: 'REMOVE_FROM_CART',
16 | payload: product
17 | })
18 |
19 | const clearCart = () => dispatch({ type: 'CLEAR_CART' })
20 |
21 | return { state, addToCart, removeFromCart, clearCart }
22 | }
23 |
24 | // la dependencia de usar React Context
25 | // es MÍNIMA
26 | export function CartProvider ({ children }) {
27 | const { state, addToCart, removeFromCart, clearCart } = useCartReducer()
28 |
29 | return (
30 |
37 | {children}
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/context/filters.jsx:
--------------------------------------------------------------------------------
1 | import { createContext, useState } from 'react'
2 |
3 | // Este es el que tenemos que consumir
4 | export const FiltersContext = createContext()
5 |
6 | // Este es el que nos provee de acceso al contexto
7 | export function FiltersProvider ({ children }) {
8 | const [filters, setFilters] = useState({
9 | category: 'all',
10 | minPrice: 250
11 | })
12 |
13 | return (
14 |
19 | {children}
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/hooks/useCart.js:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react'
2 | import { CartContext } from '../context/cart.jsx'
3 |
4 | export const useCart = () => {
5 | const context = useContext(CartContext)
6 |
7 | if (context === undefined) {
8 | throw new Error('useCart must be used within a CartProvider')
9 | }
10 |
11 | return context
12 | }
13 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/hooks/useFilters.js:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react'
2 | import { FiltersContext } from '../context/filters.jsx'
3 |
4 | export function useFilters () {
5 | const { filters, setFilters } = useContext(FiltersContext)
6 |
7 | const filterProducts = (products) => {
8 | return products.filter(product => {
9 | return (
10 | product.price >= filters.minPrice &&
11 | (
12 | filters.category === 'all' ||
13 | product.category === filters.category
14 | )
15 | )
16 | })
17 | }
18 |
19 | return { filters, filterProducts, setFilters }
20 | }
21 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | -webkit-text-size-adjust: 100%;
15 | }
16 |
17 | #root {
18 | max-width: 600px;
19 | margin: 0 auto;
20 | padding: 2rem;
21 | text-align: center;
22 | width: 100%;
23 | }
24 |
25 | body {
26 | margin: 0;
27 | display: flex;
28 | place-items: center;
29 | min-width: 320px;
30 | min-height: 100vh;
31 | }
32 |
33 | button {
34 | border-radius: 8px;
35 | border: 1px solid transparent;
36 | padding: 0.6em 1.2em;
37 | font-size: 1em;
38 | font-weight: 500;
39 | font-family: inherit;
40 | background-color: #1a1a1a;
41 | cursor: pointer;
42 | transition: border-color 0.25s;
43 | }
44 | button:hover {
45 | border-color: #646cff;
46 | }
47 | button:focus,
48 | button:focus-visible {
49 | outline: 4px auto -webkit-focus-ring-color;
50 | }
51 |
52 | ul {
53 | list-style: none;
54 | padding: 0;
55 | }
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/main.jsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client'
2 | import App from './App'
3 | import { FiltersProvider } from './context/filters.jsx'
4 | import './index.css'
5 |
6 | ReactDOM.createRoot(document.getElementById('root')).render(
7 |
8 |
9 |
10 | )
11 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/src/reducers/cart.js:
--------------------------------------------------------------------------------
1 | export const cartInitialState = JSON.parse(window.localStorage.getItem('cart')) || []
2 |
3 | export const CART_ACTION_TYPES = {
4 | ADD_TO_CART: 'ADD_TO_CART',
5 | REMOVE_FROM_CART: 'REMOVE_FROM_CART',
6 | CLEAR_CART: 'CLEAR_CART'
7 | }
8 |
9 | // update localStorage with state for cart
10 | export const updateLocalStorage = state => {
11 | window.localStorage.setItem('cart', JSON.stringify(state))
12 | }
13 |
14 | const UPDATE_STATE_BY_ACTION = {
15 | [CART_ACTION_TYPES.ADD_TO_CART]: (state, action) => {
16 | const { id } = action.payload
17 | const productInCartIndex = state.findIndex(item => item.id === id)
18 |
19 | if (productInCartIndex >= 0) {
20 | // 👀 una forma sería usando structuredClone
21 | // const newState = structuredClone(state)
22 | // newState[productInCartIndex].quantity += 1
23 |
24 | // 👶 usando el map
25 | // const newState = state.map(item => {
26 | // if (item.id === id) {
27 | // return {
28 | // ...item,
29 | // quantity: item.quantity + 1
30 | // }
31 | // }
32 |
33 | // return item
34 | // })
35 |
36 | // ⚡ usando el spread operator y slice
37 | const newState = [
38 | ...state.slice(0, productInCartIndex),
39 | { ...state[productInCartIndex], quantity: state[productInCartIndex].quantity + 1 },
40 | ...state.slice(productInCartIndex + 1)
41 | ]
42 |
43 | updateLocalStorage(newState)
44 | return newState
45 | }
46 |
47 | const newState = [
48 | ...state,
49 | {
50 | ...action.payload, // product
51 | quantity: 1
52 | }
53 | ]
54 |
55 | updateLocalStorage(newState)
56 | return newState
57 | },
58 | [CART_ACTION_TYPES.REMOVE_FROM_CART]: (state, action) => {
59 | const { id } = action.payload
60 | const newState = state.filter(item => item.id !== id)
61 | updateLocalStorage(newState)
62 | return newState
63 | },
64 | [CART_ACTION_TYPES.CLEAR_CART]: () => {
65 | updateLocalStorage([])
66 | return []
67 | }
68 | }
69 |
70 | export const cartReducer = (state, action) => {
71 | const { type: actionType } = action
72 | const updateState = UPDATE_STATE_BY_ACTION[actionType]
73 | return updateState ? updateState(state, action) : state
74 | }
75 |
--------------------------------------------------------------------------------
/projects/06-shopping-cart/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()]
7 | })
8 |
--------------------------------------------------------------------------------
/projects/07-midu-router/.npmignore:
--------------------------------------------------------------------------------
1 | src
2 | public
3 | index.html
4 | pnpm-lock.yaml
5 | vite.config.js
6 | .swcrc
--------------------------------------------------------------------------------
/projects/07-midu-router/.swcrc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/swcrc",
3 | "jsc": {
4 | "parser": {
5 | "syntax": "ecmascript",
6 | "jsx": true,
7 | "dynamicImport": false,
8 | "privateMethod": false,
9 | "functionBind": false,
10 | "exportDefaultFrom": false,
11 | "exportNamespaceFrom": false,
12 | "decorators": false,
13 | "decoratorsBeforeExport": false,
14 | "topLevelAwait": false,
15 | "importMeta": false
16 | },
17 | "transform": {
18 | "react": {
19 | "runtime": "automatic"
20 | }
21 | },
22 | "target": "es2020",
23 | "loose": true,
24 | "externalHelpers": false,
25 | // Requires v1.2.50 or upper and requires target to be es2016 or upper.
26 | "keepClassNames": false
27 | },
28 | "minify": true
29 | }
--------------------------------------------------------------------------------
/projects/07-midu-router/README.md:
--------------------------------------------------------------------------------
1 | # Crea un React Router desde cero
2 |
3 | - [x] Instalar el linter
4 | - [x] Crear una forma de hacer MPAs (Multiple Page Application)
5 | - [x] Crea una forma de hacer SPAs (Single Page Applications)
6 | - [x] Poder navegar entre páginas con el botón de atrás
7 | - [x] Crear componente Link para hacerlo declarativo
8 | - [x] Crear componente Router para hacerlo más declarativo
9 | - [x] Soportar ruta por defecto (404)
10 | - [x] Soportar rutas con parámetros
11 | - [x] Componente para hacerlo declarativo
12 | - [x] Lazy Loading de las rutas
13 | - [x] Hacer un i18n con las rutas
14 | - [x] Testing
15 | - [x] Publicar el paquete en NPM
16 |
17 |
--------------------------------------------------------------------------------
/projects/07-midu-router/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | midu-router demo
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/projects/07-midu-router/lib/Link.js:
--------------------------------------------------------------------------------
1 | import{jsx as _jsx}from"react/jsx-runtime";import{BUTTONS,EVENTS}from"./consts.js";export function navigate(href){window.history.pushState({},"",href);const navigationEvent=new Event(EVENTS.PUSHSTATE);window.dispatchEvent(navigationEvent)}export function Link({target,to,...props}){const handleClick=event=>{const isMainEvent=event.button===BUTTONS.primary;const isModifiedEvent=event.metaKey||event.altKey||event.ctrlKey||event.shiftKey;const isManageableEvent=target===undefined||target==="_self";if(isMainEvent&&isManageableEvent&&!isModifiedEvent){event.preventDefault();navigate(to);window.scrollTo(0,0)}};return _jsx("a",{onClick:handleClick,href:to,target:target,...props})}
--------------------------------------------------------------------------------
/projects/07-midu-router/lib/Route.js:
--------------------------------------------------------------------------------
1 | export function Route({path,Component}){return null}
--------------------------------------------------------------------------------
/projects/07-midu-router/lib/Router.js:
--------------------------------------------------------------------------------
1 | import{jsx as _jsx}from"react/jsx-runtime";import{EVENTS}from"./consts.js";import{useState,useEffect,Children}from"react";import{match}from"path-to-regexp";import{getCurrentPath}from"./utils.js";export function Router({children,routes=[],defaultComponent:DefaultComponent=()=>_jsx("h1",{children:"404"})}){const[currentPath,setCurrentPath]=useState(getCurrentPath());useEffect(()=>{const onLocationChange=()=>{setCurrentPath(getCurrentPath())};window.addEventListener(EVENTS.PUSHSTATE,onLocationChange);window.addEventListener(EVENTS.POPSTATE,onLocationChange);return()=>{window.removeEventListener(EVENTS.PUSHSTATE,onLocationChange);window.removeEventListener(EVENTS.POPSTATE,onLocationChange)}},[]);let routeParams={};const routesFromChildren=Children.map(children,({props,type})=>{const{name}=type;const isRoute=name==="Route";return isRoute?props:null});const routesToUse=routes.concat(routesFromChildren).filter(Boolean);const Page=routesToUse.find(({path})=>{if(path===currentPath)return true;const matcherUrl=match(path,{decode:decodeURIComponent});const matched=matcherUrl(currentPath);if(!matched)return false;routeParams=matched.params;return true})?.Component;return Page?_jsx(Page,{routeParams:routeParams}):_jsx(DefaultComponent,{routeParams:routeParams})}
--------------------------------------------------------------------------------
/projects/07-midu-router/lib/index.js:
--------------------------------------------------------------------------------
1 | export{Router}from"./Router";export{Link}from"./Link";export{Route}from"./Route";
--------------------------------------------------------------------------------
/projects/07-midu-router/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "midu-router",
3 | "version": "0.0.6",
4 | "type": "module",
5 | "main": "lib/index.js",
6 | "module": "lib/index.js",
7 | "exports": {
8 | ".": {
9 | "import": "./lib/index.js",
10 | "require": "./lib/index.js"
11 | },
12 | "./package.json": "./package.json"
13 | },
14 | "scripts": {
15 | "dev": "vite",
16 | "build": "vite build",
17 | "prepare": "npm run test && swc src/components src/utils src/index.jsx -d lib",
18 | "preview": "vite preview",
19 | "test": "echo",
20 | "test:watch": "vitest",
21 | "test:ui": "vitest --ui"
22 | },
23 | "dependencies": {
24 | "path-to-regexp": "6.2.1"
25 | },
26 | "peerDependencies": {
27 | "react": "18.2.0",
28 | "react-dom": "18.2.0"
29 | },
30 | "devDependencies": {
31 | "@swc/cli": "0.1.62",
32 | "@swc/core": "1.3.36",
33 | "@testing-library/dom": "9.0.0",
34 | "@testing-library/react": "14.0.0",
35 | "@types/react": "18.0.27",
36 | "@types/react-dom": "18.0.10",
37 | "@vitejs/plugin-react-swc": "3.0.0",
38 | "@vitest/ui": "0.28.5",
39 | "happy-dom": "8.7.1",
40 | "standard": "17.0.0",
41 | "vite": "4.1.0",
42 | "vitest": "0.28.5"
43 | },
44 | "eslintConfig": {
45 | "extends": [
46 | "./node_modules/standard/eslintrc.json"
47 | ]
48 | }
49 | }
--------------------------------------------------------------------------------
/projects/07-midu-router/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/07-midu-router/src/App.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/midudev/aprendiendo-react/2de1877d7b634fe7f2f4573999b1e057d1abcef5/projects/07-midu-router/src/App.css
--------------------------------------------------------------------------------
/projects/07-midu-router/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { lazy, Suspense } from 'react'
2 |
3 | import Page404 from './pages/404.jsx'
4 | import SearchPage from './pages/Search.jsx'
5 |
6 | import { Router } from './components/Router.jsx'
7 | import { Route } from './components/Route.jsx'
8 |
9 | const LazyHomePage = lazy(() => import('./pages/Home.jsx'))
10 | const LazyAboutPage = lazy(() => import('./pages/About.jsx'))
11 |
12 | const appRoutes = [
13 | {
14 | path: '/:lang/about',
15 | Component: LazyAboutPage
16 | },
17 | {
18 | path: '/search/:query',
19 | Component: SearchPage
20 | }
21 | ]
22 |
23 | function App () {
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | )
34 | }
35 |
36 | export default App
37 |
--------------------------------------------------------------------------------
/projects/07-midu-router/src/Router.test.jsx:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, beforeEach, vi } from 'vitest'
2 | import { render, screen, cleanup, fireEvent } from '@testing-library/react'
3 | import { Router } from './components/Router.jsx'
4 | import { Route } from './components/Route.jsx'
5 | import { Link } from './components/Link.jsx'
6 | import { getCurrentPath } from './utils/getCurrentPath.js'
7 |
8 | vi.mock('./utils/getCurrentPath.js', () => ({
9 | getCurrentPath: vi.fn()
10 | }))
11 |
12 | describe('Router', () => {
13 | beforeEach(() => {
14 | cleanup()
15 | vi.clearAllMocks()
16 | })
17 |
18 | it('should render without problems', () => {
19 | render()
20 | expect(true).toBeTruthy()
21 | })
22 |
23 | it('should render 404 if no routes match', () => {
24 | render( 404
} />)
25 | expect(screen.getByText('404')).toBeTruthy()
26 | })
27 |
28 | it('should render the component of the first route that matches', () => {
29 | getCurrentPath.mockReturnValue('/about')
30 |
31 | const routes = [
32 | {
33 | path: '/',
34 | Component: () => Home
35 | },
36 | {
37 | path: '/about',
38 | Component: () => About
39 | }
40 | ]
41 |
42 | render()
43 | expect(screen.getByText('About')).toBeTruthy()
44 | })
45 |
46 | it('should navigate using Links', async () => {
47 | getCurrentPath.mockReturnValueOnce('/')
48 |
49 | render(
50 |
51 | {
53 | return (
54 | <>
55 | Home
56 | Go to About
57 | >
58 | )
59 | }}
60 | />
61 | About
} />
62 |
63 | )
64 |
65 | // Click on the link
66 | const anchor = screen.getByText(/Go to About/)
67 | fireEvent.click(anchor)
68 |
69 | const aboutTitle = await screen.findByText('About')
70 |
71 | // Check that the new route is rendered
72 | expect(aboutTitle).toBeTruthy()
73 | })
74 | })
75 |
--------------------------------------------------------------------------------
/projects/07-midu-router/src/components/Link.jsx:
--------------------------------------------------------------------------------
1 | import { BUTTONS, EVENTS } from '../utils/consts.js'
2 |
3 | export function navigate (href) {
4 | window.history.pushState({}, '', href)
5 | const navigationEvent = new Event(EVENTS.PUSHSTATE)
6 | window.dispatchEvent(navigationEvent)
7 | }
8 |
9 | export function Link ({ target, to, ...props }) {
10 | const handleClick = (event) => {
11 | const isMainEvent = event.button === BUTTONS.primary // primary click
12 | const isModifiedEvent = event.metaKey || event.altKey || event.ctrlKey || event.shiftKey
13 | const isManageableEvent = target === undefined || target === '_self'
14 |
15 | if (isMainEvent && isManageableEvent && !isModifiedEvent) {
16 | event.preventDefault()
17 | navigate(to) // navegación con SPA
18 | window.scrollTo(0, 0)
19 | }
20 | }
21 |
22 | return
23 | }
24 |
--------------------------------------------------------------------------------
/projects/07-midu-router/src/components/Route.jsx:
--------------------------------------------------------------------------------
1 | export function Route ({ path, Component }) {
2 | return null
3 | }
4 |
--------------------------------------------------------------------------------
/projects/07-midu-router/src/components/Router.jsx:
--------------------------------------------------------------------------------
1 | import { EVENTS } from '../utils/consts.js'
2 | import { useState, useEffect, Children } from 'react'
3 | import { match } from 'path-to-regexp'
4 | import { getCurrentPath } from '../utils/getCurrentPath.js'
5 |
6 | export function Router ({ children, routes = [], defaultComponent: DefaultComponent = () => 404
}) {
7 | const [currentPath, setCurrentPath] = useState(getCurrentPath())
8 |
9 | useEffect(() => {
10 | const onLocationChange = () => {
11 | setCurrentPath(getCurrentPath())
12 | }
13 |
14 | window.addEventListener(EVENTS.PUSHSTATE, onLocationChange)
15 | window.addEventListener(EVENTS.POPSTATE, onLocationChange)
16 |
17 | return () => {
18 | window.removeEventListener(EVENTS.PUSHSTATE, onLocationChange)
19 | window.removeEventListener(EVENTS.POPSTATE, onLocationChange)
20 | }
21 | }, [])
22 |
23 | let routeParams = {}
24 |
25 | // add routes from children components
26 | const routesFromChildren = Children.map(children, ({ props, type }) => {
27 | const { name } = type
28 | const isRoute = name === 'Route'
29 | return isRoute ? props : null
30 | })
31 |
32 | const routesToUse = routes.concat(routesFromChildren).filter(Boolean)
33 |
34 | const Page = routesToUse.find(({ path }) => {
35 | if (path === currentPath) return true
36 |
37 | // hemos usado path-to-regexp
38 | // para poder detectar rutas dinámicas como por ejemplo
39 | // /search/:query <- :query es una ruta dinámica
40 | const matcherUrl = match(path, { decode: decodeURIComponent })
41 | const matched = matcherUrl(currentPath)
42 | if (!matched) return false
43 |
44 | // guardar los parámetros de la url que eran dinámicos
45 | // y que hemos extraído con path-to-regexp
46 | // por ejemplo, si la ruta es /search/:query
47 | // y la url es /search/javascript
48 | // matched.params.query === 'javascript'
49 | routeParams = matched.params
50 | return true
51 | })?.Component
52 |
53 | return Page
54 | ?
55 | :
56 | }
57 |
--------------------------------------------------------------------------------
/projects/07-midu-router/src/index.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/midudev/aprendiendo-react/2de1877d7b634fe7f2f4573999b1e057d1abcef5/projects/07-midu-router/src/index.css
--------------------------------------------------------------------------------
/projects/07-midu-router/src/index.jsx:
--------------------------------------------------------------------------------
1 | export { Router } from './components/Router'
2 | export { Link } from './components/Link'
3 | export { Route } from './components/Route'
4 |
--------------------------------------------------------------------------------
/projects/07-midu-router/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App'
4 | import './index.css'
5 |
6 | ReactDOM.createRoot(document.getElementById('root')).render(
7 |
8 | )
9 |
--------------------------------------------------------------------------------
/projects/07-midu-router/src/pages/404.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from '../components/Link'
2 |
3 | export default function Page404 () {
4 | return (
5 | <>
6 |
7 |
This is NOT fine
8 |

9 |
10 | Volver a la Home
11 | >
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/projects/07-midu-router/src/pages/About.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from '../components/Link'
2 |
3 | const i18n = {
4 | es: {
5 | title: 'Sobre nosotros',
6 | button: 'Ir a la home',
7 | description: '¡Hola! Me llamo Miguel Ángel y estoy creando un clon de React Router.'
8 | },
9 | en: {
10 | title: 'About us',
11 | button: 'Go to home page',
12 | description: 'Hi! My name is Miguel Ángel and I am creating a clone of React Router.'
13 | }
14 | }
15 |
16 | const useI18n = (lang) => {
17 | return i18n[lang] || i18n.en
18 | }
19 |
20 | export default function AboutPage ({ routeParams }) {
21 | const i18n = useI18n(routeParams.lang ?? 'es')
22 |
23 | return (
24 | <>
25 | {i18n.title}
26 |
27 |

28 |
{i18n.description}
29 |
30 | {i18n.button}
31 | >
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/projects/07-midu-router/src/pages/Home.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from '../components/Link'
2 |
3 | export default function HomePage () {
4 | return (
5 | <>
6 | Home
7 | Esta es una página de ejemplo para crear un React Router desde cero
8 | Ir a Sobre nosotros
9 | >
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/projects/07-midu-router/src/pages/Search.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 |
3 | export default function SearchPage ({ routeParams }) {
4 | useEffect(() => {
5 | document.title = `Has buscado ${routeParams.query}`
6 | }, [])
7 |
8 | return (
9 | Has buscado {routeParams.query}
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/projects/07-midu-router/src/utils/consts.js:
--------------------------------------------------------------------------------
1 | export const EVENTS = {
2 | PUSHSTATE: 'pushstate',
3 | POPSTATE: 'popstate'
4 | }
5 |
6 | export const BUTTONS = {
7 | primary: 0
8 | }
9 |
--------------------------------------------------------------------------------
/projects/07-midu-router/src/utils/getCurrentPath.js:
--------------------------------------------------------------------------------
1 | export const getCurrentPath = () => window.location.pathname
2 |
--------------------------------------------------------------------------------
/projects/07-midu-router/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | test: {
8 | environment: 'happy-dom'
9 | }
10 | })
11 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "todo-app-typescript",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview",
10 | "lint": "ts-standard"
11 | },
12 | "dependencies": {
13 | "@formkit/auto-animate": "1.0.0-beta.6",
14 | "react": "18.2.0",
15 | "react-dom": "18.2.0",
16 | "todomvc-app-css": "2.4.2"
17 | },
18 | "devDependencies": {
19 | "@types/react": "18.0.28",
20 | "@types/react-dom": "18.0.11",
21 | "@typescript-eslint/eslint-plugin": "^5.54.0",
22 | "@vitejs/plugin-react-swc": "3.2.0",
23 | "eslint": "^8.35.0",
24 | "eslint-config-standard-with-typescript": "^34.0.0",
25 | "eslint-plugin-import": "^2.27.5",
26 | "eslint-plugin-n": "^15.6.1",
27 | "eslint-plugin-promise": "^6.1.1",
28 | "eslint-plugin-react": "^7.32.2",
29 | "typescript": "^4.9.5",
30 | "vite": "4.1.4"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Copyright } from './components/Copyright'
2 | import { Footer } from './components/Footer'
3 | import { Header } from './components/Header'
4 | import { Todos } from './components/Todos'
5 | import { useTodos } from './hooks/useTodos'
6 |
7 | const App: React.FC = () => {
8 | const {
9 | activeCount,
10 | completedCount,
11 | filterSelected,
12 | handleClearCompleted,
13 | handleCompleted,
14 | handleFilterChange,
15 | handleRemove,
16 | handleSave,
17 | handleUpdateTitle,
18 | todos: filteredTodos
19 | } = useTodos()
20 |
21 | return (
22 | <>
23 |
24 |
25 |
31 |
38 |
39 |
40 | >
41 | )
42 | }
43 |
44 | export default App
45 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/components/Copyright.css:
--------------------------------------------------------------------------------
1 | .copyright {
2 | filter: invert(1);
3 | border: 1px solid white;
4 | color: white;
5 | position: fixed;
6 | left: 16px;
7 | bottom: 16px;
8 | text-align: left;
9 | padding: 8px 24px;
10 | border-radius: 32px;
11 | opacity: .95;
12 | }
13 |
14 | .copyright span {
15 | font-size: 14px;
16 | color: #09f;
17 | opacity: .8;
18 | }
19 |
20 | .copyright h4, .copyright h5 {
21 | margin: 0;
22 | display: flex;
23 | }
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/components/Copyright.tsx:
--------------------------------------------------------------------------------
1 | import './Copyright.css'
2 |
3 | export const Copyright: React.FC = () => (
4 |
8 | )
9 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/components/CreateTodo.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | interface Props {
4 | saveTodo: (title: string) => void
5 | }
6 |
7 | export const CreateTodo: React.FC = ({ saveTodo }) => {
8 | const [inputValue, setInputValue] = useState('')
9 |
10 | const handleKeyDown: React.KeyboardEventHandler = (e) => {
11 | if (e.key === 'Enter' && inputValue !== '') {
12 | saveTodo(inputValue)
13 | setInputValue('')
14 | }
15 | }
16 |
17 | return (
18 | { setInputValue(e.target.value) }}
22 | onKeyDown={handleKeyDown}
23 | placeholder='¿Qué quieres hacer?'
24 | autoFocus
25 | />
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/components/Filters.tsx:
--------------------------------------------------------------------------------
1 | import { TODO_FILTERS } from '../consts.js'
2 | import { type FilterValue } from '../types.js'
3 |
4 | const FILTERS_BUTTONS = {
5 | [TODO_FILTERS.ALL]: { literal: 'All', href: `/?filter=${TODO_FILTERS.ALL}` },
6 | [TODO_FILTERS.ACTIVE]: { literal: 'Active', href: `/?filter=${TODO_FILTERS.ACTIVE}` },
7 | [TODO_FILTERS.COMPLETED]: { literal: 'Completed', href: `/?filter=${TODO_FILTERS.COMPLETED}` }
8 | } as const
9 |
10 | interface Props {
11 | handleFilterChange: (filter: FilterValue) => void
12 | filterSelected: typeof TODO_FILTERS[keyof typeof TODO_FILTERS]
13 | }
14 |
15 | export const Filters: React.FC = ({ filterSelected, handleFilterChange }) => {
16 | const handleClick = (filter: FilterValue) => (e: React.MouseEvent) => {
17 | e.preventDefault()
18 | handleFilterChange(filter)
19 | }
20 |
21 | return (
22 |
23 | {
24 | Object.entries(FILTERS_BUTTONS).map(([key, { href, literal }]) => {
25 | const isSelected = key === filterSelected
26 | const className = isSelected ? 'selected' : ''
27 |
28 | return (
29 | -
30 | {literal}
33 |
34 |
35 | )
36 | })
37 | }
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { type FilterValue } from '../types'
2 | import { Filters } from './Filters'
3 |
4 | interface Props {
5 | handleFilterChange: (filter: FilterValue) => void
6 | activeCount: number
7 | completedCount: number
8 | onClearCompleted: () => void
9 | filterSelected: FilterValue
10 | }
11 |
12 | export const Footer: React.FC = ({
13 | activeCount,
14 | completedCount,
15 | onClearCompleted,
16 | filterSelected,
17 | handleFilterChange
18 | }) => {
19 | const singleActiveCount = activeCount === 1
20 | const activeTodoWord = singleActiveCount ? 'tarea' : 'tareas'
21 |
22 | return (
23 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { CreateTodo } from './CreateTodo'
2 |
3 | interface Props {
4 | saveTodo: (title: string) => void
5 | }
6 |
7 | export const Header: React.FC = ({ saveTodo }) => {
8 | return (
9 |
10 | todo
11 |
14 |
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/components/Todo.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react'
2 |
3 | interface Props {
4 | id: string
5 | title: string
6 | completed: boolean
7 | setCompleted: (id: string, completed: boolean) => void
8 | setTitle: (params: { id: string, title: string }) => void
9 | isEditing: string
10 | setIsEditing: (completed: string) => void
11 | removeTodo: (id: string) => void
12 | }
13 |
14 | export const Todo: React.FC = ({
15 | id,
16 | title,
17 | completed,
18 | setCompleted,
19 | setTitle,
20 | removeTodo,
21 | isEditing,
22 | setIsEditing
23 | }) => {
24 | const [editedTitle, setEditedTitle] = useState(title)
25 | const inputEditTitle = useRef(null)
26 |
27 | const handleKeyDown: React.KeyboardEventHandler = (e) => {
28 | if (e.key === 'Enter') {
29 | setEditedTitle(editedTitle.trim())
30 |
31 | if (editedTitle !== title) {
32 | setTitle({ id, title: editedTitle })
33 | }
34 |
35 | if (editedTitle === '') removeTodo(id)
36 |
37 | setIsEditing('')
38 | }
39 |
40 | if (e.key === 'Escape') {
41 | setEditedTitle(title)
42 | setIsEditing('')
43 | }
44 | }
45 |
46 | useEffect(() => {
47 | inputEditTitle.current?.focus()
48 | }, [isEditing])
49 |
50 | return (
51 | <>
52 |
53 | { setCompleted(id, e.target.checked) }}
58 | />
59 |
60 |
61 |
62 |
63 | { setEditedTitle(e.target.value) }}
67 | onKeyDown={handleKeyDown}
68 | onBlur={() => { setIsEditing('') }}
69 | ref={inputEditTitle}
70 | />
71 | >
72 | )
73 | }
74 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/components/Todos.tsx:
--------------------------------------------------------------------------------
1 | import { Todo } from './Todo'
2 | import type { Todo as TodoType } from '../types'
3 | import { useState } from 'react'
4 | import { useAutoAnimate } from '@formkit/auto-animate/react'
5 |
6 | interface Props {
7 | todos: TodoType[]
8 | setCompleted: (id: string, completed: boolean) => void
9 | setTitle: (params: Omit) => void
10 | removeTodo: (id: string) => void
11 | }
12 |
13 | export const Todos: React.FC = ({
14 | todos,
15 | setCompleted,
16 | setTitle,
17 | removeTodo
18 | }) => {
19 | const [isEditing, setIsEditing] = useState('')
20 | const [parent] = useAutoAnimate(/* optional config */)
21 |
22 | return (
23 |
24 | {todos?.map((todo) => (
25 | - { setIsEditing(todo.id) }}
28 | className={`
29 | ${todo.completed ? 'completed' : ''}
30 | ${isEditing === todo.id ? 'editing' : ''}
31 | `}
32 | >
33 |
44 |
45 | ))}
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/consts.ts:
--------------------------------------------------------------------------------
1 | export const TODO_FILTERS = {
2 | ALL: 'all',
3 | ACTIVE: 'active',
4 | COMPLETED: 'completed'
5 | } as const
6 |
7 | export const KEY_CODES = {
8 | ENTER: 13,
9 | ESCAPE: 27
10 | } as const
11 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/index.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/midudev/aprendiendo-react/2de1877d7b634fe7f2f4573999b1e057d1abcef5/projects/08-todo-app-typescript/src/index.css
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/main.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client'
2 | import App from './App'
3 | import './index.css'
4 | import 'todomvc-app-css/index.css'
5 |
6 | ReactDOM.createRoot(
7 | document.getElementById('root') as HTMLElement)
8 | .render(
9 |
10 | )
11 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/mocks/todos.ts:
--------------------------------------------------------------------------------
1 | export const mockAllCompletedTodos = [
2 | {
3 | completed: true,
4 | id: '7b6d5f38-e510-4409-aeb0-1f6f6422384e',
5 | title: 'Ver el stream de midu'
6 | },
7 | {
8 | completed: true,
9 | id: 'efad0afc-7d2e-4020-8ef4-14fd0b832de8',
10 | title: 'Aprender React con el curso de midu'
11 | },
12 | {
13 | completed: true,
14 | id: '6a3d0d0f-d2d6-4d2a-9b08-5a5d8a5e0c1d',
15 | title: 'Mover las manitas'
16 | }
17 | ]
18 |
19 | export const mockAllActiveTodos = [
20 | {
21 | completed: false,
22 | id: 'c19f8c9b-ae32-4c8a-9bed-d141b09f5477',
23 | title: 'Hacer ejercicio de vez en cuando'
24 | },
25 | {
26 | completed: false,
27 | id: 'efad0afc-7d2e-4020-8ef4-14fd0b832de8',
28 | title: 'Seguir a midu en TikTok'
29 | },
30 | {
31 | completed: false,
32 | id: '6a3d0d0f-d2d6-4d2a-9b08-5a5d8a5e0c1d',
33 | title: 'Darle estrellita al repo de midu'
34 | }
35 | ]
36 |
37 | export const mockTodos = [
38 | {
39 | completed: false,
40 | id: 'c19f8c9b-ae32-4c8a-9bed-d141b09f5477',
41 | title: 'Sacar al miduperro a pasear'
42 | },
43 | {
44 | completed: true,
45 | id: 'efad0afc-7d2e-4020-8ef4-14fd0b832de8',
46 | title: 'Ir a por el pan'
47 | },
48 | {
49 | completed: false,
50 | id: '6a3d0d0f-d2d6-4d2a-9b08-5a5d8a5e0c1d',
51 | title: 'Participar en la Hackathon de Cloudinary'
52 | }
53 | ]
54 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/services/todos.ts:
--------------------------------------------------------------------------------
1 | import { type TodoList } from '../types'
2 |
3 | const API_URL = 'https://api.jsonbin.io/v3/b/63ff3a52ebd26539d087639c'
4 |
5 | interface Todo {
6 | id: string
7 | title: string
8 | completed: boolean
9 | order: number
10 | }
11 |
12 | export const fetchTodos = async (): Promise => {
13 | const res = await fetch(API_URL)
14 | if (!res.ok) {
15 | console.error('Error fetching todos')
16 | return []
17 | }
18 |
19 | const { record: todos } = await res.json() as { record: Todo[] }
20 | return todos
21 | }
22 |
23 | export const updateTodos = async ({ todos }: { todos: TodoList }): Promise => {
24 | console.log(import.meta.env.VITE_API_BIN_KEY)
25 | const res = await fetch(API_URL, {
26 | method: 'PUT',
27 | headers: {
28 | 'Content-Type': 'application/json',
29 | 'X-Master-Key': import.meta.env.VITE_API_BIN_KEY
30 | },
31 | body: JSON.stringify(todos)
32 | })
33 |
34 | return res.ok
35 | }
36 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/types.d.ts:
--------------------------------------------------------------------------------
1 | import type { TODO_FILTERS } from './consts'
2 |
3 | export interface Todo {
4 | id: string
5 | title: string
6 | completed: boolean
7 | }
8 |
9 | export type TodoId = Pick
10 | export type TodoTitle = Pick
11 |
12 | export type FilterValue = typeof TODO_FILTERS[keyof typeof TODO_FILTERS]
13 |
14 | export type TodoList = Todo[]
15 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface ImportMetaEnv {
4 | readonly VITE_API_BIN_KEY: string
5 | // more env variables...
6 | }
7 |
8 | interface ImportMeta {
9 | readonly env: ImportMetaEnv
10 | }
11 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src", "vite.config.ts"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/projects/08-todo-app-typescript/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()]
7 | })
8 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/.env:
--------------------------------------------------------------------------------
1 | VITE_OPENAI_API_KEY="TU API KEY AQUI"
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true
5 | },
6 | extends: [
7 | 'plugin:react/recommended',
8 | 'standard-with-typescript'
9 | ],
10 | overrides: [
11 | ],
12 | parserOptions: {
13 | ecmaVersion: 'latest',
14 | sourceType: 'module',
15 | project: './tsconfig.json'
16 | },
17 | plugins: [
18 | 'react'
19 | ],
20 | rules: {
21 | '@typescript-eslint/explicit-function-return-type': 'off',
22 | 'react/react-in-jsx-scope': 'off',
23 | 'react/prop-types': 'off'
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "google-translate-clone",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview",
10 | "test": "vitest"
11 | },
12 | "dependencies": {
13 | "bootstrap": "5.2.3",
14 | "openai": "3.2.1",
15 | "react": "^18.2.0",
16 | "react-bootstrap": "2.7.2",
17 | "react-dom": "^18.2.0"
18 | },
19 | "devDependencies": {
20 | "@testing-library/react": "^14.0.0",
21 | "@testing-library/user-event": "^14.4.3",
22 | "@types/react": "^18.0.28",
23 | "@types/react-dom": "^18.0.11",
24 | "@typescript-eslint/eslint-plugin": "^5.57.0",
25 | "@vitejs/plugin-react-swc": "^3.0.0",
26 | "eslint": "^8.37.0",
27 | "eslint-config-standard-with-typescript": "^34.0.1",
28 | "eslint-plugin-import": "^2.27.5",
29 | "eslint-plugin-n": "^15.7.0",
30 | "eslint-plugin-promise": "^6.1.1",
31 | "eslint-plugin-react": "^7.32.2",
32 | "happy-dom": "^8.9.0",
33 | "typescript": "^5.0.2",
34 | "vite": "^4.2.0",
35 | "vitest": "^0.29.8"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 800px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | }
6 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import { test, expect } from 'vitest'
2 | import { render } from '@testing-library/react'
3 | import userEvent from '@testing-library/user-event'
4 | import App from './App'
5 |
6 | test('My App works as expected', async () => {
7 | const user = userEvent.setup()
8 | const app = render()
9 |
10 | const textareaFrom = app.getByPlaceholderText('Introducir texto')
11 |
12 | await user.type(textareaFrom, 'Hola mundo')
13 | const result = await app.findByDisplayValue(/Hello world/i, {}, { timeout: 2000 })
14 |
15 | expect(result).toBeTruthy()
16 | })
17 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/src/components/Icons.tsx:
--------------------------------------------------------------------------------
1 | export const ArrowsIcon = () => (
2 |
3 | )
4 |
5 | export const ClipboardIcon = () => (
6 |
7 | )
8 |
9 | export const SpeakerIcon = () => (
10 |
11 | )
12 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/src/components/LanguageSelector.tsx:
--------------------------------------------------------------------------------
1 | import { Form } from 'react-bootstrap'
2 | import { AUTO_LANGUAGE, SUPPORTED_LANGUAGES } from '../constants'
3 | import { SectionType, type FromLanguage, type Language } from '../types.d'
4 |
5 | type Props =
6 | | { type: SectionType.From, value: FromLanguage, onChange: (language: FromLanguage) => void }
7 | | { type: SectionType.To, value: Language, onChange: (language: Language) => void }
8 |
9 | export const LanguageSelector = ({ onChange, type, value }: Props) => {
10 | const handleChange = (event: React.ChangeEvent) => {
11 | onChange(event.target.value as Language)
12 | }
13 |
14 | return (
15 |
16 | {type === SectionType.From && }
17 |
18 | {Object.entries(SUPPORTED_LANGUAGES).map(([key, literal]) => (
19 |
22 | ))}
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/src/components/TextArea.tsx:
--------------------------------------------------------------------------------
1 | import { Form } from 'react-bootstrap'
2 | import { SectionType } from '../types.d'
3 |
4 | interface Props {
5 | type: SectionType
6 | loading?: boolean
7 | onChange: (value: string) => void
8 | value: string
9 | }
10 |
11 | const commonStyles = { border: 0, height: '200px' }
12 |
13 | const getPlaceholder = ({ type, loading }: { type: SectionType, loading?: boolean }) => {
14 | if (type === SectionType.From) return 'Introducir texto'
15 | if (loading === true) return 'Cargando...'
16 | return 'Traducción'
17 | }
18 |
19 | export const TextArea = ({ type, loading, value, onChange }: Props) => {
20 | const styles = type === SectionType.From
21 | ? commonStyles
22 | : { ...commonStyles, backgroundColor: '#f5f5f5' }
23 |
24 | const handleChange = (event: React.ChangeEvent) => {
25 | onChange(event.target.value)
26 | }
27 |
28 | return (
29 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const SUPPORTED_LANGUAGES = {
2 | en: 'English',
3 | es: 'Español',
4 | de: 'Deutsch'
5 | }
6 |
7 | export const VOICE_FOR_LANGUAGE = {
8 | en: 'en-GB',
9 | es: 'es-MX',
10 | de: 'de-DE'
11 | }
12 |
13 | export const AUTO_LANGUAGE = 'auto'
14 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/src/hooks/useDebounce.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export function useDebounce (value: T, delay = 500) {
4 | const [debouncedValue, setDebouncedValue] = useState(value)
5 |
6 | useEffect(() => {
7 | const timer = setTimeout(() => {
8 | setDebouncedValue(value)
9 | }, delay)
10 |
11 | return () => { clearTimeout(timer) } // <----
12 | }, [value, delay])
13 |
14 | return debouncedValue
15 | }
16 |
17 | /*
18 | línea del tiempo de cómo se comporta el usuario:
19 |
20 | 0ms -> user type - 'h' -> value
21 | useEffect ... L7
22 | 150ms -> user type 'he' -> value
23 | clear useEffect - L11
24 | useEffect ... L7
25 | 300ms -> user type 'hel' -> value
26 | clear useEffect - L11
27 | useEffect ... L7
28 | 400ms -> user type 'hell' -> value
29 | clear useEffect - L11
30 | useEffect ... L7
31 | 900ms -> L8 -> setDebouncedValue('hell') -> debounceValue L14
32 | */
33 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | -webkit-text-size-adjust: 100%;
15 | }
16 |
17 | a {
18 | font-weight: 500;
19 | color: #646cff;
20 | text-decoration: inherit;
21 | }
22 | a:hover {
23 | color: #535bf2;
24 | }
25 |
26 | body {
27 | margin: 0;
28 | display: flex;
29 | place-items: center;
30 | min-width: 320px;
31 | min-height: 100vh;
32 | }
33 |
34 | h1 {
35 | font-size: 3.2em;
36 | line-height: 1.1;
37 | }
38 |
39 | button {
40 | border-radius: 8px;
41 | border: 1px solid transparent;
42 | padding: 0.6em 1.2em;
43 | font-size: 1em;
44 | font-weight: 500;
45 | font-family: inherit;
46 | background-color: #1a1a1a;
47 | cursor: pointer;
48 | transition: border-color 0.25s;
49 | }
50 | button:hover {
51 | border-color: #646cff;
52 | }
53 | button:focus,
54 | button:focus-visible {
55 | outline: 4px auto -webkit-focus-ring-color;
56 | }
57 |
58 | @media (prefers-color-scheme: light) {
59 | :root {
60 | color: #213547;
61 | background-color: #ffffff;
62 | }
63 | a:hover {
64 | color: #747bff;
65 | }
66 | button {
67 | background-color: #f9f9f9;
68 | }
69 | }
70 |
71 | textarea {
72 | resize: none;
73 | }
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App'
4 | import './index.css'
5 |
6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/src/types.d.ts:
--------------------------------------------------------------------------------
1 | import { type AUTO_LANGUAGE, type SUPPORTED_LANGUAGES } from './constants'
2 |
3 | export type Language = keyof typeof SUPPORTED_LANGUAGES
4 | export type AutoLanguage = typeof AUTO_LANGUAGE
5 | export type FromLanguage = Language | AutoLanguage
6 |
7 | export interface State {
8 | fromLanguage: FromLanguage
9 | toLanguage: Language
10 | fromText: string
11 | result: string
12 | loading: boolean
13 | }
14 |
15 | export type Action =
16 | | { type: 'SET_FROM_LANGUAGE', payload: FromLanguage }
17 | | { type: 'INTERCHANGE_LANGUAGES' }
18 | | { type: 'SET_TO_LANGUAGE', payload: Language }
19 | | { type: 'SET_FROM_TEXT', payload: string }
20 | | { type: 'SET_RESULT', payload: string }
21 |
22 | export enum SectionType {
23 | From = 'from',
24 | To = 'to'
25 | }
26 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/projects/09-google-translate-clone/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { defineConfig } from 'vite'
3 | import react from '@vitejs/plugin-react-swc'
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | test: {
9 | environment:'happy-dom'
10 | }
11 | })
12 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "crud-react-redux",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "@reduxjs/toolkit": "1.9.3",
13 | "@tremor/react": "2.1.0",
14 | "react": "18.2.0",
15 | "react-dom": "18.2.0",
16 | "react-redux": "8.0.5",
17 | "sonner": "0.3.0"
18 | },
19 | "devDependencies": {
20 | "@types/react": "18.0.28",
21 | "@types/react-dom": "18.0.11",
22 | "@vitejs/plugin-react-swc": "3.0.0",
23 | "autoprefixer": "10.4.14",
24 | "postcss": "8.4.21",
25 | "rome": "12.0.0",
26 | "tailwindcss": "3.3.1",
27 | "typescript": "4.9.3",
28 | "vite": "4.2.1"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/rome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/rome/configuration_schema.json",
3 | "organizeImports": {
4 | "enabled": true
5 | },
6 | "linter": {
7 | "enabled": true,
8 | "rules": {
9 | "recommended": true
10 | }
11 | },
12 | "formatter": {
13 | "enabled": true
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/src/App.tsx:
--------------------------------------------------------------------------------
1 | import "./App.css";
2 | import { ListOfUsers } from "./components/ListOfUsers";
3 | import { CreateNewUser } from './components/CreateNewUser';
4 | import { Toaster } from 'sonner'
5 |
6 | function App() {
7 | return (
8 | <>
9 |
10 |
11 |
12 | >
13 | );
14 | }
15 |
16 | export default App;
17 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/src/components/CreateNewUser.tsx:
--------------------------------------------------------------------------------
1 | import { Badge, Button, Card, TextInput, Title } from "@tremor/react"
2 | import { useState } from "react"
3 | import { useUserActions } from "../hooks/useUserActions"
4 |
5 | export function CreateNewUser() {
6 | const { addUser } = useUserActions()
7 | const [result, setResult] = useState<"ok" | "ko" | null>(null)
8 |
9 | const handleSubmit = (event: React.FormEvent) => {
10 | event.preventDefault()
11 |
12 | setResult(null)
13 |
14 | const form = event.target as HTMLFormElement
15 | const formData = new FormData(form)
16 |
17 | const name = formData.get("name") as string
18 | const email = formData.get("email") as string
19 | const github = formData.get("github") as string
20 |
21 | if (!name || !email || !github) {
22 | // validaciones que tu quieras
23 | return setResult("ko")
24 | }
25 |
26 | addUser({ name, email, github })
27 | setResult("ok")
28 | form.reset()
29 | }
30 |
31 | return (
32 |
33 | Create New User
34 |
35 |
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/src/hooks/store.ts:
--------------------------------------------------------------------------------
1 | import type { TypedUseSelectorHook } from "react-redux";
2 | import { useDispatch, useSelector } from "react-redux";
3 | import type { AppDispatch, RootState } from "../store";
4 |
5 | export const useAppSelector: TypedUseSelectorHook = useSelector;
6 | export const useAppDispatch: () => AppDispatch = useDispatch;
7 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/src/hooks/useUserActions.ts:
--------------------------------------------------------------------------------
1 | import { User, UserId, addNewUser, deleteUserById } from "../store/users/slice";
2 | import { useAppDispatch } from "./store";
3 |
4 | export const useUserActions = () => {
5 | const dispatch = useAppDispatch();
6 |
7 | const addUser = ({ name, email, github }: User) => {
8 | dispatch(addNewUser({ name, email, github }))
9 | }
10 |
11 | const removeUser = (id: UserId) => {
12 | dispatch(deleteUserById(id));
13 | };
14 |
15 | return { addUser, removeUser };
16 | };
17 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
--------------------------------------------------------------------------------
/projects/10-crud-redux/src/main.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from "react-dom/client";
2 | import App from "./App";
3 | import "./index.css";
4 |
5 | import { Provider } from "react-redux";
6 | import { store } from "./store";
7 |
8 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
9 |
10 |
11 | ,
12 | );
13 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { configureStore, type Middleware } from "@reduxjs/toolkit";
2 | import { toast } from 'sonner';
3 | import usersReducer, { rollbackUser } from "./users/slice";
4 |
5 | const persistanceLocalStorageMiddleware: Middleware = (store) => (next) => (action) => {
6 | next(action);
7 | localStorage.setItem("__redux__state__", JSON.stringify(store.getState()));
8 | };
9 |
10 | const syncWithDatabaseMiddleware: Middleware = store => next => action => {
11 | const { type, payload } = action
12 | const previousState = store.getState() as RootState
13 | next(action)
14 |
15 | if (type === 'users/deleteUserById') { // <- eliminado un usuario
16 | const userIdToRemove = payload
17 | const userToRemove = previousState.users.find(user => user.id === userIdToRemove)
18 |
19 | fetch(`https://jsonplaceholder.typicode.com/users/${userIdToRemove}`, {
20 | method: 'DELETE'
21 | })
22 | .then(res => {
23 | // if (res.ok) {
24 | // toast.success(`Usuario ${payload} eliminado correctamente`)
25 | // }
26 | throw new Error('Error al eliminar el usuario')
27 | })
28 | .catch(err => {
29 | toast.error(`Error deleting user ${userIdToRemove}`)
30 | if (userToRemove) store.dispatch(rollbackUser(userToRemove))
31 | console.log(err)
32 | console.log('error')
33 | })
34 | }
35 | }
36 |
37 | export const store = configureStore({
38 | reducer: {
39 | users: usersReducer,
40 | },
41 | middleware: [persistanceLocalStorageMiddleware, syncWithDatabaseMiddleware],
42 | });
43 |
44 | export type RootState = ReturnType;
45 | export type AppDispatch = typeof store.dispatch;
46 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/src/store/users/slice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
2 |
3 | const DEFAULT_STATE = [
4 | {
5 | id: "1",
6 | name: "Yazman Rodriguez",
7 | email: "yazmanito@gmail.com",
8 | github: "yazmanito",
9 | },
10 | {
11 | id: "2",
12 | name: "John Doe",
13 | email: "leo@gmail.com",
14 | github: "leo",
15 | },
16 | {
17 | id: "3",
18 | name: "Haakon Dahlberg",
19 | email: "haakon@gmail.com",
20 | github: "midudev",
21 | },
22 | ];
23 |
24 | export type UserId = string;
25 |
26 | export interface User {
27 | name: string;
28 | email: string;
29 | github: string;
30 | }
31 |
32 | export interface UserWithId extends User {
33 | id: UserId;
34 | }
35 |
36 | const initialState: UserWithId[] = (() => {
37 | const persistedState = localStorage.getItem("__redux__state__");
38 | return persistedState ? JSON.parse(persistedState).users : DEFAULT_STATE;
39 | })();
40 |
41 | export const usersSlice = createSlice({
42 | name: "users",
43 | initialState,
44 | reducers: {
45 | addNewUser: (state, action: PayloadAction) => {
46 | const id = crypto.randomUUID()
47 | state.push({ id, ...action.payload })
48 | },
49 | deleteUserById: (state, action: PayloadAction) => {
50 | const id = action.payload;
51 | return state.filter((user) => user.id !== id);
52 | },
53 | rollbackUser: (state, action: PayloadAction) => {
54 | const isUserAlreadyDefined = state.some(user => user.id === action.payload.id)
55 | if (!isUserAlreadyDefined) {
56 | state.push(action.payload)
57 | }
58 | }
59 | },
60 | });
61 |
62 | export default usersSlice.reducer;
63 |
64 | export const { addNewUser, deleteUserById, rollbackUser } = usersSlice.actions;
65 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: [
4 | "./index.html",
5 | "./src/**/*.{js,ts,jsx,tsx}",
6 |
7 | // path tremor node_modules
8 | "./node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
9 | ],
10 | theme: {
11 | extend: {},
12 | },
13 | plugins: [],
14 | };
15 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/projects/10-crud-redux/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/projects/11-typescript-prueba-tecnica/README.md:
--------------------------------------------------------------------------------
1 | # Prueba técnica con TypeScript y React
2 |
3 | Esto es una prueba técnica de una empresa europea para un sueldo de 55000 €/anuales.
4 |
5 | El objetivo de esta prueba técnica es crear una aplicación similar a la que se proporciona en este enlace: https://midu-react-11.surge.sh/. Para lograr esto, debe usar la API proporcionada por https://randomuser.me/.
6 |
7 | Los pasos a seguir:
8 |
9 | - [x] Fetch 100 rows of data using the API.
10 | - [x] Display the data in a table format, similar to the example.
11 | - [x] Provide the option to color rows as shown in the example.
12 | - [x] Allow the data to be sorted by country as demonstrated in the example.
13 | - [x] Enable the ability to delete a row as shown in the example.
14 | - [x] Implement a feature that allows the user to restore the initial state, meaning that all deleted rows will be recovered.
15 | - [x] Handle any potential errors that may occur.
16 | - [x] Implement a feature that allows the user to filter the data by country.
17 | - [x] Avoid sorting users again the data when the user is changing filter by country.
18 | - [x] Sort by clicking on the column header.
19 |
--------------------------------------------------------------------------------
/projects/11-typescript-prueba-tecnica/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/projects/11-typescript-prueba-tecnica/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "11-typescript-prueba-tecnica",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "react": "^18.2.0",
13 | "react-dom": "^18.2.0"
14 | },
15 | "devDependencies": {
16 | "@types/react": "^18.0.28",
17 | "@types/react-dom": "^18.0.11",
18 | "@typescript-eslint/eslint-plugin": "^5.43.0",
19 | "@vitejs/plugin-react-swc": "^3.0.0",
20 | "eslint": "^8.0.1",
21 | "eslint-config-standard-with-typescript": "^34.0.1",
22 | "eslint-plugin-import": "^2.25.2",
23 | "eslint-plugin-n": "^15.0.0",
24 | "eslint-plugin-promise": "^6.0.0",
25 | "eslint-plugin-react": "^7.32.2",
26 | "typescript": "^4.9.5",
27 | "vite": "^4.2.0"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/projects/11-typescript-prueba-tecnica/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/11-typescript-prueba-tecnica/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | margin: 0 auto;
3 | padding: 2rem;
4 | text-align: center;
5 | width: 100%;
6 | }
7 |
8 | .table--showColors tr:nth-child(odd) {
9 | background: #333;
10 | }
11 |
12 | .table--showColors tr:nth-child(even) {
13 | background: #555;
14 | }
15 |
16 | header {
17 | display: flex;
18 | gap: 4px;
19 | margin-bottom: 48px;
20 | justify-content: center;
21 | align-items: center;
22 | }
23 |
24 | .pointer {
25 | cursor: crosshair;
26 | }
--------------------------------------------------------------------------------
/projects/11-typescript-prueba-tecnica/src/components/UsersList.tsx:
--------------------------------------------------------------------------------
1 | import { SortBy, type User } from '../types.d'
2 |
3 | interface Props {
4 | changeSorting: (sort: SortBy) => void
5 | deleteUser: (email: string) => void
6 | showColors: boolean
7 | users: User[]
8 | }
9 |
10 | export function UsersList ({ changeSorting, deleteUser, showColors, users }: Props) {
11 | return (
12 |
13 |
14 |
15 | Foto |
16 | { changeSorting(SortBy.NAME) }}>Nombre |
17 | { changeSorting(SortBy.LAST) }}>Apellido |
18 | { changeSorting(SortBy.COUNTRY) }}>País |
19 | Acciones |
20 |
21 |
22 |
23 |
24 | {
25 | users.map((user) => {
26 | return (
27 |
28 |
29 |
30 | |
31 |
32 | {user.name.first}
33 | |
34 |
35 | {user.name.last}
36 | |
37 |
38 | {user.location.country}
39 | |
40 |
41 |
44 | |
45 |
46 | )
47 | })
48 | }
49 |
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/projects/11-typescript-prueba-tecnica/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | -webkit-text-size-adjust: 100%;
15 | }
16 |
17 | a {
18 | font-weight: 500;
19 | color: #646cff;
20 | text-decoration: inherit;
21 | }
22 |
23 | a:hover {
24 | color: #535bf2;
25 | }
26 |
27 | body {
28 | margin: 0;
29 | display: flex;
30 | place-items: center;
31 | min-width: 320px;
32 | min-height: 100vh;
33 | }
34 |
35 | h1 {
36 | font-size: 3.2em;
37 | line-height: 1.1;
38 | }
39 |
40 | button {
41 | border-radius: 8px;
42 | border: 1px solid transparent;
43 | padding: 0.6em 1.2em;
44 | font-size: 1em;
45 | font-weight: 500;
46 | font-family: inherit;
47 | background-color: #1a1a1a;
48 | cursor: pointer;
49 | transition: border-color 0.25s;
50 | }
51 | button:hover {
52 | border-color: #646cff;
53 | }
54 | button:focus,
55 | button:focus-visible {
56 | outline: 4px auto -webkit-focus-ring-color;
57 | }
58 |
59 | @media (prefers-color-scheme: light) {
60 | :root {
61 | color: #213547;
62 | background-color: #ffffff;
63 | }
64 | a:hover {
65 | color: #747bff;
66 | }
67 | button {
68 | background-color: #f9f9f9;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/projects/11-typescript-prueba-tecnica/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App'
4 | import './index.css'
5 |
6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
7 |
8 | )
9 |
--------------------------------------------------------------------------------
/projects/11-typescript-prueba-tecnica/src/types.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface Array {
3 | toSorted(compareFn?: (a: T, b: T) => number): T[]
4 | }
5 | }
6 |
7 | export interface APIResults {
8 | results: User[]
9 | info: Info
10 | }
11 |
12 | export interface Info {
13 | seed: string
14 | results: number
15 | page: number
16 | version: string
17 | }
18 |
19 | export interface User {
20 | gender: Gender
21 | name: Name
22 | location: Location
23 | email: string
24 | login: Login
25 | dob: Dob
26 | registered: Dob
27 | phone: string
28 | cell: string
29 | id: ID
30 | picture: Picture
31 | nat: string
32 | }
33 |
34 | export interface Dob {
35 | date: Date
36 | age: number
37 | }
38 |
39 | export enum Gender {
40 | Female = 'female',
41 | Male = 'male',
42 | }
43 |
44 | export interface ID {
45 | name: string
46 | value: null | string
47 | }
48 |
49 | export interface Location {
50 | street: Street
51 | city: string
52 | state: string
53 | country: string
54 | postcode: number | string
55 | coordinates: Coordinates
56 | timezone: Timezone
57 | }
58 |
59 | export interface Coordinates {
60 | latitude: string
61 | longitude: string
62 | }
63 |
64 | export interface Street {
65 | number: number
66 | name: string
67 | }
68 |
69 | export interface Timezone {
70 | offset: string
71 | description: string
72 | }
73 |
74 | export interface Login {
75 | uuid: string
76 | username: string
77 | password: string
78 | salt: string
79 | md5: string
80 | sha1: string
81 | sha256: string
82 | }
83 |
84 | export interface Name {
85 | title: Title
86 | first: string
87 | last: string
88 | }
89 |
90 | export enum Title {
91 | MS = 'Ms',
92 | Madame = 'Madame',
93 | Mademoiselle = 'Mademoiselle',
94 | Miss = 'Miss',
95 | Monsieur = 'Monsieur',
96 | Mr = 'Mr',
97 | Mrs = 'Mrs',
98 | }
99 |
100 | export enum SortBy {
101 | NONE = 'none',
102 | NAME = 'name',
103 | LAST = 'last',
104 | COUNTRY = 'country',
105 | }
106 |
107 | export interface Picture {
108 | large: string
109 | medium: string
110 | thumbnail: string
111 | }
112 |
--------------------------------------------------------------------------------
/projects/11-typescript-prueba-tecnica/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/projects/11-typescript-prueba-tecnica/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/projects/11-typescript-prueba-tecnica/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/projects/11-typescript-prueba-tecnica/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/README.md:
--------------------------------------------------------------------------------
1 | # Prueba técnica con TypeScript y React
2 |
3 | Esto es una prueba técnica de una empresa europea para un sueldo de 55000 €/anuales.
4 |
5 | El objetivo de esta prueba técnica es crear una aplicación similar a la que se proporciona en este enlace: https://midu-react-11.surge.sh/. Para lograr esto, debe usar la API proporcionada por https://randomuser.me/.
6 |
7 | Los pasos a seguir:
8 |
9 | - [x] Fetch 100 rows of data using the API.
10 | - [x] Display the data in a table format, similar to the example.
11 | - [x] Provide the option to color rows as shown in the example.
12 | - [x] Allow the data to be sorted by country as demonstrated in the example.
13 | - [x] Enable the ability to delete a row as shown in the example.
14 | - [x] Implement a feature that allows the user to restore the initial state, meaning that all deleted rows will be recovered.
15 | - [x] Handle any potential errors that may occur.
16 | - [x] Implement a feature that allows the user to filter the data by country.
17 | - [x] Avoid sorting users again the data when the user is changing filter by country.
18 | - [x] Sort by clicking on the column header.
19 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "11b-typescript-prueba-tecnica-with-react-query",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "@tanstack/react-query": "4.29.3",
13 | "@tanstack/react-query-devtools": "4.29.3",
14 | "react": "^18.2.0",
15 | "react-dom": "^18.2.0"
16 | },
17 | "devDependencies": {
18 | "@types/react": "^18.0.28",
19 | "@types/react-dom": "^18.0.11",
20 | "@typescript-eslint/eslint-plugin": "^5.43.0",
21 | "@vitejs/plugin-react-swc": "^3.0.0",
22 | "eslint": "^8.0.1",
23 | "eslint-config-standard-with-typescript": "^34.0.1",
24 | "eslint-plugin-import": "^2.25.2",
25 | "eslint-plugin-n": "^15.0.0",
26 | "eslint-plugin-promise": "^6.0.0",
27 | "eslint-plugin-react": "^7.32.2",
28 | "ts-standard": "12.0.2",
29 | "typescript": "^4.9.5",
30 | "vite": "^4.2.0"
31 | },
32 | "eslintConfig": {
33 | "parserOptions": {
34 | "project": "./tsconfig.json"
35 | },
36 | "extends": [
37 | "./node_modules/ts-standard/eslintrc.json"
38 | ],
39 | "rules": {
40 | "@typescript-eslint/explicit-function-return-type": "off"
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | margin: 0 auto;
3 | padding: 2rem;
4 | text-align: center;
5 | width: 100%;
6 | }
7 |
8 | .table--showColors tr:nth-child(odd) {
9 | background: #333;
10 | }
11 |
12 | .table--showColors tr:nth-child(even) {
13 | background: #555;
14 | }
15 |
16 | header {
17 | display: flex;
18 | gap: 4px;
19 | margin-bottom: 48px;
20 | justify-content: center;
21 | align-items: center;
22 | }
23 |
24 | .pointer {
25 | cursor: crosshair;
26 | }
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/src/components/Results.tsx:
--------------------------------------------------------------------------------
1 | import { useUsers } from '../hooks/useUsers'
2 |
3 | export const Results = () => {
4 | const { users } = useUsers()
5 |
6 | return Results {users.length}
7 | }
8 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/src/components/UsersList.tsx:
--------------------------------------------------------------------------------
1 | import { SortBy, type User } from '../types.d'
2 |
3 | interface Props {
4 | changeSorting: (sort: SortBy) => void
5 | deleteUser: (email: string) => void
6 | showColors: boolean
7 | users: User[]
8 | }
9 |
10 | export function UsersList ({ changeSorting, deleteUser, showColors, users }: Props) {
11 | return (
12 |
13 |
14 |
15 | Foto |
16 | { changeSorting(SortBy.NAME) }}>Nombre |
17 | { changeSorting(SortBy.LAST) }}>Apellido |
18 | { changeSorting(SortBy.COUNTRY) }}>País |
19 | Acciones |
20 |
21 |
22 |
23 |
24 | {
25 | users.map((user) => {
26 | return (
27 |
28 |
29 |
30 | |
31 |
32 | {user.name.first}
33 | |
34 |
35 | {user.name.last}
36 | |
37 |
38 | {user.location.country}
39 | |
40 |
41 |
44 | |
45 |
46 | )
47 | })
48 | }
49 |
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/src/hooks/useUsers.ts:
--------------------------------------------------------------------------------
1 | import { fetchUsers } from '../services/users'
2 | import { useInfiniteQuery } from '@tanstack/react-query'
3 | import { type User } from '../types.d'
4 |
5 | export const useUsers = () => {
6 | const { isLoading, isError, data, refetch, fetchNextPage, hasNextPage } = useInfiniteQuery<{ nextCursor?: number, users: User[] }>(
7 | ['users'], // <- la key de la información o de la query
8 | fetchUsers,
9 | {
10 | getNextPageParam: (lastPage) => lastPage.nextCursor,
11 | refetchOnWindowFocus: false,
12 | staleTime: 1000 * 3
13 | }
14 | )
15 |
16 | return {
17 | refetch,
18 | fetchNextPage,
19 | isLoading,
20 | isError,
21 | users: data?.pages.flatMap(page => page.users) ?? [],
22 | hasNextPage
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | -webkit-text-size-adjust: 100%;
15 | }
16 |
17 | a {
18 | font-weight: 500;
19 | color: #646cff;
20 | text-decoration: inherit;
21 | }
22 |
23 | a:hover {
24 | color: #535bf2;
25 | }
26 |
27 | body {
28 | margin: 0;
29 | display: flex;
30 | justify-content: center;
31 | min-width: 320px;
32 | min-height: 100vh;
33 | }
34 |
35 | h1 {
36 | font-size: 3.2em;
37 | line-height: 1.1;
38 | }
39 |
40 | button {
41 | border-radius: 8px;
42 | border: 1px solid transparent;
43 | padding: 0.6em 1.2em;
44 | font-size: 1em;
45 | font-weight: 500;
46 | font-family: inherit;
47 | background-color: #1a1a1a;
48 | cursor: pointer;
49 | transition: border-color 0.25s;
50 | }
51 | button:hover {
52 | border-color: #646cff;
53 | }
54 | button:focus,
55 | button:focus-visible {
56 | outline: 4px auto -webkit-focus-ring-color;
57 | }
58 |
59 | @media (prefers-color-scheme: light) {
60 | :root {
61 | color: #213547;
62 | background-color: #ffffff;
63 | }
64 | a:hover {
65 | color: #747bff;
66 | }
67 | button {
68 | background-color: #f9f9f9;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/src/main.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client'
2 | import App from './App'
3 | import './index.css'
4 | import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
5 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
6 |
7 | const queryClient = new QueryClient()
8 |
9 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
10 |
11 |
12 |
13 |
14 | )
15 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/src/services/users.ts:
--------------------------------------------------------------------------------
1 | const delay = async (ms: number) => await new Promise(resolve => setTimeout(resolve, ms))
2 |
3 | export const fetchUsers = async ({ pageParam = 1 }: { pageParam?: number }) => {
4 | await delay(300)
5 |
6 | return await fetch(`https://randomuser.me/api?results=10&seed=midudev&page=${pageParam}`)
7 | .then(async res => {
8 | if (!res.ok) throw new Error('Error en la petición')
9 | return await res.json()
10 | })
11 |
12 | .then(res => {
13 | const currentPage = Number(res.info.page)
14 | const nextCursor = currentPage > 3 ? undefined : currentPage + 1
15 |
16 | return {
17 | users: res.results,
18 | nextCursor
19 | }
20 | })
21 | }
22 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/src/types.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface Array {
3 | toSorted(compareFn?: (a: T, b: T) => number): T[]
4 | }
5 | }
6 |
7 | export interface APIResults {
8 | results: User[]
9 | info: Info
10 | }
11 |
12 | export interface Info {
13 | seed: string
14 | results: number
15 | page: number
16 | version: string
17 | }
18 |
19 | export interface User {
20 | gender: Gender
21 | name: Name
22 | location: Location
23 | email: string
24 | login: Login
25 | dob: Dob
26 | registered: Dob
27 | phone: string
28 | cell: string
29 | id: ID
30 | picture: Picture
31 | nat: string
32 | }
33 |
34 | export interface Dob {
35 | date: Date
36 | age: number
37 | }
38 |
39 | export enum Gender {
40 | Female = 'female',
41 | Male = 'male',
42 | }
43 |
44 | export interface ID {
45 | name: string
46 | value: null | string
47 | }
48 |
49 | export interface Location {
50 | street: Street
51 | city: string
52 | state: string
53 | country: string
54 | postcode: number | string
55 | coordinates: Coordinates
56 | timezone: Timezone
57 | }
58 |
59 | export interface Coordinates {
60 | latitude: string
61 | longitude: string
62 | }
63 |
64 | export interface Street {
65 | number: number
66 | name: string
67 | }
68 |
69 | export interface Timezone {
70 | offset: string
71 | description: string
72 | }
73 |
74 | export interface Login {
75 | uuid: string
76 | username: string
77 | password: string
78 | salt: string
79 | md5: string
80 | sha1: string
81 | sha256: string
82 | }
83 |
84 | export interface Name {
85 | title: Title
86 | first: string
87 | last: string
88 | }
89 |
90 | export enum Title {
91 | MS = 'Ms',
92 | Madame = 'Madame',
93 | Mademoiselle = 'Mademoiselle',
94 | Miss = 'Miss',
95 | Monsieur = 'Monsieur',
96 | Mr = 'Mr',
97 | Mrs = 'Mrs',
98 | }
99 |
100 | export enum SortBy {
101 | NONE = 'none',
102 | NAME = 'name',
103 | LAST = 'last',
104 | COUNTRY = 'country',
105 | }
106 |
107 | export interface Picture {
108 | large: string
109 | medium: string
110 | thumbnail: string
111 | }
112 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/projects/11b-typescript-prueba-tecnica-with-react-query/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "12-app-with-react-query",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "@tanstack/react-query": "4.29.3",
13 | "react": "18.2.0",
14 | "react-dom": "18.2.0"
15 | },
16 | "devDependencies": {
17 | "@types/react": "18.0.28",
18 | "@types/react-dom": "18.0.11",
19 | "@vitejs/plugin-react-swc": "3.0.0",
20 | "autoprefixer": "^10.4.14",
21 | "postcss": "^8.4.22",
22 | "tailwindcss": "^3.3.1",
23 | "ts-standard": "12.0.2",
24 | "typescript": "4.9.3",
25 | "vite": "4.2.0"
26 | },
27 | "eslintConfig": {
28 | "parserOptions": {
29 | "project": "./tsconfig.json"
30 | },
31 | "extends": [
32 | "./node_modules/ts-standard/eslintrc.json"
33 | ],
34 | "rules": {
35 | "@typescript-eslint/explicit-function-return-type": "off"
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/src/App.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/midudev/aprendiendo-react/2de1877d7b634fe7f2f4573999b1e057d1abcef5/projects/12-comments-react-query/src/App.css
--------------------------------------------------------------------------------
/projects/12-comments-react-query/src/components/Form.tsx:
--------------------------------------------------------------------------------
1 | export const FormInput = ({ ...props }) => (
2 |
3 |
4 |
5 |
6 | )
7 |
8 | export const FormTextArea = ({ ...props }) => (
9 |
10 | )
11 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/src/components/Results.tsx:
--------------------------------------------------------------------------------
1 | import { CommentWithId } from '../service/comments'
2 |
3 | export const Results = ({ data }: { data?: CommentWithId[] }) => {
4 | return (
5 |
6 | -
7 | {
8 | data?.map((comment) => (
9 |
13 |
{comment.title}
14 | {comment.message}
15 |
16 | ))
17 | }
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/src/main.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client'
2 | import App from './App'
3 | import './index.css'
4 | import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
5 |
6 | const queryClient = new QueryClient()
7 |
8 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
9 |
10 |
11 |
12 | )
13 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/src/service/comments.ts:
--------------------------------------------------------------------------------
1 | export interface Comment {
2 | title: string
3 | message: string
4 | preview?: boolean
5 | }
6 |
7 | export interface CommentWithId extends Comment {
8 | id: string
9 | }
10 |
11 | // ApiKey could be public as service is 100% free
12 | const apiKey = '$2b$10$jOpMXFaiNgsyhru7Nt.GouBUmHStWY9IRZR7vCocenxkK.vv7tDsu'
13 |
14 | export const getComments = async () => {
15 | const response = await fetch('https://api.jsonbin.io/v3/b/643fbe2bc0e7653a05a77535', {
16 | method: 'GET',
17 | headers: {
18 | 'Content-Type': 'application/json',
19 | 'X-Access-Key': apiKey
20 | }
21 | })
22 |
23 | if (!response.ok) {
24 | throw new Error('Failed to fetch comments.')
25 | }
26 |
27 | const json = await response.json()
28 |
29 | return json?.record
30 | }
31 |
32 | const delay = async (ms: number) => await new Promise(resolve => setTimeout(resolve, ms))
33 |
34 | export const postComment = async (comment: Comment) => {
35 | const comments = await getComments()
36 |
37 | const id = crypto.randomUUID()
38 | const newComment = { ...comment, id }
39 | const commentsToSave = [...comments, newComment]
40 |
41 | const response = await fetch('https://api.jsonbin.io/v3/b/643fbe2bc0e7653a05a77535', {
42 | method: 'PUT',
43 | headers: {
44 | 'Content-Type': 'application/json',
45 | 'X-Access-Key': import.meta.env.VITE_PUBLIC_API_KEY
46 | },
47 | body: JSON.stringify(commentsToSave)
48 | })
49 |
50 | if (!response.ok) {
51 | throw new Error('Failed to post comment.')
52 | }
53 |
54 | return newComment
55 | }
56 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: [
4 | './index.html',
5 | './src/**/*.{js,ts,jsx,tsx}'
6 | ],
7 | theme: {
8 | extend: {}
9 | },
10 | plugins: []
11 | }
12 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/projects/12-comments-react-query/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: { browser: true, es2020: true },
3 | extends: [
4 | 'eslint:recommended',
5 | 'plugin:@typescript-eslint/recommended',
6 | 'plugin:react-hooks/recommended',
7 | './node_modules/ts-standard/eslintrc.json'
8 | ],
9 | parser: '@typescript-eslint/parser',
10 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module', project: './tsconfig.json' },
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': 'warn',
14 | '@typescript-eslint/explicit-function-return-type': 'off',
15 | '@typescript-eslint/no-floating-promises': 'off'
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | JavaScript Quiz
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "javascript-quiz",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@emotion/react": "11.10.6",
14 | "@emotion/styled": "11.10.6",
15 | "@fontsource/roboto": "4.5.8",
16 | "@mui/icons-material": "5.11.16",
17 | "@mui/material": "5.12.2",
18 | "canvas-confetti": "1.6.0",
19 | "react": "18.2.0",
20 | "react-dom": "18.2.0",
21 | "react-syntax-highlighter": "15.5.0",
22 | "zustand": "4.3.7"
23 | },
24 | "devDependencies": {
25 | "@types/canvas-confetti": "^1.6.0",
26 | "@types/react": "18.0.28",
27 | "@types/react-dom": "18.0.11",
28 | "@types/react-syntax-highlighter": "^15.5.6",
29 | "@typescript-eslint/eslint-plugin": "5.57.1",
30 | "@typescript-eslint/parser": "5.57.1",
31 | "@vitejs/plugin-react-swc": "3.0.0",
32 | "eslint": "8.38.0",
33 | "eslint-plugin-react-hooks": "4.6.0",
34 | "eslint-plugin-react-refresh": "0.3.4",
35 | "ts-standard": "12.0.2",
36 | "typescript": "5.0.2",
37 | "vite": "4.3.2"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 1rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/src/App.tsx:
--------------------------------------------------------------------------------
1 | import './App.css'
2 | import { Container, Stack, Typography, useTheme } from '@mui/material'
3 | import { JavaScriptLogo } from './JavaScriptLogo'
4 | import { Start } from './Start'
5 | import { useQuestionsStore } from './store/questions'
6 | import { Game } from './Game'
7 | import { useQuestionsData } from './hooks/useQuestionsData'
8 | import { Results } from './Results'
9 | import useMediaQuery from "@mui/material/useMediaQuery";
10 |
11 | function App () {
12 | const questions = useQuestionsStore(state => state.questions)
13 | const { unanswered } = useQuestionsData()
14 | const theme = useTheme()
15 |
16 | const medium = useMediaQuery(theme.breakpoints.up("md"));
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 | JavaScript Quiz
26 |
27 |
28 |
29 |
30 |
31 |
32 | ¿Quieres aprender React ⚛️? ¡Haz click aquí!
33 |
34 |
35 | {questions.length === 0 && }
36 | {questions.length > 0 && unanswered > 0 && }
37 | {questions.length > 0 && unanswered === 0 && }
38 |
39 | Desarrollado con TypeScript + Zustand - Ir al código
40 |
41 |
42 |
43 | )
44 | }
45 |
46 | export default App
47 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/src/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@mui/material'
2 | import { useQuestionsData } from './hooks/useQuestionsData'
3 | import { useQuestionsStore } from './store/questions'
4 |
5 | export const Footer = () => {
6 | const { correct, incorrect, unanswered } = useQuestionsData()
7 | const reset = useQuestionsStore(state => state.reset)
8 |
9 | return (
10 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/src/JavaScriptLogo.tsx:
--------------------------------------------------------------------------------
1 | export const JavaScriptLogo = () => (
2 |
6 | )
7 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/src/Results.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@mui/material"
2 | import { useQuestionsData } from "./hooks/useQuestionsData"
3 | import { useQuestionsStore } from "./store/questions"
4 |
5 | export const Results = () => {
6 | const { correct, incorrect } = useQuestionsData()
7 | const reset = useQuestionsStore(state => state.reset)
8 |
9 | return (
10 |
11 |
¡Tus resultados
12 |
13 |
14 | ✅ {correct} correctas
15 | ❌ {incorrect} incorrectas
16 |
17 |
18 |
19 |
22 |
23 |
24 | )
25 | }
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/src/Start.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@mui/material'
2 | import { useQuestionsStore } from './store/questions'
3 |
4 | const LIMIT_QUESTIONS = 10
5 |
6 | export const Start = () => {
7 | const fetchQuestions = useQuestionsStore(state => state.fetchQuestions)
8 |
9 | const handleClick = () => {
10 | fetchQuestions(LIMIT_QUESTIONS)
11 | }
12 |
13 | return (
14 |
15 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/src/hooks/useQuestionsData.ts:
--------------------------------------------------------------------------------
1 | import { useQuestionsStore } from '../store/questions'
2 |
3 | export const useQuestionsData = () => {
4 | const questions = useQuestionsStore(state => state.questions)
5 |
6 | let correct = 0
7 | let incorrect = 0
8 | let unanswered = 0
9 |
10 | questions.forEach(question => {
11 | const { userSelectedAnswer, correctAnswer } = question
12 | if (userSelectedAnswer == null) unanswered++
13 | else if (userSelectedAnswer === correctAnswer) correct++
14 | else incorrect++
15 | })
16 |
17 | return { correct, incorrect, unanswered }
18 | }
19 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | -webkit-text-size-adjust: 100%;
15 | }
16 |
17 | a {
18 | font-weight: 500;
19 | color: #646cff;
20 | text-decoration: inherit;
21 | }
22 | a:hover {
23 | color: #535bf2;
24 | }
25 |
26 | body {
27 | margin: 0;
28 | display: flex;
29 | place-items: center;
30 | min-width: 320px;
31 | min-height: 100vh;
32 | }
33 |
34 | h1 {
35 | font-size: 3.2em;
36 | line-height: 1.1;
37 | }
38 |
39 | button {
40 | border-radius: 8px;
41 | border: 1px solid transparent;
42 | padding: 0.6em 1.2em;
43 | font-size: 1em;
44 | font-weight: 500;
45 | font-family: inherit;
46 | background-color: #1a1a1a;
47 | cursor: pointer;
48 | transition: border-color 0.25s;
49 | }
50 | button:hover {
51 | border-color: #646cff;
52 | }
53 | button:focus,
54 | button:focus-visible {
55 | outline: 4px auto -webkit-focus-ring-color;
56 | }
57 |
58 | @media (prefers-color-scheme: light) {
59 | :root {
60 | color: #213547;
61 | background-color: #ffffff;
62 | }
63 | a:hover {
64 | color: #747bff;
65 | }
66 | button {
67 | background-color: #f9f9f9;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeProvider, createTheme } from '@mui/material/styles'
2 | import CssBaseline from '@mui/material/CssBaseline'
3 |
4 | import ReactDOM from 'react-dom/client'
5 | import App from './App.tsx'
6 | import './index.css'
7 |
8 | import '@fontsource/roboto/300.css'
9 | import '@fontsource/roboto/400.css'
10 | import '@fontsource/roboto/500.css'
11 | import '@fontsource/roboto/700.css'
12 |
13 | const darkTheme = createTheme({
14 | palette: {
15 | mode: 'dark'
16 | }
17 | })
18 |
19 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
20 |
21 |
22 |
23 |
24 | )
25 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/src/services/questions.ts:
--------------------------------------------------------------------------------
1 | export const getAllQuestions = async () => {
2 | const res = await fetch('http://localhost:5173/data.json')
3 | const json = await res.json()
4 | return json
5 | }
6 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/src/types.d.ts:
--------------------------------------------------------------------------------
1 | export interface Question {
2 | id: number
3 | question: string
4 | code: string
5 | answers: string[]
6 | correctAnswer: number
7 | userSelectedAnswer?: number
8 | isCorrectUserAnswer?: boolean
9 | }
10 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": [
5 | "DOM",
6 | "DOM.Iterable",
7 | "ESNext"
8 | ],
9 | "module": "ESNext",
10 | "skipLibCheck": true,
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true
23 | },
24 | "include": [
25 | "src",
26 | "./.eslintrc.cjs"
27 | ],
28 | "references": [
29 | {
30 | "path": "./tsconfig.node.json"
31 | }
32 | ]
33 | }
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/projects/13-javascript-quiz-con-zustand/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/README.md:
--------------------------------------------------------------------------------
1 | # Enunciado
2 |
3 | ## Requirements:
4 | - Use a Styled Components/CSS-in-JS solution of your choice ✅
5 | - Show placeholder/skeleton for stories and comments while loading ✅
6 | - Respect list item indentation for comments ✅
7 | - Each page should have a unique URL (ex. localhost:8080/article/12121). It should be a SPA but all URLs should be accesible by direct link. ✅
8 |
9 | ## Instructions:
10 |
11 | Part 1: Write a React or React Native app that fetches and displays the top 10 stories from Hacker News using the Hacker News API - https://github.com/HackerNews/API ✅
12 |
13 | Part 2: If you click into a story, you should see the comments in a different page.
14 | Fetch and display the first 10 comments and their children using the Hacker News API.
15 | You may use any additional libraries you deem necessary. (remember respecting nested comments)
16 |
17 | Part 3: Implement an infinite scroll for top stories by using a "Load more" button.
18 |
19 | Part 4: Ensure scroll to the bottom every time new stories are loaded.
20 |
21 | Part 5: Make API calls to fetch comments to fail 75% of the times, and handle the error gracefully.
22 |
23 | ## Evaluation Criteria:
24 |
25 | - Please ensure that your code is properly organized, and easy to read
26 | - Reuse as much code as possible
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Hacker News - Prueba Técnica USA de Frontend
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hacker-news-prueba-tecnica",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@vanilla-extract/css": "1.11.0",
14 | "react": "^18.2.0",
15 | "react-content-loader": "6.2.1",
16 | "react-dom": "^18.2.0",
17 | "swr": "2.1.5",
18 | "wouter": "2.11.0"
19 | },
20 | "devDependencies": {
21 | "@types/react": "^18.0.37",
22 | "@types/react-dom": "^18.0.11",
23 | "@typescript-eslint/eslint-plugin": "^5.59.0",
24 | "@typescript-eslint/parser": "^5.59.0",
25 | "@vanilla-extract/vite-plugin": "^3.8.2",
26 | "@vitejs/plugin-react-swc": "^3.0.0",
27 | "eslint": "^8.38.0",
28 | "eslint-plugin-react-hooks": "^4.6.0",
29 | "eslint-plugin-react-refresh": "^0.3.4",
30 | "ts-standard": "^12.0.2",
31 | "typescript": "^5.0.2",
32 | "vite": "^4.3.9"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/public/logo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/midudev/aprendiendo-react/2de1877d7b634fe7f2f4573999b1e057d1abcef5/projects/14-hacker-news-prueba-tecnica/public/logo.gif
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense, lazy } from 'react'
2 | import { Header } from './components/Header'
3 | import { Route } from 'wouter'
4 |
5 | const TopStoriesPage = lazy(() => import('./pages/TopStories'))
6 | const DetailPage = lazy(() => import('./pages/Detail'))
7 |
8 | export default function App () {
9 | return (
10 | <>
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | >
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/components/CommentLoader.tsx:
--------------------------------------------------------------------------------
1 | import ContentLoader from 'react-content-loader'
2 |
3 | export const CommentLoader = () => (
4 |
12 |
13 |
14 |
15 |
16 | )
17 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/components/Header.css.ts:
--------------------------------------------------------------------------------
1 | import { style } from '@vanilla-extract/css'
2 |
3 | export const header = style({
4 | alignItems: 'center',
5 | borderBottom: '1px solid #eee',
6 | display: 'flex',
7 | gap: '16px',
8 | padding: '12px 32px'
9 | })
10 |
11 | export const link = style({
12 | color: '#374151',
13 | fontSize: '18px',
14 | margin: 0,
15 | textDecoration: 'none'
16 | })
17 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { header, link } from './Header.css'
2 |
3 | export const Header = () => {
4 | return (
5 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/components/ListOfComments.tsx:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr'
2 | import { getItemInfo } from '../services/hacker-news'
3 | import { CommentLoader } from './CommentLoader'
4 | import { getRelativeTime } from '../utils/getRelativeTime'
5 |
6 | const Comment = (props: {
7 | id: number
8 | }) => {
9 | const { id } = props
10 | const { data, isLoading } = useSWR(`/comment/${id}`, () => getItemInfo(id))
11 |
12 | if (isLoading) {
13 | return
14 | }
15 |
16 | const { by, text, time, kids } = data
17 |
18 | const relativeTime = getRelativeTime(time)
19 |
20 | return (
21 | <>
22 |
23 |
24 |
25 | {by}
26 | ·
27 | {relativeTime}
28 |
29 |
30 |
31 | {text}
32 |
33 |
34 | {kids?.length > 0 && }
35 | >
36 | )
37 | }
38 |
39 | export const ListOfComments = (props: {
40 | ids: number[]
41 | }) => {
42 | const { ids } = props
43 |
44 | return (
45 |
46 | {
47 | ids?.map((id: number) => (
48 | -
49 |
50 |
51 | ))
52 | }
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/components/Story.css.ts:
--------------------------------------------------------------------------------
1 | import { style } from '@vanilla-extract/css'
2 |
3 | export const story = style({
4 | color: '#374151',
5 | marginBottom: '8px'
6 | })
7 |
8 | export const storyTitle = style({
9 | textDecoration: 'none',
10 | color: '#111',
11 | fontSize: '18px'
12 | })
13 |
14 | export const storyHeader = style({
15 | display: 'flex',
16 | alignItems: 'center',
17 | gap: '8px',
18 | marginBottom: '2px',
19 | lineHeight: '24px'
20 | })
21 |
22 | export const storyFooter = style({
23 | display: 'flex',
24 | alignItems: 'center',
25 | gap: '8px',
26 | lineHeight: '24px',
27 | fontSize: '12px'
28 | })
29 |
30 | export const storyLink = style({
31 | color: '#888',
32 | textDecoration: 'none',
33 | ':hover': {
34 | textDecoration: 'underline'
35 | }
36 | })
37 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/components/Story.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'wouter'
2 | import useSWR from 'swr'
3 | import { getItemInfo } from '../services/hacker-news'
4 | import { storyLink, story, storyFooter, storyHeader, storyTitle } from './Story.css'
5 | import { StoryLoader } from './StoryLoader'
6 | import { getRelativeTime } from '../utils/getRelativeTime'
7 |
8 | export const Story = (props: {
9 | id: number
10 | index: number
11 | }) => {
12 | const { id, index } = props
13 |
14 | const { data, isLoading } = useSWR(`/story/${id}`, () => getItemInfo(id))
15 |
16 | if (isLoading) {
17 | // enseñar el placeholder
18 | return
19 | }
20 |
21 | const { by, kids, score, title, url, time } = data
22 | console.log(data)
23 |
24 | let domain = ''
25 | try {
26 | domain = new URL(url).hostname.replace('www.', '')
27 | } catch {}
28 |
29 | // TODO: Create relativeTime
30 | const relativeTime = getRelativeTime(time)
31 |
32 | return (
33 |
34 |
54 |
55 |
70 |
71 | )
72 | }
73 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/components/StoryLoader.tsx:
--------------------------------------------------------------------------------
1 | import ContentLoader from 'react-content-loader'
2 |
3 | export const StoryLoader = () => {
4 | return (
5 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | font-synthesis: none;
7 | text-rendering: optimizeLegibility;
8 | -webkit-font-smoothing: antialiased;
9 | -moz-osx-font-smoothing: grayscale;
10 | -webkit-text-size-adjust: 100%;
11 | }
12 |
13 | body {
14 | margin: 0;
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import App from './App.tsx'
4 | import './index.css'
5 |
6 | createRoot(document.getElementById('root') as HTMLElement).render(
7 |
8 |
9 |
10 | )
11 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/pages/Detail.tsx:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr'
2 |
3 | import { getItemInfo } from '../services/hacker-news'
4 | import { ListOfComments } from '../components/ListOfComments'
5 | import { useEffect } from 'react'
6 |
7 | export default function DetailPage (props: {
8 | params: {
9 | id: string
10 | }
11 | }) {
12 | const { params: { id } } = props
13 |
14 | const { data, isLoading } = useSWR(`/story/${id}`, () => getItemInfo(Number(id)))
15 |
16 | const { kids, title }: { kids: number[], title: string } = data ?? {}
17 | const commentIds = kids?.slice(0, 10) ?? []
18 |
19 | useEffect(() => {
20 | document.title = `Hacker News - ${title}`
21 | }, [title])
22 |
23 | return (
24 |
25 | {
26 | isLoading
27 | ?
Loading...
28 | :
29 | }
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/pages/TopStories.tsx:
--------------------------------------------------------------------------------
1 | // import useSWR from 'swr'
2 | import useSWRInfinite from 'swr/infinite'
3 |
4 | import { getTopStories } from '../services/hacker-news'
5 | import { Story } from '../components/Story'
6 | import { useEffect, useRef } from 'react'
7 |
8 | export default function TopStoriesPage () {
9 | // const { data } = useSWR('stories', () => getTopStories(1, 10))
10 | const { data, isLoading, setSize } = useSWRInfinite(
11 | (index) => `stories/${index + 1}`, // la key que usa para cachear los resultados
12 | (key) => {
13 | const [, page] = key.split('/')
14 | return getTopStories(Number(page), 10)
15 | }
16 | )
17 |
18 | const chivatoEl = useRef(null)
19 |
20 | const stories = data?.flat()
21 |
22 | useEffect(() => {
23 | document.title = 'Hacker News - Prueba Técnica USA de Frontend'
24 | }, [])
25 |
26 | useEffect(() => {
27 | // use intersection observer to detect end of the page scroll
28 | const observer = new IntersectionObserver((entries) => {
29 | if (entries[0].isIntersecting && !isLoading) {
30 | setSize((prevSize) => prevSize + 1)
31 | }
32 | }, {
33 | rootMargin: '100px'
34 | })
35 |
36 | if (chivatoEl.current == null) {
37 | return
38 | }
39 |
40 | observer.observe(chivatoEl.current)
41 |
42 | return () => {
43 | observer.disconnect()
44 | }
45 | }, [isLoading, setSize])
46 |
47 | return (
48 | <>
49 |
50 | {stories?.map((id: number, index: number) => (
51 | -
52 |
53 |
54 | ))}
55 |
56 |
57 | {!isLoading && .}
58 |
59 | {/* */}
62 | >
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/services/hacker-news.ts:
--------------------------------------------------------------------------------
1 | export const getTopStories = async (page: number, limit: number) => {
2 | const response = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json')
3 | const json = await response.json()
4 | // page starts with 1
5 | const startIndex = (page - 1) * limit
6 | const endIndex = startIndex + limit
7 | const ids = json.slice(startIndex, endIndex)
8 |
9 | return ids
10 |
11 | // junior dev tip: use Promise.all to fetch multiple items in parallel
12 | // return await Promise.all(ids.map((id: number) => getItemInfo(id)))
13 | }
14 |
15 | export const getItemInfo = async (id: number) => {
16 | const response = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`)
17 | return await response.json()
18 | }
19 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/utils/getRelativeTime.ts:
--------------------------------------------------------------------------------
1 | const DATE_UNITS: Record = {
2 | year: 31536000,
3 | month: 2629800,
4 | day: 86400,
5 | hour: 3600,
6 | minute: 60,
7 | second: 1 // second is the smallest unit
8 | } as const
9 |
10 | const rtf = new Intl.RelativeTimeFormat('es', { numeric: 'auto' })
11 |
12 | export const getRelativeTime = (epochTime: number) => {
13 | const started = new Date(epochTime * 1000).getTime()
14 | const now = new Date().getTime()
15 |
16 | const elapsed = (started - now) / 1000
17 |
18 | for (const unit in DATE_UNITS) {
19 | const absoluteElapsed = Math.abs(elapsed)
20 |
21 | if (absoluteElapsed > DATE_UNITS[unit] || unit === 'second') {
22 | return rtf.format(
23 | Math.round(elapsed / DATE_UNITS[unit]),
24 | unit as Intl.RelativeTimeFormatUnit
25 | )
26 | }
27 | }
28 |
29 | return ''
30 | }
31 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/projects/14-hacker-news-prueba-tecnica/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 | import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react(), vanillaExtractPlugin()],
8 | })
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": [
6 | "DOM",
7 | "DOM.Iterable",
8 | "ESNext"
9 | ],
10 | "allowJs": false,
11 | "skipLibCheck": true,
12 | "esModuleInterop": false,
13 | "allowSyntheticDefaultImports": true,
14 | "strict": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "module": "ESNext",
17 | "moduleResolution": "Node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ],
26 | "references": [
27 | {
28 | "path": "./tsconfig.node.json"
29 | }
30 | ]
31 | }
--------------------------------------------------------------------------------