ํด๋ผ์ด์ธํธ
์ ์๋ฒ
์ Dependencies
๋ค์ด๋ฐ๊ธฐ
npm install
Server
์ Root ๊ฒฝ๋ก, Client
๋ clientํด๋ ๊ฒฝ๋กserver/config/dev.js
ํ์ผ ์ค์
MongoDB
๋ก๊ทธ์ธdev.js
ํ์ผ์ ๋ฃ๋๋ค.โญ// server/config/dev.js
module.exports = {
mongoURI:
'mongodb+srv://devPark:<password>@react-boiler-plate.ovbtd.mongodb.net/react-shop-app?retryWrites=true&w=majority'
}
โญ server/models/Product.js
const mongoose = require('mongoose')
const Schema = mongoose.Schema
const productSchema = mongoose.Schema(
{
writer: {
type: Schema.Types.ObjectId,
ref: 'User',
},
title: {
type: String,
maxlength: 50,
},
description: {
type: String,
},
price: {
type: Number,
default: 0,
},
images: {
type: Array,
default: [],
},
sold: {
type: Number,
maxlength: 100,
default: 0,
},
continents: {
type: Number,
default: 1,
},
views: {
type: Number,
default: 0,
},
},
{ timestamps: true }
)
const Product = mongoose.model('Product', productSchema)
module.exports = { Product }
Multer
$ npm install multer --save
multipart/form-data
๋ฅผ ๋ค๋ฃจ๊ธฐ ์ํ node.js ์ ๋ฏธ๋ค์จ์ด์ด๋ฉฐ ํจ์จ์ฑ์ ์ต๋ํ ํ๊ธฐ ์ํด busboy ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ํ๊ณ ์๋ค.โญ server/routes/product.js
const express = require('express')
const router = express.Router()
const multer = require('multer')
const { Product } = require('../models/Product')
var storage = multer.diskStorage({
// ํ์ผ ์ ์ฅ ๊ฒฝ๋ก์ค์
destination: function (req, file, cb) {
cb(null, 'uploads/')
},
// ์ด๋ค ์ด๋ฆ์ผ๋ก ์ ์ฅ๋ ์ง ์ค์
filename: function (req, file, cb) {
cb(null, `${Date.now()}_${file.originalname}`)
}
})
var upload = multer({ storage: storage }).single('file')
router.post('/image', (req, res) => {
// ์ด๋ฏธ์ง๋ฅผ ์ ์ฅ
upload(req, res, err => {
if (err) {
return req.json({ success: false, err })
}
// ํด๋ผ์ด์ธํธ๋ก ํ์ผ์ ๋ณด๋ฅผ ์ ๋ฌ
return res.json({ success: true,
filePath: res.req.file.path,
fileName: res.req.file.filename})
})
})
// ํด๋ผ์ด์ธํธ์์ ๋ณด๋ด์ง ์ ๋ณด๋ฅผ MongoDB์ ์ ์ฅ
router.post('/', (req, res) => {
const product = new Product(req.body)
product.save((err) => {
if (err) return res.status(400).json({ success: false, err })
return res.status(200).json({ success: true })
})
})
react-dropzone
$npm install react-dropzone --save
โญ UploadProductPage.js
import React, { useState } from 'react'
import { Button, Form, Input } from 'antd'
import FileUpload from '../../utils/FileUpload'
import axios from 'axios'
const { TextArea } = Input
const Continents = [
{ key: 1, value: 'Africa' },
{ key: 2, value: 'Europe' },
{ key: 3, value: 'Asia' },
{ key: 4, value: 'North America' },
{ key: 5, value: 'South America' },
{ key: 6, value: 'Australia' },
{ key: 7, value: 'Antarctica' }
]
function UploadProductPage(props) {
const [Title, setTitle] = useState('')
const [Description, setDescription] = useState('')
const [Price, setPrice] = useState(0)
const [Continent, setContinent] = useState(1)
const [Images, setImages] = useState([])
const titleChangeHandler = (e) => {
setTitle(e.currentTarget.value)
}
const descriptionChangeHandler = (e) => {
setDescription(e.currentTarget.value)
}
const priceChangeHandler = (e) => {
setPrice(e.currentTarget.value)
}
const continentChangeHandler = (e) => {
setContinent(e.currentTarget.value)
}
// 2. ์์ ์ปดํฌ๋ํธ(FileUpload.js)์ ์ด๋ฏธ์ง ๋ฐ์ดํฐ๋ฅผ ์ ์ฅ
const updateImages = (newImages) => {
setImages(newImages)
}
// 3. Form์ ์
๋ ฅํ ๋ฐ์ดํฐ๋ฅผ ์๋ฒ๋ก ๋ณด๋ด๊ธฐ
const submitHandler = (e) => {
e.preventDefault()
if (!Title || !Description || !Price || !Continent || Images.length === 0) {
return alert('๋ชจ๋ ๊ฐ์ ๋ฃ์ด์ฃผ์
์ผ ํฉ๋๋ค.')
}
const body = {
// UploadProductPage.js๋ auth.js์ ์์์ปดํฌ๋ํธ
// let user = useSelector(state => state.user)
// <SpecificComponent user={user} />
// ๋ก๊ทธ์ธ ๋ ์ฌ๋์ ID
writer: props.user.userData._id,
title: Title,
description: Description,
price: Price,
images: Images,
continents: Continent
}
axios.post('/api/product', body)
.then(res => {
if (res.data.success) {
alert('์ํ ์
๋ก๋์ ์ฑ๊ณต ํ์ต๋๋ค.')
props.history.push('/')
} else {
alert('์ํ ์
๋ก๋์ ์คํจ ํ์ต๋๋ค.')
}
})
}
return (
<div style={{ maxWidth: '700px', margin: '2rem auto' }}>
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<h2> ์ฌํ ์ํ ์
๋ก๋ </h2>
</div>
<Form onSubmit={submitHandler}>
{/* DropZone :
1. ์์ ์ปดํฌ๋ํธ(FileUpload.js)์ ์ด๋ฏธ์ง ๋ฐ์ดํฐ๋ฅผ ๋ฐ์์จ๋ค. */}
<FileUpload refreshFunction={updateImages}/>
<br />
<br />
<label>์ด๋ฆ</label>
<Input vluae={Title} onChange={titleChangeHandler}/>
<br />
<br />
<label>์ค๋ช
</label>
<TextArea value={Description} onChange={descriptionChangeHandler}/>
<br />
<br />
<label>๊ฐ๊ฒฉ($)</label>
<Input type='number' value={Price} onChalnge={priceChangeHandler}/>
<br />
<br />
<select value={Continent} onChange={continentChangeHandler}>
{Continents.map(item => (
<option key={item.key} value={item.key}> {item.value} </option>
))}
</select>
<br />
<br />
<Button type='submit' onClick={submitHandler}>
ํ์ธ
</Button>
</Form>
</div>
)
}
export default UploadProductPage
โญ components/utils/FileUpload.js
import React, { useState } from 'react'
import Dropzone from 'react-dropzone'
import { Icon , Input } from 'antd'
import axios from 'axios'
function FileUpload(props) {
const [Images, setImages] = useState([])
const dropHandler = (files) => {
// ์ด๋ฏธ์ง๋ฅผ Ajax๋ก ์
๋ก๋ํ ๊ฒฝ์ฐ Form ์ ์ก์ด ํ์
let formData = new FormData()
const config = {
header: { 'content-type': 'multipart/form-data'}
}
// append๋ฅผ ํตํด ํค-๊ฐ ํ์์ผ๋ก ์ถ๊ฐ
formData.append('file', files[0])
axios.post('/api/product/image', formData, config)
.then(res => {
if (res.data.success) {
// ์๋ฒ์ ์ต์ข
๋ฐ์ดํฐ๋ฅผ ์ ๋ฌํ๊ธฐ ์ํด ์ ์ฅ
setImages([...Images, res.data.filePath])
// ๋ถ๋ชจ ์ปดํฌ๋ํธ(UploadProductPage.js)๋ก ๋ฐ์ดํฐ ์
๋ฐ์ดํธ
props.refreshFunction([...Images, res.data.filePath])
} else {
alert('ํ์ผ์ ์ ์ฅํ๋๋ฐ ์คํจํ์ต๋๋ค.')
}
})
}
// ์ด๋ฏธ์ง๋ฅผ ์ง์ฐ๋ ๊ธฐ๋ฅ
const deleteHandler = (image) => {
const currentIndex = Images.indexOf(image)
let newImages = [...Images]
newImages.splice(currentIndex, 1)
setImages(newImages)
// ๋ถ๋ชจ ์ปดํฌ๋ํธ(UploadProductPage.js)๋ก ๋ฐ์ดํฐ ์
๋ฐ์ดํธ
props.refreshFunction(newImages)
}
return (
// ์ด๋ฏธ์ง ์
๋ก๋ Form
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Dropzone onDrop={dropHandler}>
{({getRootProps, getInputProps}) => (
<div
style={{
width: 300, height: 240, border: '1px solid lightgray',
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}
{...getRootProps()}>
<Input {...getInputProps()} />
<Icon type='plus' style={{ fontSize: '3rem'}}/>
</div>
)}
</Dropzone>
{/* ์
๋ก๋๋ ์ด๋ฏธ์ง๋ฅผ ๋ฃ์ Form */}
<div style={{ display: 'flex', width: '350px', height: '240px', overflowX: 'scroll' }}>
{Images.map((image, index) => (
<div onClick={() => deleteHandler(image)} key={index}>
<img style={{ minWidth: '300px', width: '300px', height: '240px' }}
src={`http://localhost:5000/${image}`}
/>
</div>
))}
</div>
</div>
)
}
export default FileUpload
LIMIT
: ์ฒ์ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ฌ๋์ ๋๋ณด๊ธฐ ๋ฒํผ์ ๋๋ฌ์ ๊ฐ์ ธ์ฌ๋SKIP
: ์ด๋์๋ถํฐ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ ์ค๋์ง์ ๋ํ ์์น2rd Skip = 0 + 6
โญ LandingPage.js
import { Icon, Col, Card, Row} from 'antd'
import Meta from 'antd/lib/card/Meta'
import ImageSlider from '../../utils/ImageSlider'
function LandingPage() {
// 1. ๋ ๋์นด๋, ์ด๋ฏธ์ง์ฌ๋ผ์ด๋
const [Products, setProducts] = useState([])
// 2. ๋๋ณด๊ธฐ ๊ธฐ๋ฅ
const [Skip, setSkip] = useState(0)
const [Limit, setLimit] = useState(8)
const [PostSize, setPostSize] = useState(0)
// 2. ๋๋ณด๊ธฐ ๊ธฐ๋ฅ
useEffect(() => {
// ์ํ 8๊ฐ๋ง ๊ฐ์ ธ์ค๊ธฐ
let body = {
skip: Skip,
limit: Limit
}
getProducts(body)
}, [])
// 2. ๋๋ณด๊ธฐ ๊ธฐ๋ฅ
const getProducts = (body) => {
axios.post('/api/product/products', body)
.then(response => {
if (response.data.success) {
if (body.loadMore) {
setProducts([...Products, ...response.data.productInfo])
} else {
setProducts(response.data.productInfo)
}
setPostSize(response.data.postSize)
} else {
alert(' ์ํ๋ค์ ๊ฐ์ ธ์ค๋๋ฐ ์คํจํ์ต๋๋ค. ')
}
})
}
// 2. ๋๋ณด๊ธฐ ๊ธฐ๋ฅ
const loadMoreHandler = () => {
// ๋๋ณด๊ธฐ ๋ฒํผ์ ๋๋ ์ ๋ ์ถ๊ฐ product๋ฅผ ๊ฐ์ ธ์ฌ๋๋ Skip ๋ถ๋ถ์ด ๋ฌ๋ผ์ง๋ค
// Skip (0 -> 8) + Limit (8 -> 8)
let skip = Skip + Limit
let body = {
skip: skip,
limit: Limit,
loadMore: true
}
getProducts(body)
setSkip(skip)
}
// 1. ๋ ๋์นด๋, ์ด๋ฏธ์ง์ฌ๋ผ์ด๋
const renderCards = Products.map((product, index) => {
return <Col lg={6} md={8} xs={24} key={index}>
<Card
cover={<a href={`/product/${product._id}`}><ImageSlider images={product.images} /></a>}
>
<Meta
title={product.title}
description={`$${product.price}`}
/>
</Card>
</Col>
})
return (
<div style={{ width: '75%', margin: '3rem auto' }}>
<div style={{ textAlign: 'center' }}>
<h2>Let's Travel Anywhere<Icon type="rocket"/></h2>
</div>
{/* 1. ๋ ๋์นด๋/์ด๋ฏธ์ง์ฌ๋ผ์ด๋ */}
<Row gutter={[16, 16]}>
{renderCards}
</Row>
<br/>
{/* 2. ๋๋ณด๊ธฐ ๊ธฐ๋ฅ */}
{PostSize >= Limit &&
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button onClick={loadMoreHandler}>๋๋ณด๊ธฐ</button>
</div>
}
</div>
)
}
โญ components/utils/ImageSlider.js
import React from 'react'
import { Carousel } from 'antd'
function ImageSlider(props) {
return (
<div>
<Carousel autoplay>
{props.images.map((image, index) => (
<div key={index}>
<img style={{ width: '100%', maxHeight: '150px' }}
src={`http://localhost:5000/${image}`} />
</div>
))}
</Carousel>
</div>
)
}
export default ImageSlider
โญ server/routes/product
router.post('/products', (req, res) => {
// 2. ๋๋ณด๊ธฐ ๊ธฐ๋ฅ
// limit๊ณผ skip์ ์ด์ฉํด ์ ํ๋ ์์ product ๊ฐ์ ธ์ค๊ธฐ
let limit = req.body.limit ? parseInt(req.body.limit) : 20
let skip = req.body.skip ? parseInt(req.body.skip) : 0
let findArgs = {}
// 2. ๋๋ณด๊ธฐ ๊ธฐ๋ฅ
Product.find(findArgs)
.populate('writer')
.skip(skip)
.limit(limit)
.exec((err, productInfo) => {
if (err) return res.status(400).json({ success: false, err})
return res.status(200).json({
success: true, productInfo,
postSize: productInfo.length
})
})
}
โญ LandingPage/Sections/Datas.js
const continents = [
{
"_id": 1,
"name": "Africa"
},
{
"_id": 2,
"name": "Europe"
},
{
"_id": 3,
"name": "Asia"
},
{
"_id": 4,
"name": "North America"
},
{
"_id": 5,
"name": "South America"
},
{
"_id": 6,
"name": "Australia"
},
{
"_id": 7,
"name": "Antarctica"
}
]
const price = [
{
"_id": 0,
"name": "Any",
"array": []
},
{
"_id": 1,
"name": "$0 to $249",
"array": [0, 249]
},
{
"_id": 2,
"name": "$250 to $499",
"array": [250, 499]
},
{
"_id": 3,
"name": "$500 to $749",
"array": [500, 749]
},
{
"_id": 4,
"name": "$750 to $999",
"array": [750, 999]
},
{
"_id": 5,
"name": "More than $1000",
"array": [1000, 1500000]
}
]
export {
continents,
price
}
โญ LandingPage.js
import { continents, price } from './Sections/Datas'
import CheckBox from './Sections/CheckBox'
import Radiobox from './Sections/RadioBox'
function LandingPage() {
// 3. ์ฒดํฌ๋ฐ์ค ํํฐ
const [Filters, setFilters] = useState({
continents: [],
price: []
})
// 3. ์ฒดํฌ๋ฐ์ค ํํฐ
const showFilteredResults = (filters) => {
let body = {
skip: 0,
limit: Limit,
filters: filters
}
getProducts(body)
setSkip(0)
}
// 3. ์ฒดํฌ๋ฐ์ค ํํฐ
const handleFilters = (filters, category) => {
const newFilters = {...Filters}
newFilters[category] = filters
if (category === "price") {
let priceValues = handlePrice(filters)
newFilters[category] = priceValues
}
showFilteredResults(newFilters)
setFilters(newFilters)
}
return (
<div style={{ width: '75%', margin: '3rem auto' }}>
<Row gutter={[16, 16]}>
{/* 3. ์ฒดํฌ๋ฐ์ค ํํฐ */}
<Col lg={12} xs={24}>
<Checkbox list={continents}
handleFilters={filters => handleFilters(filters, "continents")} />
</Col>
{/* 4. ๋ผ๋์ค๋ฐ์ค ํํฐ */}
<Col lg={12} xs={24}>
<Radiobox list={price}
handleFilters={filters => handleFilters(filters, "price")} />
</Col>
</Row>
</div>
)
}
โญ LandingPage/Sections/CheckBox.js
import React, { useState } from 'react'
import { Collapse, Checkbox } from 'antd'
const { Panel } = Collapse
function CheckBox(props) {
const [Checked, setChecked] = useState([])
const handleToggle = (value) => {
// ํด๋ฆญํ ์ฒดํฌ๋ฐ์ค์ index๋ฅผ ๊ตฌํ๊ณ
const currentIndex = Checked.indexOf(value)
// ์ ์ฒด checked๋ state์์ ํ์ฌ ๋๋ฅธ Checkbox๊ฐ ์ด๋ฏธ ์๋ค๋ฉด
const newChecked = [...Checked]
// (value ๊ฐ์ด ์๋ค๋ฉด value๊ฐ์ ๋ฃ์ด์ค๋ค)
if (currentIndex === -1) {
newChecked.push(value)
// ๋นผ์ฃผ๊ณ
} else {
newChecked.splice(currentIndex, 1)
}
// state์ ๋ฃ์ด์ค๋ค.
setChecked(newChecked)
// ๋ถ๋ชจ ์ปดํฌ๋ํธ(LandingPage.js)์ ์
๋ฐ์ดํธ
props.handleFilters(newChecked)
}
// ๋ถ๋ชจ ์ปดํฌ๋ํธ(LandingPage.js)์ ์
๋ฐ์ดํธ
const renderCheckBoxLists = () => props.list && props.list.map((value, index) => (
<React.Fragment key={index}>
<Checkbox onChange={() => handleToggle(value._id)} checked={Checked.indexOf(value._id) === -1 ? false : true} />
<span> {value.name} </span>
</React.Fragment>
))
return (
<div>
<Collapse defaultActiveKey={['0']}>
<Panel header="Continents" key="1">
{renderCheckBoxLists()}
</Panel>
</Collapse>
</div>
)
}
export default CheckBox
list.map((value) => <Radio key={value._id}></Radio>)
list.map((value, index) => <Radio key={index}></Radio>)
โญ LandingPage/Sections/RadioBox.js
import React, { useState } from 'react'
import { Collapse, Radio } from 'antd'
const { Panel } = Collapse
function RadioBox(props) {
// Value : price._id (Datas.js)
const [Value, setValue] = useState(0)
const renderRadioBox = () => (
// ๋ถ๋ชจ ์ปดํฌ๋ํธ๋ก(LandingPage.js) ์
๋ฐ์ดํธ
props.list && props.list.map(value => (
<Radio key={value._id} value={value._id}> {value.name} </Radio>
))
)
const handleChange = (event) => {
setValue(event.target.value)
// ๋ถ๋ชจ ์ปดํฌ๋ํธ๋ก(LandingPage.js) ์
๋ฐ์ดํธ
props.handleFilters(event.target.value)
}
return (
<div>
<Collapse defaultActiveKey={['0']}>
<Panel header="Price" key="1">
<Radio.Group onChange={handleChange} value={Value}>
{renderRadioBox()}
</Radio.Group>
</Panel>
</Collapse>
</div>
)
}
export default RadioBox
โญ server/routes/product
router.post('/products', (req, res) => {
// 4. ๋ผ๋์ค ๋ฐ์ค ํํฐ
// req.body.filters -> continents: "[1, 2, 3..]" (LandingPage.js)
// key -> "continents": [1, 2, 3..]
for (let key in req.body.filters) {
if (req.body.filters[key].length > 0) {
if (key === 'price') {
findArgs[key] = {
//Greater than equal
$gte: req.body.filters[key][0],
//Less than equal
$lte: req.body.filters[key][1]
}
} else {
findArgs[key] = req.body.filters[key]
}
}
}
})
โญ LandingPage.js
import SearchFeature from './Sections/SearchFeature'
function LandingPage() {
// 5. ๊ฒ์ ๊ธฐ๋ฅ
const [SearchTerm, setSearchTerm] = useState('')
// 5. ๊ฒ์ ๊ธฐ๋ฅ
const updateSearchTerm = (newSearchTerm) => {
let body = {
skip: 0,
limit: Limit,
filters: Filters,
searchTerm: newSearchTerm
}
setSkip(0)
setSearchTerm(newSearchTerm)
getProducts(body)
}
return (
<div style={{ width: '75%', margin: '3rem auto' }}>
{/* 5. ๊ฒ์ ๊ธฐ๋ฅ */}
<div style={{ display: 'flex', justifyContent: 'flex-end', margin: '1rem auto' }}>
<SearchFeature
refreshFunction={updateSearchTerm}
/>
</div>
</div>
)
}
export default LandingPage
โญ LandingPage/Sections/SearchFeature.js
import React, { useState } from 'react'
import { Input } from 'antd';
const { Search } = Input
function SearchFeature(props) {
const [SearchTerm, setSearchTerm] = useState('')
const searchHandler = (event) => {
setSearchTerm(event.currentTarget.value)
props.refreshFunction(event.currentTarget.value)
}
return (
<div>
<Search
placeholder="input search text"
onChange={searchHandler}
style={{ width: 200 }}
value={SearchTerm}
/>
</div>
)
}
export default SearchFeature
โญ server/models/Product.js
const productSchema = mongoose.Schema({
...
productSchema.index({
title: 'text',
description: 'text'
}, {
weights:{
title: 5,
description: 1
}
})
โญ server/routes/product.js
router.post('/products', (req, res) => {
// 5. ๊ฒ์ ๊ธฐ๋ฅ
let term = req.body.searchTerm // 'Mexico'
let findArgs = {}
// 5. ๊ฒ์ ๊ธฐ๋ฅ
if (term) {
Product.find(findArgs)
.find({ $text: { $search: term } })
.populate('writer')
.skip(skip)
.limit(limit)
.exec((err, productInfo) => {
if (err) return res.status(400).json({ success: false, err})
return res.status(200).json({
success: true, productInfo,
postSize: productInfo.length
})
})
}
})
โญ server/routes/product.js
router.get('/products_by_id', (req, res) => {
// query๋ฅผ ์ด์ฉํด์ ํด๋ผ์ด์ธํธ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ฌ๋๋ req.query
let type = req.query.type
let productIds = req.query.id
if (type === "array") {
let ids = req.query.id.split(',')
productIds = ids.map(item => {
return item
})
}
Product.find({ _id: { $in: productIds } })
.populate('writer')
.exec((err, product) => {
if (err) return res.status(400).send(err)
return res.status(200).send(product)
})
})
โญ App.js
import DetailProductPage from './views/DetailProductPage/DetailProductPage'
function App() {
return (
<Switch>
<Route exact path="/product/:productId" component={Auth(DetailProductPage, null)} />
</Switch>
)
}
โญ DetailProductPage.js
import ProductImage from './Sections/ProductImage'
import ProductInfo from './Sections/ProductInfo'
import { Row, Col } from 'antd'
function DetailProductPage(props) {
const [Product, setProduct] = useState({})
useEffect(() => {
axios
.get(`/api/product/products_by_id?id=${productId}&type=single`)
.then((response) => {
if (response.data.success) {
setProduct(response.data.product[0])
} else {
alert('์์ธ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ๋ฅผ ์คํจํ์ต๋๋ค.')
}
})
}, [])
return (
<div style={{ width: '100%', padding: '3rem 4rem'}}>
<div style= {{ display: 'flex', justifyContent: 'center' }}>
<h1>{Product.title}</h1>
</div>
<br />
<Row gutter={[16, 16]}>
<Col lg={12} sm={24}>
{/* ProductImage */}
<ProductImage detail={Product}/>
</Col>
<Col lg={12} sm={24}>
{/* ProductInfo */}
<ProductInfo detail={Product}/>
</Col>
</Row>
</div>
)
)
}
$ npm install react-image-gallery --save
๋ฉ์ธ์ด๋ฏธ์ง, ์ธ๋ค์ผ
react-image-gallery
gm -npm
โญ index.css
@import '~react-image-gallery/styles/css/image-gallery.css';
โญ DetailProductPage/Sections/ProductImage.js
import React, { useState, useEffect } from 'react'
import ImageGallery from 'react-image-gallery'
function ProductImage(props) {
const [Images, setImages] = useState([])
useEffect(() => {
if (props.detail.images && props.detail.images.length > 0) {
let images = []
props.detail.images.map(item => {
images.push({
original: `http://localhost:5000/${item}`,
thumbnail: `http://localhost:5000/${item}`
})
})
setImages(images)
}
}, [props.detail])
const images = [
{
original: 'https://picsum.photos/id/1018/1000/600/',
thumbnail: 'https://picsum.photos/id/1018/250/150/',
},
{
original: 'https://picsum.photos/id/1015/1000/600/',
thumbnail: 'https://picsum.photos/id/1015/250/150/',
},
{
original: 'https://picsum.photos/id/1019/1000/600/',
thumbnail: 'https://picsum.photos/id/1019/250/150/',
},
]
return (
<div>
<ImageGallery items={Images} />
</div>
)
}
export default ProductImage
โญ DetailProductPage/Sections/ProductInfo.js
import React from 'react'
import { Button, Descriptions } from 'antd'
function ProductInfo(props) {
const clickHandler = () => {}
return (
<div>
<Descriptions title="Product Info">
<Descriptions.Item label="Price">
{props.detail.price}
</Descriptions.Item>
<Descriptions.Item label="Sold">{props.detail.sold}</Descriptions.Item>
<Descriptions.Item label="View">{props.detail.views}</Descriptions.Item>
<Descriptions.Item label="Description">
{props.detail.description}
</Descriptions.Item>
</Descriptions>
<br />
<br />
<br />
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Button size="large" shape="round" type="danger" onClick={clickHandler}>
Add to Cart
</Button>
</div>
</div>
)
}
export default ProductInfo
โญ server/models/User.js
const userSchema = mongoose.Schema({
cart: {
type: Array,
default: []
},
history: {
type: Array,
default: []
}
})
โญ server/routes/users.js
router.get('/auth', auth, (req, res) => {
res.status(200).json({
cart: req.user.cart,
history: req.user.history
})
})
router.post('/addToCart', auth, (req, res) => {
// ๋จผ์ User Collection์ ํด๋น ์ ์ ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค๊ธฐ
// auth ๋ฏธ๋ค์จ์ด๋ฅผ ํต๊ณผํ๋ฉด์ req.user ์์ user ์ ๋ณด๊ฐ ๋ด๊ธด๋ค
User.findOne({ _id: req.user._id }, (err, userInfo) => {
// ๊ฐ์ ธ์จ ์ ๋ณด์์ ์นดํธ์๋ค ๋ฃ์ผ๋ ค ํ๋ ์ํ์ด ์ด๋ฏธ ๋ค์ด ์๋์ง ํ์ธ
let duplicate = false
userInfo.cart.forEach((item) => {
if (item.id === req.body.productId) {
duplicate = true
}
})
// ์ํ์ด ์ด๋ฏธ ์์๋ -> ์ํ ๊ฐ์๋ฅผ 1๊ฐ ์ฌ๋ฆฌ๊ธฐ
if (duplicate) {
User.findOneAndUpdate(
{ _id: req.user._id, 'cart.id': req.body.productId },
{ $inc: { 'cart.$.quantity': 1 } },
// ์
๋ฐ์ดํธ๋ ์ ๋ณด๋ฅผ ๋ฐ๊ธฐ ์ํด { new: true }๋ฅผ ์ฌ์ฉ
{ new: true },
(err, userInfo) => {
if (err) return res.status(200).json({ success: false, err })
res.status(200).send(userInfo.cart)
}
)
}
// ์ํ์ด ์ด๋ฏธ ์์ง ์์๋ -> ํ์ํ ์ํ ์ ๋ณด ์ํ ID ๊ฐ์ 1, ๋ ์ง ์ ๋ ๋ค ๋ฃ์ด์ค์ผํจ
else {
User.findOneAndUpdate(
{ _id: req.user._id },
{
$push: {
cart: {
id: req.body.productId,
quantity: 1,
date: Date.now(),
},
},
},
{ new: true },
(err, userInfo) => {
if (err) return res.status(400).json({ success: false, err })
res.status(200).send(userInfo.cart)
}
)
}
})
})
โญ _actions/types.js
export const ADD_TO_CART = 'add_to_cart'
โญ _actions/user_actions.js
import {
ADD_TO_CART
} from './types'
export function addToCart(id) {
let body = {
productId: id
}
const request = axios.post(`${USER_SERVER}/addToCart`, body)
.then(response => response.data)
return {
type: ADD_TO_CART,
payload: request
}
}
โญ _reducers/user_reducer.js
import {
ADD_TO_CART
} from '../_actions/types'
export default function(state={},action){
switch(action.type){
case ADD_TO_CART:
return {
...state,
userData: {
...state.userData,
cart: action.payload
}
}
default:
return state
}
}
์นดํธ ์์ ๋ค์ด๊ฐ ์๋ ์ํ๋ค์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ๊ฐ์ ธ์ค๊ธฐ
User Collection
, Product Collection
์ฐจ์ด์ : Quantity
๊ฐ ์๋์ง ์๋์ง
๊ทธ๋์ : Product Collection
๋ Quantity ์ ๋ณด๊ฐ ํ์
โญ server/routes/users.js
router.post("/addToCart", auth, (req, res) => {
// ๋จผ์ User Collection์ ํด๋น ์ ์ ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค๊ธฐ
// auth ๋ฏธ๋ค์จ์ด๋ฅผ ํต๊ณผํ๋ฉด์ req.user ์์ user ์ ๋ณด๊ฐ ๋ด๊ธด๋ค
User.findOne({ _id: req.user._id },
(err, userInfo) => {
// ๊ฐ์ ธ์จ ์ ๋ณด์์ ์นดํธ์๋ค ๋ฃ์ผ๋ ค ํ๋ ์ํ์ด ์ด๋ฏธ ๋ค์ด ์๋์ง ํ์ธ
let duplicate = false
userInfo.cart.forEach((item) => {
if (item.id === req.body.productId) {
duplicate = true
}
})
// ์ํ์ด ์ด๋ฏธ ์์๋ -> ์ํ ๊ฐ์๋ฅผ 1๊ฐ ์ฌ๋ฆฌ๊ธฐ
if (duplicate) {
User.findOneAndUpdate(
{ _id: req.user._id, "cart.id": req.body.productId },
{ $inc: {"cart.$.quantity": 1} },
// ์
๋ฐ์ดํธ๋ ์ ๋ณด๋ฅผ ๋ฐ๊ธฐ ์ํด { new: true }๋ฅผ ์ฌ์ฉ
{ new: true },
(err, userInfo) => {
if (err) return res.status(200).json({ success: false, err})
res.status(200).send(userInfo.cart)
}
)
}
// ์ํ์ด ์ด๋ฏธ ์์ง ์์๋ -> ํ์ํ ์ํ ์ ๋ณด ์ํ ID ๊ฐ์ 1, ๋ ์ง ์ ๋ ๋ค ๋ฃ์ด์ค์ผํจ
else {
User.findOneAndUpdate(
{ _id: req.user._id },
{
$push: {
cart: {
id: req.body.productId,
quantity: 1,
date: Date.now()
}
}
},
{ new: true },
(err, userInfo) => {
if (err) return res.status(400).json( {success: false, err })
res.status(200).send(userInfo.cart)
}
)
}
})
})
router.get('/removeFromCart', auth, (req, res) => {
// **๋จผ์ cart์์ ๋ด๊ฐ ์ง์ฐ๋ ค๊ณ ํ ์ํ์ ์ง์์ฃผ๊ธฐ**
User.findOneAndUpdate(
{ _id: req.user._id },
{
"$pull":
{ "cart": { "id": req.query.id } }
},
{ new: true },
(err, userInfo) => {
let cart = userInfo.cart;
let array = cart.map(item => {
return item.id
})
// **product collection์์ ํ์ฌ ๋จ์์๋ ์ํ๋ค์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค๊ธฐ**
// productIds = ['5e8961794be6d81ce2b94752(2๋ฒ์งธ)', '5e8960d721e2ca1cb3e30de4(3๋ฒ์งธ)'] ์ด๋ฐ์์ผ๋ก ๋ฐ๊ฟ์ฃผ๊ธฐ
Product.find({ _id: { $in: array } })
.populate('writer')
.exec((err, productInfo) => {
return res.status(200).json({
productInfo,
cart
})
})
}
)
})
// CartPage.js
import React, { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { getCartItems, removeCartItem, onSuccessBuy } from '../../../_actions/user_actions'
import UserCardBlock from './Sections/UserCardBlock'
import { Empty, Result } from 'antd'
import Paypal from '../../utils/Paypal'
function CartPage(props) {
const dispatch = useDispatch()
const [Total, setTotal] = useState(0)
const [ShowTotal, setShowTotal] = useState(false)
// Paypal ๊ฒฐ์ ์ฑ๊ณตํ ๋ฐ์ดํฐ
const [ShowSuccess, setShowSuccess] = useState(false)
useEffect(() => {
let cartItems = []
// ๋ฆฌ๋์ค User state์ cart ์์ ์ํ์ด ๋ค์ด์๋์ง ํ์ธ
if (props.user.userData && props.user.userData.cart) {
if (props.user.userData.cart.length > 0) {
props.user.userData.cart.forEach(item => {
cartItems.push(item.id)
})
dispatch(getCartItems(cartItems, props.user.userData.cart))
.then(response => { calculateTotal(response.payload) })
}
}
}, [props.user.userData])
// ์นดํธ ์์ ์๋ ์ํ ์ด ๊ธ์ก ๊ณ์ฐ
let calculateTotal = (cartDetail) => {
let total = 0
cartDetail.map(item => {
total += parseInt(item.price, 10) * item.quantity
})
setTotal(total)
setShowTotal(true)
}
// ์นดํธ์ ์๋ ์ํ ์ ๊ฑฐ ๊ธฐ๋ฅ
let removeFromCart = (productId) => {
dispatch(removeCartItem(productId))
.then(response => {
if (response.payload.productInfo.length <= 0) {
setShowTotal(false)
}
})
}
// Paypal ๊ฒฐ์ ์ฑ๊ณต ํ ๊ธฐ๋ฅ
const transactionSuccess = (data) => {
dispatch(onSuccessBuy({
paymentData: data,
cartDetail: props.user.cartDetail
}))
.then(response => {
if (response.payload.success) {
setShowTotal(false)
setShowSuccess(true)
}
})
}
return (
<div style={{ width: '85%', margin: '3rem auto' }}>
<h1>My Cart</h1>
<div>
<UserCardBlock products={props.user.cartDetail} removeItem={removeFromCart} />
</div>
{ShowTotal ?
<div style={{ marginTop: '3rem' }}>
<h2>Total Amount: ${Total}</h2>
</div>
: ShowSuccess ?
<Result
status="success"
title="Successfully Purchased Items"
/>
:
<>
<br />
<Empty description={false} />
</>
}
{ShowTotal &&
<Paypal
total={Total}
onSuccess={transactionSuccess}
/>
}
</div>
)
}
export default CartPage
// CartPage/Sections/UserCardBlock.js
import React from 'react'
import './UserCardBlock.css'
function UserCardBlock(props) {
const renderCartImage = (images) => {
if (images.length > 0) {
let image = images[0]
return `http://localhost:5000/${image}`
}
}
const renderItems = () => (
props.products && props.products.map((product, index) => (
<tr key={index}>
<td>
<img style={{ width: '70px' }} alt="product"
src={renderCartImage(product.images)} />
</td>
<td>
{product.quantity} EA
</td>
<td>
$ {product.price}
</td>
<td>
<button onClick={() => props.removeItem(product._id)}>
Remove
</button>
</td>
</tr>
))
)
return (
<div>
<table>
<thead>
<tr>
<th>Product Image</th>
<th>Product Quantity</th>
<th>Product Price</th>
<th>Remove from Cart</th>
</tr>
</thead>
<tbody>
{renderItems()}
</tbody>
</table>
</div>
)
}
export default UserCardBlock
// CartPage/Sections/UserCardBlock.css
table {
font-family: arial, sans-serif;
border-collapse: collapse;
width: 100%;
}
td,
th {
border: 1px solid #dddddd;
text-align: left;
padding: 8px;
}
tr:nth-child(even) {
background-color: #dddddd;
}
SandBox Paypal ํ์ ๊ฐ์
Paypal์ ์ํ test ID ๋ง๋ค๊ธฐ
Account name
์ค Default
๊ฐ ์ ์จ์๋๊ฑธ๋ก ์ฌ์ฉ
View/Edit Account
-> Password
๋ณ๊ฒฝ
Payment Model ๋ง๋ค๊ธฐ
user
, data
, product
๊ฒฐ์ ์ฑ๊ณต ํ์ ํด์ผ ํ ์ผ์?
์นดํธ๋ฅผ ๋น์ฐ๊ธฐ
๊ฒฐ์ ์ ๋ณด ์ ์ฅํ๊ธฐ
Payment Collection
(Detailed)User Collection
(Simple)npm install async --save
(Root
๊ฒฝ๋ก)โญ// server/models/Payment.js
const mongoose = require('mongoose')
const paymentSchema = mongoose.Schema(
{
user: {
type: Array,
default: [],
},
data: {
type: Array,
default: [],
},
product: {
type: Array,
default: [],
},
},
{ timestamps: true }
)
const Payment = mongoose.model('Payment', paymentSchema)
module.exports = { Payment }
Paypal Button ๋ง๋ค๊ธฐ
npm install react-paypal-express-checkout --save
(Client
๊ฒฝ๋ก)Paypal๋ก ๊ฒฐ์ ํ๊ธฐ
Create app
-> App Name
-> Client ID
-> Paypal.js
์ sandbox
Paypal
๋ฒํผ ํด๋ฆญ ํ ๋ก๊ทธ์ธ ํ ๋ ID
๋ Sandbox Accounts
์ Account Name
โญ// utils/Paypal.js
import React from 'react'
import PaypalExpressBtn from 'react-paypal-express-checkout'
export default class Paypal extends React.Component {
render() {
const onSuccess = (payment) => {
console.log('The payment was succeeded!', payment)
this.props.onSuccess(payment)
}
const onCancel = (data) => {
console.log('The payment was cancelled!', data)
}
const onError = (err) => {
console.log('Error!', err)
}
let env = 'sandbox'
โญlet total = this.props.total
const client = {
โญsandbox:
'YQLUoERa2zPTNGSFq3o9QBQPqw2pc3DKnWDn5RrchIixQUF9__bLP0cFpgfgLyh1EGt4S9NJk_H',
production: 'YOUR-PRODUCTION-APP-ID',
}
return (
<PaypalExpressBtn
env={env}
client={client}
currency={currency}
total={total}
onError={onError}
onSuccess={onSuccess}
onCancel={onCancel}
style={{
size: 'large',
color: 'blue',
shape: 'rect',
label: 'checkout',
}}
/>
)
}
}
โญ// CartPage.js
import Paypal from '../../utils/Paypal'
function CartPage(props) {
...
return (
...
{ShowTotal &&
<Paypal
total={Total}
/>
}
)
}
โญ _actions_types.js
export const GET_CART_ITEMS = 'get_cart_items'
export const REMOVE_CART_ITEM = 'remove_cart_item'
export const ON_SUCCESS_BUY = 'on_success_buy'
โญ _actions_user_actions.js
import axios from 'axios';
import {
GET_CART_ITEMS,
REMOVE_CART_ITEM,
ON_SUCCESS_BUY
} from './types'
import { USER_SERVER } from '../components/Config.js'
export function getCartItems(cartItems, userCart) {
const request = axios.get(`/api/product/products_by_id?id=${cartItems}&type=array`)
.then(response => {
// CartItem๋ค์ ํด๋นํ๋ ์ ๋ณด๋ค์
// Product Collection์์ ๊ฐ์ ธ์จํ์
// Quantity ์ ๋ณด๋ฅผ ๋ฃ์ด ์ค๋ค.
userCart.forEach(cartItem => {
response.data.forEach((productDetail, index) => {
if (cartItem.id === productDetail._id) {
response.data[index].quantity = cartItem.quantity
}
})
})
return response.data
})
return {
type: GET_CART_ITEMS,
payload: request
}
}
export function removeCartItem(productId) {
const request = axios.get(`/api/users/removeFromCart?id=${productId}`)
.then(response => {
//productInfo, cart ์ ๋ณด๋ฅผ ์กฐํฉํด์ CartDetail์ ๋ง๋ ๋ค.
response.data.cart.forEach(item => {
response.data.productInfo.forEach((product, index) => {
if (item.id === product._id) {
response.data.productInfo[index].quantity = item.quantity
}
})
})
return response.data
})
return {
type: REMOVE_CART_ITEM,
payload: request
}
}
export function onSuccessBuy(data) {
const request = axios.post(`/api/users/successBuy`, data)
.then(response => response.data)
return {
type: ON_SUCCESS_BUY,
payload: request
}
}
โญ _reducers/user_reducer.js
import {
GET_CART_ITEMS,
REMOVE_CART_ITEM,
ON_SUCCESS_BUY
} from '../_actions/types'
export default function(state={},action){
switch(action.type){
case REMOVE_CART_ITEM:
return {
...state, cartDetail: action.payload.productInfo,
userData: {
...state.userData,
cart: action.payload.cart
}
}
case ON_SUCCESS_BUY:
return {
...state, cartDetail: action.payload.cartDetail,
userData: {
...state.userData, cart: action.payload.cart
}
}
default:
return state
}
}
โญ History.js
import React from 'react'
function HistoryPage(props) {
return (
<div style={{ width: '80%', margin: '3rem auto' }}>
<div style={{ textAlign: 'center' }}>
<h1>History</h1>
</div>
<br />
<table>
<thead>
<tr>
<th>Payment Id</th>
<th>Price</th>
<th>Quantity</th>
<th>Date of Purchase</th>
</tr>
</thead>
<tbody>
{props.user.userData && props.user.userData.history &&
props.user.userData.history.map(item => (
<tr key={item.id}>
<td>{item.id}</td>
<td>{item.price}</td>
<td>{item.quantity}</td>
<td>{item.dateOfPurchase}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export default HistoryPage