Alex пре 2 година
родитељ
комит
6700409164

+ 5 - 0
04/README.md

@@ -0,0 +1,5 @@
+### HW REACT Router
+
+- Страница логина, пароля
+- Страница корзины
+- Страница товара

Разлика између датотеке није приказан због своје велике величине
+ 30405 - 0
04/package-lock.json


+ 43 - 0
04/package.json

@@ -0,0 +1,43 @@
+{
+  "name": "router",
+  "version": "0.1.0",
+  "private": true,
+  "dependencies": {
+    "@testing-library/jest-dom": "^5.16.1",
+    "@testing-library/react": "^12.1.2",
+    "@testing-library/user-event": "^13.5.0",
+    "node-sass": "^7.0.0",
+    "react": "^17.0.2",
+    "react-dom": "^17.0.2",
+    "react-redux": "^7.2.6",
+    "react-router-dom": "^5.3.0",
+    "react-scripts": "5.0.0",
+    "redux": "^4.1.2",
+    "redux-thunk": "^2.4.1",
+    "web-vitals": "^2.1.2"
+  },
+  "scripts": {
+    "start": "react-scripts start",
+    "build": "react-scripts build",
+    "test": "react-scripts test",
+    "eject": "react-scripts eject"
+  },
+  "eslintConfig": {
+    "extends": [
+      "react-app",
+      "react-app/jest"
+    ]
+  },
+  "browserslist": {
+    "production": [
+      ">0.2%",
+      "not dead",
+      "not op_mini all"
+    ],
+    "development": [
+      "last 1 chrome version",
+      "last 1 firefox version",
+      "last 1 safari version"
+    ]
+  }
+}

BIN
04/public/favicon.ico


+ 43 - 0
04/public/index.html

@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="theme-color" content="#000000" />
+    <meta
+      name="description"
+      content="Web site created using create-react-app"
+    />
+    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
+    <!--
+      manifest.json provides metadata used when your web app is installed on a
+      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
+    -->
+    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+    <!--
+      Notice the use of %PUBLIC_URL% in the tags above.
+      It will be replaced with the URL of the `public` folder during the build.
+      Only files inside the `public` folder can be referenced from the HTML.
+
+      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
+      work correctly both with client-side routing and a non-root public URL.
+      Learn how to configure a non-root public URL by running `npm run build`.
+    -->
+    <title>React App</title>
+  </head>
+  <body>
+    <noscript>You need to enable JavaScript to run this app.</noscript>
+    <div id="root"></div>
+    <!--
+      This HTML file is a template.
+      If you open it directly in the browser, you will see an empty page.
+
+      You can add webfonts, meta tags, or analytics to this file.
+      The build step will place the bundled scripts into the <body> tag.
+
+      To begin the development, run `npm start` or `yarn start`.
+      To create a production bundle, use `npm run build` or `yarn build`.
+    -->
+  </body>
+</html>

BIN
04/public/logo192.png


BIN
04/public/logo512.png


+ 25 - 0
04/public/manifest.json

@@ -0,0 +1,25 @@
+{
+  "short_name": "React App",
+  "name": "Create React App Sample",
+  "icons": [
+    {
+      "src": "favicon.ico",
+      "sizes": "64x64 32x32 24x24 16x16",
+      "type": "image/x-icon"
+    },
+    {
+      "src": "logo192.png",
+      "type": "image/png",
+      "sizes": "192x192"
+    },
+    {
+      "src": "logo512.png",
+      "type": "image/png",
+      "sizes": "512x512"
+    }
+  ],
+  "start_url": ".",
+  "display": "standalone",
+  "theme_color": "#000000",
+  "background_color": "#ffffff"
+}

+ 3 - 0
04/public/robots.txt

@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:

+ 543 - 0
04/src/App.js

