Commit 6667ac63 authored by Egor Kremnev's avatar Egor Kremnev

add upload files

parent 7c67ab2b
const path = require('path');
const rootPath = __dirname;
module.exports = {
rootPath,
port: 8001,
uploadPath: path.join(rootPath, 'public', 'uploads')
};
/*
GET /articles?category=1 - List of articles
GET /articles/:id - Show article by id
POST /articles - Create article
PUT /articles/:id - Update article by id
PATCH /articles/:id - Partial update article by id
DELETE /articles/:id - Remove article by id
GET /products - List of articles
GET /products/:id - Show article by id
POST /products - Create article
PUT /products/:id - Update article by id
PATCH /products/:id - Partial update article by id
DELETE /products/:id - Remove article by id
*/
const cors = require('cors'); const cors = require('cors');
const express = require('express'); const express = require('express');
const app = express(); const app = express();
const port = 8001; const {port} = require('./config');
const createProductsRoutes = require('./routes/products'); const createProductsRoutes = require('./routes/products');
const fileDb = require('./db/fileDb'); const fileDb = require('./db/fileDb');
...@@ -24,6 +9,7 @@ fileDb.init(); ...@@ -24,6 +9,7 @@ fileDb.init();
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use(express.static('public'));
app.use('/api/v1/products', createProductsRoutes(fileDb)); app.use('/api/v1/products', createProductsRoutes(fileDb));
app.listen(port, () => { app.listen(port, () => {
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"fix-esm": "^1.0.1", "fix-esm": "^1.0.1",
"multer": "^1.4.5-lts.1",
"nanoid": "^4.0.2" "nanoid": "^4.0.2"
}, },
"devDependencies": { "devDependencies": {
...@@ -504,6 +505,11 @@ ...@@ -504,6 +505,11 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
...@@ -596,6 +602,22 @@ ...@@ -596,6 +602,22 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
...@@ -694,6 +716,20 @@ ...@@ -694,6 +716,20 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true "dev": true
}, },
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"engines": [
"node >= 0.8"
],
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
...@@ -731,6 +767,11 @@ ...@@ -731,6 +767,11 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/cors": { "node_modules/cors": {
"version": "2.8.5", "version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
...@@ -1088,6 +1129,11 @@ ...@@ -1088,6 +1129,11 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
...@@ -1186,11 +1232,47 @@ ...@@ -1186,11 +1232,47 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
}, },
"node_modules/multer": {
"version": "1.4.5-lts.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz",
"integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz",
...@@ -1345,6 +1427,11 @@ ...@@ -1345,6 +1427,11 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
...@@ -1399,6 +1486,25 @@ ...@@ -1399,6 +1486,25 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
...@@ -1533,6 +1639,27 @@ ...@@ -1533,6 +1639,27 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
...@@ -1596,6 +1723,11 @@ ...@@ -1596,6 +1723,11 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/undefsafe": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
...@@ -1635,6 +1767,11 @@ ...@@ -1635,6 +1767,11 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
...@@ -1651,6 +1788,14 @@ ...@@ -1651,6 +1788,14 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
......
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"fix-esm": "^1.0.1", "fix-esm": "^1.0.1",
"multer": "^1.4.5-lts.1",
"nanoid": "^4.0.2" "nanoid": "^4.0.2"
}, },
"devDependencies": { "devDependencies": {
......
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const {nanoid} = require('fix-esm').require('nanoid'); const {nanoid} = require('fix-esm').require('nanoid');
const multer = require('multer');
const path = require('path');
const {uploadPath} = require('./../config');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadPath);
},
filename: (req, file, cb) => {
cb(null, nanoid() + path.extname(file.originalname));
}
});
const upload = multer({storage});
const createRoutes = (db) => { const createRoutes = (db) => {
router.post('/', upload.single('image'), (req, res) => {
const item = {...req.body, id: nanoid()};
if (req.file) item.image = req.file.filename;
db.addItem(item);
res.send(item);
});
router.get('/', (req, res) => { router.get('/', (req, res) => {
res.send(db.getItems()); res.send(db.getItems());
}); });
...@@ -14,13 +37,6 @@ const createRoutes = (db) => { ...@@ -14,13 +37,6 @@ const createRoutes = (db) => {
res.sendStatus(404); res.sendStatus(404);
}); });
router.post('/', (req, res) => {
const item = {...req.body, id: nanoid()};
console.log(item);
db.addItem(item);
res.send(item);
});
return router; return router;
}; };
......
import axios from "axios"; import axios from "axios";
import {apiUrl} from "../constants/config";
const instance = axios.create({ const instance = axios.create({
baseURL: "http://localhost:8001/api/v1" baseURL: apiUrl + "/api/v1"
}); });
export default instance; export default instance;
import {useState} from "react"; import {useState} from "react";
import {Button, Grid, TextField} from "@mui/material"; import {Button, Grid, TextField} from "@mui/material";
import FileInput from "../../UI/FileInput/FileInput";
const ProductForm = ({createProductHandler}) => { const ProductForm = ({createProductHandler}) => {
const [state, setState] = useState({ const [state, setState] = useState({
title: "", title: "",
price: "", price: "",
description: "" description: "",
image: ''
}); });
const submitFormHandler = e => { const submitFormHandler = e => {
e.preventDefault(); e.preventDefault();
createProductHandler(state);
const formData = new FormData();
Object.keys(state).forEach(key => {
formData.append(key, state[key]);
});
createProductHandler(formData);
}; };
const inputChangeHandler = e => { const inputChangeHandler = e => {
...@@ -22,6 +31,15 @@ const ProductForm = ({createProductHandler}) => { ...@@ -22,6 +31,15 @@ const ProductForm = ({createProductHandler}) => {
}); });
}; };
const onFileChangeHandler = e => {
const file = e.currentTarget.files[0];
const name = e.currentTarget.name;
setState(prevState => {
return {...prevState, [name]: file};
});
};
return ( return (
<form <form
autoComplete="off" autoComplete="off"
...@@ -63,6 +81,13 @@ const ProductForm = ({createProductHandler}) => { ...@@ -63,6 +81,13 @@ const ProductForm = ({createProductHandler}) => {
name="description" name="description"
/> />
</Grid> </Grid>
<Grid item xs>
<FileInput
onChange={onFileChangeHandler}
name="image"
label="Image"
/>
</Grid>
<Grid item xs> <Grid item xs>
<Button type="submit" color="primary" variant="contained">Create</Button> <Button type="submit" color="primary" variant="contained">Create</Button>
</Grid> </Grid>
......
import {Card, CardActions, CardContent, CardHeader, Grid, IconButton} from "@mui/material"; import {Card, CardActions, CardContent, CardHeader, Grid, IconButton, CardMedia} from "@mui/material";
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import {PRODUCT_VIEW} from "../../../constants/routes"; import {PRODUCT_VIEW} from "../../../constants/routes";
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import imageNotAvailable from '../../../assets/images/image_not_available.png';
import {uploadUrl} from "../../../constants/config";
const ProductItem = ({product}) => {
const imagePath = product.image
? uploadUrl + '/' + product.image
: imageNotAvailable;
const ProductItem = ({title, price, id}) => {
return ( return (
<Grid item xs={12} sm={12} md={6} lg={4}> <Grid item xs={12} sm={12} md={6} lg={4}>
<Card> <Card>
<CardHeader title={title}/> <CardHeader title={product.title}/>
<CardContent> <CardContent>
<CardMedia
image={imagePath}
title={product.title}
sx={{maxWidth:200, height: 200}}
/>
<strong style={{marginLeft: '10px'}}> <strong style={{marginLeft: '10px'}}>
Price: {price} KZT Price: {product.price} KZT
</strong> </strong>
</CardContent> </CardContent>
<CardActions> <CardActions>
<IconButton component={Link} to={PRODUCT_VIEW.replace(':id', id)}> <IconButton component={Link} to={PRODUCT_VIEW.replace(':id', product.id)}>
<ArrowForwardIosIcon/> <ArrowForwardIosIcon/>
</IconButton> </IconButton>
</CardActions> </CardActions>
......
import {useState, useRef} from 'react';
import {Button, Grid, TextField} from '@mui/material';
const FileInput = ({onChange, name, label}) => {
const [filename, setFilename] = useState('');
const inputRef = useRef();
const activateInput = () => {
inputRef.current.click();
};
const onChangeFile = (e) => {
const file = e.currentTarget.files[0];
setFilename(file ? file.name : '');
onChange(e);
};
return (
<>
<input
type="file"
name={name}
ref={inputRef}
onChange={onChangeFile}
accept="image/*"
style={{display: 'none'}}
/>
<Grid
container
direction="row"
spacing={2}
alignItems="center"
>
<Grid item xs>
<TextField
label={label}
disabled
variant="standard"
fullWidth
value={filename}
onClick={activateInput}
/>
</Grid>
<Grid item>
<Button
variant="contained"
onClick={activateInput}
>
Browse file
</Button>
</Grid>
</Grid>
</>
);
};
export default FileInput;
export const apiUrl = "http://localhost:8001";
export const uploadUrl = apiUrl + '/uploads';
...@@ -45,9 +45,7 @@ const Products = () => { ...@@ -45,9 +45,7 @@ const Products = () => {
products.map(product => ( products.map(product => (
<ProductItem <ProductItem
key={product.id} key={product.id}
id={product.id} product={product}
title={product.title}
price={product.price}
/> />
)) ))
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment