Mern-Shop-App

Minยท2020๋…„ 12์›” 31์ผ
1

Project

๋ชฉ๋ก ๋ณด๊ธฐ
2/6
post-thumbnail

Mern-Shop-App

๐Ÿ“ ํ”„๋กœ์ ํŠธ ์„ค๋ช…

  • John Ahn๋‹˜์˜ ์‡ผํ•‘๋ชฐ ์‚ฌ์ดํŠธ ๋งŒ๋“ค๊ธฐ ๊ฐ•์˜๋ฅผ ์ˆ˜๊ฐ•ํ•˜๋ฉฐ ์ƒํ’ˆ์—…๋กœ๋“œ ํŽ˜์ด์ง€(์ƒํ’ˆ์—…๋กœ๋“œ ๊ธฐ๋Šฅ), ๋žœ๋”ฉ ํŽ˜์ด์ง€(์นด๋“œ, ์ด๋ฏธ์ง€ ์Šฌ๋ผ์ด๋”, ๋”๋ณด๊ธฐ ๋ฒ„ํŠผ, ์ฒดํฌ๋ฐ•์Šค/๋ผ๋””์˜ค๋ฐ•์Šค ํ•„ํ„ฐ, ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ), ์ƒ์„ธ๋ณด๊ธฐ ํŽ˜์ด์ง€(์žฅ๋ฐ”๊ตฌ๋‹ˆ ๊ธฐ๋Šฅ), ์นดํŠธ ํŽ˜์ด์ง€(์นดํŠธ ์ƒํ’ˆ์ •๋ณด, Paypal ๊ธฐ๋Šฅ), ๊ฒฐ์ œ๋‚ด์—ญ ํŽ˜์ด์ง€๋ฅผ ํ•™์Šตํ•œ ๋‚ด์šฉ์„ ์ •๋ฆฌํ•œ ํ”„๋กœ์ ํŠธ์ž…๋‹ˆ๋‹ค.

๐Ÿ’ก ๊ธฐ์ˆ ์Šคํƒ:

  • ํ”„๋ก ํŠธ์—”๋“œ : React, Redux, Ant Design
  • ๋ฐฑ์—”๋“œ: Express, MongoDB

๐Ÿ’ก ์‚ฌ์šฉ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ

  • react-dropzone, multer, react-image-gallery, gm, react-paypal-express-checkout, async

0. ์ดˆ๊ธฐ ์„ค์ •

  • boiler-plate ์ฝ”๋“œ

  • ํด๋ผ์ด์–ธํŠธ์™€ ์„œ๋ฒ„์— 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'
  }

1. ์ƒํ’ˆ์—…๋กœ๋“œ ํŽ˜์ด์ง€

1) Product Model

โญ 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 }

2) ์„œ๋ฒ„์— ์ด๋ฏธ์ง€ ์ €์žฅ

Multer
$ npm install multer --save

  • Multer๋Š” ํŒŒ์ผ ์—…๋กœ๋“œ๋ฅผ ์œ„ํ•ด ์‚ฌ์šฉ๋˜๋Š” 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 })
  })
})

3) ์ƒํ’ˆ ์—…๋กœ๋“œ(๋ถ€๋ชจ ์ปดํฌ๋„ŒํŠธ)

react-dropzone
$npm install react-dropzone --save

  • Simple React hook to create a HTML5-compliant drag'n'drop zone for files.
  1. ์ž์‹ ์ปดํฌ๋„ŒํŠธ(FileUpload.js)์˜ ์ด๋ฏธ์ง€ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์˜จ๋‹ค.
  2. ์ž์‹ ์ปดํฌ๋„ŒํŠธ(FileUpload.js)์˜ ์ด๋ฏธ์ง€ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅ
  3. Form์— ์ž…๋ ฅํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ์„œ๋ฒ„๋กœ ๋ณด๋‚ด๊ธฐ
โญ 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

4) ์ƒํ’ˆ ์—…๋กœ๋“œ(์ž์‹ ์ปดํฌ๋„ŒํŠธ)

โญ 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

2. ๋žœ๋”ฉ ํŽ˜์ด์ง€

1) ๋ Œ๋”์นด๋“œ/์ด๋ฏธ์ง€์Šฌ๋ผ์ด๋”, ๋”๋ณด๊ธฐ ๊ธฐ๋Šฅ

  • LIMIT : ์ฒ˜์Œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ฌ๋•Œ์™€ ๋”๋ณด๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ๊ฐ€์ ธ์˜ฌ๋•Œ
    ์–ผ๋งˆ๋‚˜ ๋งŽ์€ ๋ฐ์ดํ„ฐ๋ฅผ ํ•œ๋ฒˆ์— ๊ฐ€์ ธ์˜ค๋Š”์ง€
  • SKIP : ์–ด๋””์„œ๋ถ€ํ„ฐ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ ์˜ค๋Š”์ง€์— ๋Œ€ํ•œ ์œ„์น˜
    ์ฒ˜์Œ์—๋Š” 0๋ถ€ํ„ฐ ์‹œ์ž‘, LIMIT์ด 6์ด๋ผ๋ฉด ๋‹ค์Œ ๋ฒˆ์—๋Š” 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
    })
  })

}  