@@ -0,0 +1,543 @@
+import React, {useState, useEffect, useRef, Component} from 'react';
+import logoDefault from './logo.svg';
+import './App.scss';
+import {Provider, connect} from 'react-redux';
+import {createStore, combineReducers, applyMiddleware} from 'redux';
+import thunk from 'redux-thunk';
+import {Router, Route, Link, Switch} from 'react-router-dom';
+import createHistory from "history/createBrowserHistory";
+
+const history = createHistory()
+const jwtDecode = token => {
+    try {
+        let arrToken = token.split('.')
+        let base64Token = atob(arrToken[1])
+        return JSON.parse(base64Token)
+    }
+    catch (e) {
+        console.log('Лажа, Бро ' + e);
+    }
+}
+function authReducer(state, { type, token }) {
+    if (!state) {
+        if (localStorage.authToken) {
+            type = 'AUTH_LOGIN'
+            token = localStorage.authToken
+        } else state = {}
+    }
+    if (type === 'AUTH_LOGIN') {
+        localStorage.setItem('authToken', token)
+        let payload = jwtDecode(token)
+        if (typeof payload === 'object') {
+            return {
+                ...state,
+                token,
+                payload
+            }
+        } else return state
+    }
+    if (type === 'AUTH_LOGOUT') {
+        localStorage.removeItem('authToken')
+        return {}
+    }
+    return state
+}
+const actionAuthLogin = token => ({ type: 'AUTH_LOGIN', token })
+const actionAuthLogout = () => ({ type: 'AUTH_LOGOUT' })
+
+function cartReducer(state = {}, { type, good = {}, count = 1 }) {
+    const { _id } = good
+    const types = {
+        CART_ADD() {
+            count = +count
+            if (!count) return state
+            return {
+                ...state,
+                [_id]: {
+                    good,
+                    count: count + (state[_id]?.count || 0)
+                }
+            }
+        },
+        CART_CHANGE() {
+            count = +count;
+            if (!count){
+                return state
+            }
+            return {
+                ...state,
+                [_id]: {good, count}
+            }
+        },
+        CART_REMOVE() {
+            let { [_id]: remove, ...newState } = state
+            return {
+                ...newState
+            }
+        },
+        CART_CLEAR() {
+            return {}
+        },
+    }
+    if (type in types) {
+        return types[type]()
+    }
+    return state
+}
+const actionCartAdd = (good, count=1) => ({type: "CART_ADD", good, count});
+const actionCardChange = (good, count) => ({type: 'CART_CHANGE', good, count})
+const actionCardRemove = (good) => ({type: 'CART_REMOVE', good})
+const actionCardClear = () => ({type: 'CART_CLEAR'})
+
+function promiseReducer(state = {}, { type, status, payload, error, name }) {
+    if (type === 'PROMISE') {
+        return {
+            ...state,
+            [name]: { status, payload, error }
+        }
+    }
+    return state;
+}
+const actionPending = name => ({ type: 'PROMISE', status: 'PENDING', name })
+const actionResolved = (name, payload) => ({ type: 'PROMISE', status: 'RESOLVED', name, payload })
+const actionRejected = (name, error) => ({ type: 'PROMISE', status: 'REJECTED', name, error })
+const actionPromise = (name, promise) =>
+    async dispatch => {
+        dispatch(actionPending(name))
+        try {
+            let data = await promise
+            dispatch(actionResolved(name, data))
+            return data
+        }
+        catch (error) {
+            dispatch(actionRejected(name, error))
+        }
+    }
+const actionRootCats = () =>
+    actionPromise('rootCats', gql(`query {
+            CategoryFind(query: "[{\\"parent\\":null}]"){
+                _id name
+            }
+        }`))
+const actionCatById = (_id) =>
+    actionPromise('catById', gql(`query catById($q: String){
+            CategoryFindOne(query: $q){
+                subCategories{name, _id}
+                _id name goods {
+                    _id name price images {
+                        url
+                    }
+                }
+            }
+        }`, { q: JSON.stringify([{ _id }]) }))
+const actionGoodById = (_id) =>
+    actionPromise('goodById', gql(`query goodById($q: String){
+    GoodFindOne(query: $q){
+            _id name description price images{
+              url
+            }
+        }
+    }`, {q: JSON.stringify([{_id}])}))
+
+const actionFullLogin = (login, password) =>
+    async function i(dispatch) {
+        let token = await dispatch(actionLogin(login, password));
+        if (token) {
+            dispatch(actionAuthLogin(token));
+        }
+    }
+const actionLogin = (login, password) => {
+    return actionPromise(
+        "login", gql(
+            `query log($l:String, $p:String) {
+              login(login:$l, password:$p)
+              }`,
+            { l: login, p: password }
+        )
+    );
+}
+const actionRegister = (login, password) =>
+    actionPromise('register', gql(`mutation register($login:String, $password: String){
+      UserUpsert(user:{
+                 login: $login, 
+                 password: $password, 
+                 nick: $login}){
+        _id login
+      }
+    }`, {login: login, password: password}))
+const actionFullRegister = (login, password) =>
+    async dispatch => {
+        let allow = await dispatch(actionRegister(login, password))
+        if (allow) {
+            let token = await dispatch(actionLogin(login, password))
+            if (token){
+                console.log('okay')
+                dispatch(actionAuthLogin(token))
+            }
+        }
+    }
+
+const getGQL = url =>
+    async (query, variables = {}) => {
+        let obj = await fetch(url, {
+            method: 'POST',
+            headers: {
+                "Content-Type": "application/json",
+                Authorization: localStorage.authToken ? 'Bearer ' + localStorage.authToken : {},
+            },
+            body: JSON.stringify({ query, variables })
+        })
+        let a = await obj.json()
+        if (!a.data && a.errors)
+            throw new Error(JSON.stringify(a.errors))
+        return a.data[Object.keys(a.data)[0]]
+    }
+const backURL = 'http://shop-roles.asmer.fs.a-level.com.ua'
+const gql = getGQL(backURL + '/graphql');
+
+const store = createStore(combineReducers({promise: promiseReducer,
+    auth: authReducer,
+    cart: cartReducer}), applyMiddleware(thunk))
+store.subscribe(() => console.log(store.getState()))
+store.dispatch(actionRootCats())
+
+
+const Logo = ({logo=logoDefault}) =>
+    <Link to='/' className="Logo">
+      <img src={logo} />
+    </Link>
+
+const Koshik = ({cart}) => {
+    let count = 0;
+    let sum = Object.entries(cart).map(([, val]) => val.count)
+    count = sum.reduce((a, b) => a + b, 0)
+    return (
+        <div>
+            <Link className='Koshik' to={`/order`}>Корзина: {count}</Link>
+        </div>
+    )
+}
+const CKoshik = connect(({cart}) => ({cart}))(Koshik)
+
+const Header = ({logo=logoDefault}) =>
+    <header>
+      <Logo logo={logo} />
+      <CKoshik />
+      <CPageAuthorizations/>
+    </header>
+
+const Footer = ({logo=logoDefault}) =>
+    <footer>
+      <Logo logo={logo} />
+    </footer>
+
+const defaultRootCats = [
+  {
+    "_id": "5dc49f4d5df9d670df48cc64",
+    "name": "Airconditions"
+  },
+  {
+    "_id": "5dc458985df9d670df48cc47",
+    "name": "     Smartphones"
+  },
+  {
+    "_id": "5dc4b2553f23b553bf354101",
+    "name": "Крупная бытовая техника"
+  },
+  {
+    "_id": "5dcac1b56d09c45440d14cf8",
+    "name": "Макароны"
+  }]
+
+const RootCategory = ({cat:{_id, name}={}}) =>
+    <li>
+      <Link to={`/category/${_id}`}>{name}</Link>
+    </li>
+
+const RootCategories = ({cats=defaultRootCats}) =>
+    <ul className='RootCategories'>
+      {cats.map(cat => <RootCategory cat={cat} />)}
+    </ul>
+
+const CRootCategories = connect(state => ({cats: state.promise.rootCats?.payload || []}))
+(RootCategories)
+
+const Aside = () =>
+    <aside>
+      <CRootCategories />
+    </aside>
+
+const Content = ({children}) =>
+    <div className='Content'>
+      {children}
+    </div>
+
+const defaultCat ={
+  "subCategories": null,
+  "_id": "5dc458985df9d670df48cc47",
+  "name": "     Smartphones",
+  "goods": [
+    {
+      "_id": "61b105f9c750c12ba6ba4524",
+      "name": "iPhone ",
+      "price": 1200,
+      "images": [
+        {
+          "url": "images/50842a3af34bfa28be037aa644910d07"
+        }
+      ]
+    },
+    {
+      "_id": "61b1069ac750c12ba6ba4526",
+      "name": "iPhone ",
+      "price": 1000,
+      "images": [
+        {
+          "url": "images/d12b07d983dac81ccad404582a54d8be"
+        }
+      ]
+    },
+    {
+      "_id": "61b23f94c750c12ba6ba472a",
+      "name": "name1",
+      "price": 1214,
+      "images": [
+        {
+          "url": null
+        }
+      ]
+    },
+    {
+      "_id": "61b23fbac750c12ba6ba472c",
+      "name": "smart",
+      "price": 1222,
+      "images": [
+        {
+          "url": "images/871f4e6edbf86c35f70b72dcdebcd8b2"
+        }
+      ]
+    }
+  ]
+}
+
+const SubCategories = ({cats}) => <></>
+
+const GoodCard = ({good:{_id, name, price, images}={}, onCartAdd}) =>
+    <div className='GoodCard'>
+      <h2>{name}</h2>
+      {images && images[0] && images[0].url && <img src={backURL + '/' + images[0].url} />}
+      <p>Цена: {price}</p>
+      <Link to={`/good/${_id}`}>Страница товара</Link><br/>
+      <button onClick={() => onCartAdd({_id, name, price, images})}>+</button>
+    </div>
+
+const CGoodCard = connect(null, {onCartAdd: actionCartAdd})(GoodCard)
+
+const Category = ({cat:{_id, name, goods, subCategories}=defaultCat}) =>
+    <div className='Category'>
+      <h1>{name}</h1>
+      {subCategories && <SubCategories cats={subCategories} />}
+      {(goods || []).map(good => <CGoodCard good={good}/>)}
+    </div>
+
+const CCategory = connect(state => ({cat: state.promise.catById?.payload}))(Category)
+
+//корзина
+const actionOrder = (card) =>
+    async (dispatch, state) => {
+        let orderGoods = Object.entries(card).map(([_id, {good ,count}]) => ({good: {_id}, count}))
+        let res = await dispatch(actionPromise('order', gql(`
+                    mutation order($order:OrderInput){
+                      OrderUpsert(order:$order)
+                        { _id total }
+                    }
+            `, {order: {orderGoods}})))
+    }
+const CartItem = ({item, onCartChange, onCartRemove}) => {
+    const {good, count} = item[1]
+    let [value, setValue] = useState(count)
+    return (
+        <tr className='CartItem'>
+            <td>{good.name}</td>
+            <td><img src={backURL + '/' + good.images[0].url}/></td>
+            <td>{good.price}$</td>
+            <td><input type='number' min='0' max='100' value={value} onChange={e => {setValue(e.target.value.toString()); onCartChange(good, e.target.value)}}/></td>
+            <td><button type='number' onClick={() => onCartRemove(good)}>Remove</button></td>
+        </tr>
+    )
+}
+const CCartItem = connect(null, {onCartChange: actionCardChange, onCartRemove: actionCardRemove})(CartItem)
+
+const FullCart = ({cart = {}, onCartClear, onActionOrder}) => {
+    let arrCart = Object.entries(cart);
+    if (arrCart.length !== 0){
+        return (
+            <table>
+                <tr><th>Название товара</th><th>Изображение</th><th>Цена</th><th>Количество</th><th>Убрать</th></tr>
+                {(arrCart || []).map(item => <CCartItem item={item}/>)}
+                <tr>
+                    <button onClick={() => onCartClear()}>Очистить корзину</button>
+                    <button disabled={!localStorage.authToken} onClick={() => onActionOrder(cart)}>Заказать</button>
+                    {!localStorage.authToken && <span>Авторизируйтесь!!!!</span>}
+                </tr>
+            </table>
+        )
+    }
+    else {
+        return (
+            <div>Корзина пуста</div>
+        )
+    }
+}
+const CFullCart = connect(state => ({cart: state.cart}), {onCartClear: actionCardClear, onActionOrder: actionOrder})(FullCart)
+
+const Order = ({state}) => {
+    if (state?.promise?.order?.payload) {
+        console.log(state.promise.order.payload)
+        return(
+            <div>
+                <p>Ваш заказ успешно отработан!!!</p>
+                <p>ID заказа: {state.promise.order.payload['_id']}</p>
+                <p>На сумму: {state.promise.order.payload['total']}</p>
+            </div>
+        )
+    }
+    else {
+        return (
+            <CFullCart/>
+        )
+    }
+}
+const COrder = connect(state => ({state: state}))(Order)
+
+// логин - регистрация
+const LoginForm = ({onLogin, onRegister}) => {
+    let [login, setLogin] = useState('')
+    let [password, setPassword] = useState('')
+    return(
+        <div className='LoginForm'>
+            <input style={{border: (login === '') ? '1px solid red': '1px solid black'}} value={login} onChange={e => setLogin(e.target.value.toString())} type='text'/>
+            <input style={{border: (password === '') ? '1px solid red': '1px solid black'}} value={password} onChange={e => setPassword(e.target.value.toString())} type='password'/>
+            <button disabled={login === '' && password === '' || login === '' && password !== ''|| login !== '' && password === ''}
+                    onClick={() => onLogin(login, password)}>Login</button>
+            <button disabled={login === '' && password === '' || login === '' && password !== ''|| login !== '' && password === ''}
+                    onClick={() => onRegister(login, password)}>Registration</button>
+        </div>
+    )
+}
+const CLoginForm = connect(null,{onLogin: actionFullLogin, onRegister: actionFullRegister})(LoginForm)
+
+const AuthAccess = ({auth}) => {
+    if (auth.payload){
+        let {iat} = auth.payload
+        let date = new Date(iat * 1000);
+        let hours = date.getHours();
+        let minutes = "0" + date.getMinutes();
+        let seconds = "0" + date.getSeconds();
+        let formattedTime = hours + ':' + minutes.substr(-2) + ':' + seconds.substr(-2);
+        return (
+            <div>
+                <h1>Страница авторизации</h1>
+                <p>Вы успешно авторизированны!!!</p>
+                <p>Ваш логин:{auth.payload.sub.login}</p>
+                <p>Ваш ID: {auth.payload.sub.id}</p>
+                <p>Время авторизации: {formattedTime}</p>
+            </div>
+        )
+    }
+    else {
+        return (
+            <CLoginForm/>
+        )
+    }
+}
+const CAuthAccess = connect(state => ({auth: state.auth}))(AuthAccess)
+
+//Страницы
+const PageMain = () =>
+    <h1>Главная страница</h1>
+const PageCategory = ({match:{params:{_id}}, getData, history}) => {
+  useEffect(() => {
+    getData(_id)
+  },[_id])
+  return(
+      <CCategory />
+  )
+}
+const CPageCategory = connect(null, {getData: actionCatById})(PageCategory)
+
+const PageAuthorizations = ({auth, actionLogOut}) => {
+    if (auth.payload){
+        return (
+            <div>
+                <strong>Вы успешно авторизованы! Привет {auth.payload.sub.login}</strong>
+                <button onClick={() => actionLogOut()}>Выйти</button>
+            </div>
+        )
+    }
+    else {
+        return (
+            <Link to={`/authorizations`}>Войти/Зарегистрироваться</Link>
+        )
+    }
+}
+const CPageAuthorizations = connect(state => ({auth: state.auth}),{actionLogOut: actionAuthLogout})(PageAuthorizations)
+
+const Good = ({good:{_id, name, description, price, images}={}, onCartAdd}) => {
+    return (
+        <div className='good'>
+            <h1>Страница товара</h1>
+            <h1>{name}</h1>
+            {images && images[0] && images[0].url && <img src={backURL + '/' + images[0].url} />}
+            <p><strong>Описание:</strong> {description}</p>
+            <p><strong>ID:</strong> {_id}</p>
+            <strong>Цена: {price} USD</strong>
+            <button onClick={() => onCartAdd({_id, name, price, images})}>Добавить в корзину</button>
+        </div>
+    )
+}
+const CGood = connect(state => ({good: state.promise.goodById?.payload}),{onCartAdd: actionCartAdd})(Good)
+
+const PageGood = ({match:{params:{_id}}, getData}) => {
+    useEffect(() => {
+        getData(_id)
+    },[_id])
+    return (
+        <CGood/>
+    )
+}
+const CPageGood = connect(null, {getData: actionGoodById})(PageGood)
+const Page404 = () => <h1>PAGE НЭМА</h1>
+
+const Main = () =>
+    <main>
+      <Aside />
+      <Content>
+        <Switch>
+          <Route path="/" component={PageMain} exact />
+          <Route path="/category/:_id" component={CPageCategory} />
+          <Route path="/authorizations" component={CAuthAccess} />
+          <Route path="/order" component={COrder} />
+          <Route path="/good/:_id" component={CPageGood} />
+          <Route path="*" component={Page404} />
+        </Switch>
+      </Content>
+    </main>
+
+
+
+function App() {
+  return (
+      <Router history={history}>
+        <Provider store={store}>
+          <div className="App">
+            <Header />
+            <Main />
+            <Footer />
+          </div>
+        </Provider>
+      </Router>
+  );
+}
+
+export default App;

+ 58 - 0
04/src/App.scss

@@ -0,0 +1,58 @@
+.Logo{
+  img{
+    max-height: 100px;
+  }
+}
+.GoodCard {
+  border: 2px solid red;
+  border-radius: 20px;
+  img{
+    max-width: 300px;
+  }
+  padding-bottom: 20px;
+}
+.App {
+  text-align: left;
+  header{
+    .Logo{
+      img{
+        max-height: 100px;
+      }
+    }
+  }
+  main{
+    display: flex;
+    flex-direction: row;
+    aside{
+      width: 30%;
+      background-color: cyan;
+    }
+  }
+  footer{
+    background-color: #303030;
+    .Logo{
+      img{
+        max-height: 200px;
+      }
+    }
+  }
+}
+table{
+  width: 100%;
+  border-collapse: collapse;
+}
+table, th, td{
+  border: 1px solid black;
+  text-align: center;
+  align-items: center;
+}
+.CartItem{
+  img{
+    max-width: 100px;
+  }
+}
+.good{
+  img{
+    max-width: 300px;
+  }
+}

+ 8 - 0
04/src/App.test.js

@@ -0,0 +1,8 @@
+import { render, screen } from '@testing-library/react';
+import App from './App';
+
+test('renders learn react link', () => {
+  render(<App />);
+  const linkElement = screen.getByText(/learn react/i);
+  expect(linkElement).toBeInTheDocument();
+});