2) ์ฒดํฌ๋ฐ•์Šค/๋ผ๋””์˜ค๋ฐ•์Šค ํ•„ํ„ฐ

๋ฐ์ดํ„ฐ ์ƒ์„ฑ

โญ 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

๋ผ๋””์˜ค๋ฐ•์Šค ํ•„ํ„ฐ(์ž์‹ ์ปดํฌ๋„ŒํŠธ)

  • Checked State๋ฅผ ๋ถ€๋ชจ ์ปดํฌ๋„ŒํŠธ๋กœ ์—…๋ฐ์ดํŠธ ํ•˜๊ธฐ
    • 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]
      }
    }
  }

})

3) ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ

โญ 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

Product Model์— ์ถ”๊ฐ€

โญ server/models/Product.js

const productSchema = mongoose.Schema({
    ...

productSchema.index({
    title: 'text',
    description: 'text'
}, {
    weights:{
        title: 5,
        description: 1
    }
})

getProduct Route ์ˆ˜์ •

  • ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ์„ ์œ„ํ•ด
  • $text
โญ 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
        })
      })
  }

})

3. ์ƒํ’ˆ์ƒ์„ธ ํŽ˜์ด์ง€

1) ์„œ๋ฒ„

โญ 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)
      })
})

2) ํด๋ผ์ด์–ธํŠธ

โญ 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>
    )
  )
}

3) ProductImage

$ 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

4) ProductInfo

โญ 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

5) Add to Cart ๊ธฐ๋Šฅ

  • ์นดํŠธ ์•ˆ์— ๋‚ด๊ฐ€ ์ถ”๊ฐ€ํ•˜๋Š” ์ƒํ’ˆ์ด ์ด๋ฏธ ์žˆ๋‹ค๋ฉด? (์ค‘๋ณต)
    • ์ƒํ’ˆ ๊ฐœ์ˆ˜๋ฅผ 1๊ฐœ ์˜ฌ๋ฆฌ๊ธฐ
  • ์žˆ์ง€ ์•Š๋‹ค๋ฉด
    • ํ•„์š”ํ•œ ์ƒํ’ˆ ์ •๋ณด, ์ƒํ’ˆ ID, ๊ฐœ์ˆ˜ 1, ๋‚ ์งœ ์ •๋ณด๋ฅผ ๋‹ค ๋„ฃ์–ด์ค˜์•ผ ํ•จ
โญ 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
    }
}

4. ์นดํŠธ ํŽ˜์ด์ง€

4-1) ์นดํŠธ์— ๋‹ด๊ธด ์ƒํ’ˆ ์ •๋ณด๋“ค์„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๊ฐ€์ ธ์˜ค๊ธฐ

  • ์นดํŠธ ์•ˆ์— ๋“ค์–ด๊ฐ€ ์žˆ๋Š” ์ƒํ’ˆ๋“ค์„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๊ฐ€์ ธ์˜ค๊ธฐ

    • 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;
}

Paypal

  • SandBox Paypal ํšŒ์› ๊ฐ€์ž…

  • Paypal์„ ์œ„ํ•œ test ID ๋งŒ๋“ค๊ธฐ

    • Paypal SandBox Test Accounts

    • Account name ์ค‘ Default๊ฐ€ ์•ˆ ์จ์žˆ๋Š”๊ฑธ๋กœ ์‚ฌ์šฉ

    • View/Edit Account -> Password ๋ณ€๊ฒฝ

  • Payment Model ๋งŒ๋“ค๊ธฐ

    • user, data, product
  • ๊ฒฐ์ œ ์„ฑ๊ณต ํ›„์— ํ•ด์•ผ ํ•  ์ผ์€?

  • ์นดํŠธ๋ฅผ ๋น„์šฐ๊ธฐ

  • ๊ฒฐ์ œ ์ •๋ณด ์ €์žฅํ•˜๊ธฐ

    • Payment Collection (Detailed)
    • User Collection (Simple)
    • npm install async --save (Root ๊ฒฝ๋กœ)
    • async
โญ// 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 ๋งŒ๋“ค๊ธฐ

  • 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
    }
}

5. ๊ฒฐ์ œ๋‚ด์—ญ ํŽ˜์ด์ง€

โญ 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
profile
slowly but surely

0๊ฐœ์˜ ๋Œ“๊ธ€