+ 13 - 0
04/src/index.css

@@ -0,0 +1,13 @@
+body {
+  margin: 0;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+    sans-serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+    monospace;
+}

+ 17 - 0
04/src/index.js

@@ -0,0 +1,17 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import './index.css';
+import App from './App';
+import reportWebVitals from './reportWebVitals';
+
+ReactDOM.render(
+  <React.StrictMode>
+    <App />
+  </React.StrictMode>,
+  document.getElementById('root')
+);
+
+// If you want to start measuring performance in your app, pass a function
+// to log results (for example: reportWebVitals(console.log))
+// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
+reportWebVitals();

Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
04/src/logo.svg


+ 13 - 0
04/src/reportWebVitals.js

@@ -0,0 +1,13 @@
+const reportWebVitals = onPerfEntry => {
+  if (onPerfEntry && onPerfEntry instanceof Function) {
+    import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
+      getCLS(onPerfEntry);
+      getFID(onPerfEntry);
+      getFCP(onPerfEntry);
+      getLCP(onPerfEntry);
+      getTTFB(onPerfEntry);
+    });
+  }
+};
+
+export default reportWebVitals;

+ 5 - 0
04/src/setupTests.js

@@ -0,0 +1,5 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// allows you to do things like:
+// expect(element).toHaveTextContent(/react/i)
+// learn more: https://github.com/testing-library/jest-dom
+import '@testing-library/jest-dom';