본문 바로가기

페이팔기반, e커머스 플랫폼 제작하기 [4편. 제품페이지 구현하기]

by Recstasy 2021. 11. 4.

product는 이커머스의 플랫폼의 핵심이다.

page, category도 중요하지만 이커머스와 단순 웹사이트(블로그 포함)의 차이는 '제품관련 기능'에 있다.  이번 포스팅에서는 page, category에 이어 product를 완성해보자.

 

 

1 Models :: productSchema.js 

'/models' 폴더에 productSchema.js파일을 추가한다.

 


    const
mongoose = require('mongoose');


  // Product Schema
    let ProductSchema = mongoose.Schema({
        title: {
            type: String,
            required: true
        },
        slug: {
            type: String
        },
        desc: {
            type: String,
            required: true
        },
        category: {
            type: String,
            required: true
        },
        price: {
            type: Number
        },
        image: {
           type: String
        }
    });

    let Product = module.exports = mongoose.model('Product', ProductSchema);

 

 

 

 

 


2 모듈 설치 

상품 이미지 업로드를 위한 모듈을 설치한다. 

 

 

* express-fileupload

* fs-extra

*mkdirp

*resize-img

 

위의 4가지 모듈을 npm으로 설치한다. 'express-fileupload'모듈은 index.js의 미들웨어로 등록한다.

 


    const
express = require('express');

    const path = require('path');
    const mongoose = require('mongoose');
    const config = require('./config/database');
    const app = express();
    const bodyParser = require('body-parser');
    const session = require('express-session');
    const {check, validationResult} = require('express-validator');
    const fileUpload = require('express-fileupload');  //새롭게 추가

  //중략...

  //Express fileupload Middleware
    app.use(fileUpload({
        createParentPath:true
    }));

  //중략...

  //https 서버연결
    app.listen(3005);

 

모듈의 기능은 아래와 같다.

 

 

|| express-fileupload : 파일업로드 관련 기능(위치 지정, 이동, 삭제, 변경, 기타...)

 

GitHub - richardgirges/express-fileupload: Simple express file upload middleware that wraps around busboy

Simple express file upload middleware that wraps around busboy - GitHub - richardgirges/express-fileupload: Simple express file upload middleware that wraps around busboy

github.com

 

|| mkdirp : 서버의 파일경로 생성

 

mkdirp

Recursively mkdir, like `mkdir -p`

www.npmjs.com

 

|| fs-extra :: 파일 복사

 

fs-extra

fs-extra contains methods that aren't included in the vanilla Node.js fs package. Such as recursive mkdir, copy, and remove.

www.npmjs.com

 

|| path :: 파일명 변경

 

Path | Node.js v17.0.1 Documentation

Path# Source Code: lib/path.js The path module provides utilities for working with file and directory paths. It can be accessed using: const path = require('path'); Windows vs. POSIX# The default operation of the path module varies based on the operating s

nodejs.org

 

 

 

 

 

 

 


3 product라우트 추가

'/admin/products' 라우트를 index.js에 선언한다.

 


  //중략...

 
//라우팅
    let pages = require('./routes/pages.js');
    let adminPages = require('./routes/admin_pages.js');
    let adminCategories = require('./routes/admin_categories.js');
    let adminProducts = require('./routes/admin_products.js');

    app.use('/', pages);
    app.use('/admin/pages', adminPages);
    app.use('/admin/categories', adminCategories);
    app.use('/admin/products', adminProducts);

  //https 서버연결
    app.listen(3005);

[ index.js ]

 

 

 

 

 

 

 


4 라우트 & 뷰

products의 라우트&뷰는 앞서 구현했던 page, category와 비슷한 패턴을 갖는다. 하지만 바이너리 파일(이미지)을 업로드하고, 이를 서버에서 위치시키는 부분에서 좀더 주의가 필요하다.

 

4-1 라우트 (Get) :: /

'admin/products' 경로에 접속했을 때, 'products.ejs'뷰 파일에 product컬렉션에 저장된 데이터를 전달하기 위한 뷰를 구현한다. 이를 위해 '/routes'폴더에 admin_products.js파일을 생성한다. 

 


    let
express = require('express');

    let router = express.Router();
    let path = require('path');
    let fs = require('fs-extra');
    let mkdirp = require('mkdirp');
    let resizeImg = require('resize-img');
    const app = express();
    const {check, body, validationResult} = require('express-validator');

  //Get Products model
    let Product = require('../models/productSchema');

  //Get Category model
    let Category = require('../models/categorySchema');

  //Get products index
    router.get('/', (req, res)=>{
        let count;

        Product.count((err, c)=>{
            count = c;
        });

         Product.find((err, products)=>{
             res.render('admin/products', {
                 products : products,
                 count: count
             })
         })
    });

[ admin_products.js ]

 

 

 

4-2 뷰 :: products.ejs

products.ejs파일을 '/admin/'폴더 아래에 생성한다.

 

  <%- include('../layouts/adminheader') %>

    <h2 class="page-title"> Products </h2>
    <a href="/admin/products/add-product" class="btn btn-primary">Add a new product</a>
    <br><br>

    <% if(count > 0) { %>

    <table class="table table-striped">
        <thead>
            <tr class="home">
                <th>Product</th>
                <th>Price</th>
                <th>Category</th>
                <th>Product Image</th>
                <th>Edit</th>
                <th>Delete</th>
            </tr>
        </thead>
        <tbody>
        <% products.forEach((product)=>{ %>
            <tr>
                <td><%= product.title %></td>
                <td><%= parseFloat(product.price).toFixed(2) %></td>
                <td><%= product.category %></td>
                <td>
                <% if(product.image == "") { %>
                    <img src="/images/noimage.png" alt="">
                <% } else{ %>
                    <img src="/product_images/<%= product._id %>/<%=product.image%>">
                <% } %>
                </td>
                <td><a href="/admin/products/edit-product/<%=product._id %>">Edit</a></td>
                <td><a class="confirmDeletion" href="/admin/products/delete-page/<%=product._id %>">Delete</a></td>
            </tr>
        <% }) %>
        </tbody>
    </table>

    <% }else{ %>
        <h3 class="text-center"> There are no products.</h3>
    <% } %>

    <script src="//code.jquery.com/ui/1.11.4/jquery-ui.min.js"></script>

    <%-include('../layouts/adminfooter') %>

[ products.ejs ]

 

 

 

 

 

4-3 라우트 (Get) :: /add-product

'/add_product' 라우트는 제품을 추가할 수 있는 데이터를 'add_product.ejs'에 전달하고, 뷰를 랜더링한다.

 

  // 중략...

  // Get add product

    router.get('/add-product', (req, res)=>{
        let title = "";
        let desc = "";
        let price = "";

        Category.find((err,categories)=>{
            res.render('admin/add_product', {
                title:title,
                desc:desc,
                categories:categories,
                price:price
            });
        })
    });

[ admin_products.js ]

 

 

 

4-4 뷰 :: add_product.ejs

'add_product.ejs'는 product 폼을 랜더링한다. add_page.ejs파일을 복사 & 붙이기로 재활용한다. post방식으로 사용자 데이터를 전달하는 방식은 기존의 page, category와 같다. 차이점이라면 'multipart/form-data 타입에 있다.

 


    <%-
include('../layouts/adminheader') %>


    <h2 class="page-title"> Add a Product </h2>
    <a href="/admin/pages" class="btn btn-primary">Back to all products</a>
    <br><br>

    <form method="post" action="/admin/products/add-product" enctype="multipart/form-data">

        <div class="form-group">
            <label for="">Title</label>
                <input type="text" class="form-control" name="title" value="<%= title %>" placeholder="Title">
        </div>

        <div class="form-group">
            <label for="">Description</label>
            <textarea style="height:350px;" name="desc" class="form-control" id="editor" cols="30" rows="20" placeholder="description"><%= desc %></textarea>
        </div>

        <div class="form-group">
            <label for="">Category</label>
            <select name="category" class="form-control">
                <% categories.forEach((cat)=>{ %>
                    <option value="<%= cat.slug %>"><%= cat.title %></option>
                <% }); %>
            </select>
        </div>

        <div class="form-group">
            <label for="">Price</label>
            <input type="text" class="form-control" name="price" value="<%= price %>" placeholder="Price">
        </div>

        <div class="form-group">
            <label for="">Image</label>
            <input type="file" class="form-control" name="image" id="img">
            <img src="#" id="imgPreview" alt="">
        </div>

        <button class="btn btn-default">Submit</button>
    </form>

    <%-include('../layouts/adminfooter') %>
   
    <
script>

    const readURL = (input)=>{
        if(input.files && input.files[0]){
            let reader = new FileReader();
            reader.onload = (e)=>{
                $('#imgPreview').attr('src', e.target.result).width(100).height(100);
            }
            reader.readAsDataURL(input.files[0]);
        }
    }

    $("#img").change((e)=>{
        readURL(e.target);
    });
    </script>
    <script src="/js/editor.js"></script>

[ add_product.ejs ]

 

이미지와 같은 바이너리 파일을 서버로 전달하는 경우, form의 enctype = 'multipart/form-data'으로 설정했으며, 제이쿼리를 사용해서 갤러리를 생성한다. 그리고 업로드 테스트를 위해 사진을 css/images/ 폴더에 채워준다. 만일, 갤러리의 img 태그 부분을 수정해야한다면, 아래와 같이 css링크를 header.ejs파일에 추가한다.

 

[ style, image파일 추가 ]

 

 

 

 

4-5 라우트 (Post) :: /add-product

post방식의 add-product폼에는 유효성 검사를 위한 항목이 4가지다. 이미지의 경우, 파일이 없다면 'noimage' 클래스가 추가되면서 css가 작동되는 방식이다. 

 


  //Post add product
    router.post('/add-product' , async(req, res)=>{

        let imageFile;
        let extension;
        if(req.files){
            imageFile = req.files.image.name;
        }else{
            let imageFile = null;
        }

        await check('title', 'title must have a value.').notEmpty().run(req);
        await check('desc', 'Description must have a value.').notEmpty().run(req);
        await check('price', 'Price must have a value.').isDecimal().run(req);
        await check('image', 'You must upload an image').custom((imageFile, {req})=>{
            let imgex = req.files;
            if(imgex){

                extension = (path.extname(imgex.image.name)).toLowerCase();

               
switch(extension){
                    case '.jpg':
                        return '.jpg';
                    case '.jpeg':
                        return '.jpeg';
                    case '.png':
                        return '.png';
                    case '':
                        return '.jpg';
                }

            }else{
                return false;
            }
        }).run(req);

        let title = req.body.title;
        let slug = title.replace(/\s+/g, '-').toLowerCase();
        let desc = req.body.desc;
        let price = req.body.price;
        let category = req.body.category;

  //validator
        let validatorResults = await validationResult(req);
        if(validatorResults.errors[0] !== undefined){

            Category.find((err, categories)=>{
                res.render('admin/add_product', {
                    errors : validatorResults.errors,
                    title : title,
                    desc : desc,
                    categories : categories,
                    price : price
                });
            });

        }else{

            Product.findOne({slug : slug}, (err, product)=>{
                if(product){
                    req.flash('danger', 'Product slug exists, choose another.');
                    Category.find((err, categories)=>{
                        res.render('admin/add_product', {
                            title : title,
                            desc : desc,
                            categories : categories,
                            price : price
                        });
                    });
                }else{
                    var price2 = parseFloat(price).toFixed(2);
                    var product = new Product({
                        title : title,
                        slug : slug,
                        desc : desc,
                        price : price2,
                        category : category,
                        image : imageFile
                    });

                    product.save((err)=>{
                        if(err){
                            return console.log(err);
                        }

                        mkdirp('public/product_images/'+product._id).then(made=>{

                            console.log(`made directories, starting with ${made}`)
                        });

                        mkdirp('public/product_images/'+product._id + '/gallery').then(made=>{
                            console.log(`made directories, starting with ${made}`)
                        });

                        mkdirp('public/product_images/'+product._id + '/gallery/thumbs').then(made=>{
                            console.log(`made directories, starting with ${made}`)
                        });

                        if(imageFile){
                            let productImage = req.files.image;
                            let productPath = 'public/product_images/' + product._id + '/' + imageFile;
                            productImage.mv(productPath, (err)=>{
                                return console.log(err);
                            }) 
                        }

                        req.flash('sucess', 'Product added!');
                        res.redirect('/admin/products');
                    });
                }
            });
        }
    });


[ admin_products.js ]

 

다른 라우터에 비해 코드가 상당히 긴 편인데, 숫자변환과 이미지파일 경로설정이 많은 부분을 차지하고 있다. imageFile(업로드 이미지)이 있을 경우, mkdirp모듈을 이용해서 경로를 지정한 뒤 'express-fileupload'의 .mv메서드로 위치를 옮겨준다.

 

express-fileupload:: 

 

파일 업로드와 관련된 메서드는 express-fileupload npm에서 확인할 수 있다.

 

 

 

 

4-6 라우트 (Get) :: /edit-product

'/edit_product/:id'라우터는 Category, Product 쿼리를 동시에 수행한다. DB에 저장된 Category정보와 id로 검색한 해당 게시물 정보를 edit_product.ejs 파일에 전달한다. 

 


  // 중략...
  // Get edit product

    router.get('/edit-product/:id', (req, res)=>{

        let errors;
        if(req.session.errors) errors = req.session.errors;
        req.session.errors = null;

        Category.find((err, categories)=>{
            Product.findById(req.params.id, (err, p)=>{
                if(err){
                    console.log(err);
                    res.redirect('/admin/products')
                }else{
                    let galleryDir = 'public/product_images' + p._id + '/gallery';
                    let galleryImages = null;

                    fs.readdir(galleryDir, (err, files)=>{
                        if(err){
                            console.log(err);
                        }else{
                            galleryImages = files;

                         
res.render('admin/edit_product',{
                                title:p.title,
                                errors : errors,
                                desc : p.desc,
                                categories : categories,
                                category : p.category.replace(/\s+/g,'-').toLowerCase(),
                                price : parseFloat(p.price).toFixed(2),
                                image:p.image,
                                galleryImages:galleryImages,
                                id : p._id
                            })

                        }
                    });   // fs.readdir End
                }
            });   // Product.findById() End
        });    // Category.find() End  
    });


[ admin_products.js ]

 

 

 

 

4-7 뷰 :: edit_product.ejs

edit_product.ejs는 add_product.ejs파일과 상단은 거의 흡사하며, 하단에 갤러리 이미지 게시판을 추가한다. 코드가 긴 것 같지만 </hr> 이후 부분이 새롭게 추가된 내용이다.

 


  <%- include('../layouts/adminheader') %>

    <h2 class="page-title"> Edit a Product </h2>
    <a href="/admin/products" class="btn btn-primary">Back to all products</a>
    <br><br>

    <form method="post" action="/admin/products/edit-product/<%= id %>" enctype="multipart/form-data">
        <div class="form-group">
            <label for="">Title</label>
            <input type="text" class="form-control" name="title" value="<%= title %>" placeholder="Title">
        </div>

        <div class="form-group">
            <label for="">Description</label>
            <textarea style="height:350px;" name="desc" class="form-control" id="editor" cols="30" rows="20" placeholder="description"><%= desc %></textarea>
        </div>

        <div class="form-group">
            <label for="">Category</label>
            <select name="category" class="form-control">
                <% categories.forEach((cat)=>{ %>
                <option value="<%= cat.slug %>"
                <% if (cat.slug == category){ %>
                    selected = "selected"
                <% } %>
                 ><%= cat.title %></option>
                <% }); %>
             </select>
         </div>

         <div class="form-group">
             <label for="">Price</label>
             <input type="text" class="form-control" name="price" value="<%= price %>" placeholder="Price">
         </div>

         <div class="form-group">
             <label for="">Current Image</label>
             <p>
             <% if(image == ""){ %>
                 <img id="noimage" src="/images/noimage.png" alt="">
             <% }else{ %>
                 <img id="noimage" src="/product_images/<%= id %>/<%=image %>" alt="">
             <% } %>
             </p>
         </div>

         <div class="form-group">
             <label for="">Upload Image</label>
             <input type="file" class="form-control" name="image" id="img">
             <img src="#" id="imgPreview" alt="">
         </div>

         <input type="hidden" name="pimage" value="<%= image %>">
         <button class="btn btn-default">Submit</button>
     </form>

    <hr>
    <h3 class="page-header"> Gallery </h3>
    <ul class="gallery">
    <% galleryImages.forEach((image)=>{ %>
        <% if(image != "thumbs"){ %>
        <li>
            <img src="/product_images/<%= id %>/gallery/thumbs/<%=image %>" alt="">&nbsp;
            <a class="confirmDeletion" href="/admin/products/delete-image/<%=image%>?id=<%=id%>">Delete</a>
        </li>
        <% } %>
    <% }) %>
    </ul>

    <br><br>
    <form action="/admin/products/product-gallery/<%= id%>" method="post" enctype="multipart/form-data" class="dropzone" id="dropzoneForm">
    <div class="fallback">
        <input type="file" name="file" multiple>
        <input type="submit" value="Upload">
    </div>
    </form>

    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.1.1/basic.css"/>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.1.1/dropzone.css"/>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.1.1/dropzone.js"></script>
   
    <
script>

  //Preview Image
    const readURL = (input)=>{
        if(input.files && input.files[0]){
            let reader = new FileReader();
            reader.onload = (e)=>{
                $('#imgPreview').attr('src', e.target.result).width(100).height(100);
            }
            reader.readAsDataURL(input.files[0]);
        }
    }

    $("#img").change((e)=>{
        readURL(e.target);
    });

  //Dropzone
    Dropzone.options.dropzoneForm = {
        acceptedFiles : "image/*",
            init : ()=>{
                this.on("queuecomplete", (file)=>{
                    setTimeout(()=>{
                        location.reload()
                    }, 1000);
                })
            }
        }


    </script>

    <%-include('../layouts/adminfooter') %>
    <script src="/js/editor.js"></script>

[ edit_product.ejs ] 

 

 

갤러리 이미지에 드래그&드랍으로 이미지를 넣기 위해서 'dropzone'라이브러리를 사용했다. 

 

 

Basics - Dropzone

If you want to react to events happening (a file has been added or removed, an upload started, a progress changed, etc...) you want to be listening to events. Checkout the events section for more information on events.

docs.dropzone.dev

 

dropzone을 사용하는 경우, class='dropzone'을 지정하는 것만으로 드래그 GUI를 구현할 수 있다.

 

 

 

 

 

 

4-8 라우트 (Post) :: /edit-product/:id

post방식의 edit-product은 add-product와 달리 id값이 붙는다. 유효성 검사와 DB에서 데이터를 불러오는 패턴은 같으며, mkdirp부분은 필요없다.(add-product 기준)

 


  //Post edit product
    router.post('/edit-product/:id', async(req, res)=>{

        let imageFile;
        let extension;
        if(req.files){
            imageFile = req.files.image.name;
        }else{
           let imageFile = null;
        }

        await check('title', 'title must have a value.').notEmpty().run(req);
        await check('desc', 'Description must have a value.').notEmpty().run(req);
        await check('price', 'Price must have a value.').isDecimal().run(req);
        await check('image', 'You must upload an image').custom((imageFile, {req})=>{
            let imgex = req.files;
            if(imgex){

                extension = (path.extname(imgex.image.name)).toLowerCase();

                
switch(extension){
                    case '.jpg':
                        return '.jpg';
                    case '.jpeg':
                        return '.jpeg';
                    case '.png':
                        return '.png';
                    case '':
                        return '.jpg';
                }

            }else{
                return false;
            }
        }).run(req);


        let title = req.body.title;
        let slug = title.replace(/\s+/g, '-').toLowerCase();
        let desc = req.body.desc;
        let price = req.body.price;
        let category = req.body.category;
        let pimage = req.body.pimage;
        let id = req.params.id;

  //validator
        let validatorResults = await validationResult(req);
        if(validatorResults.errors[0] !== undefined){
            req.session.errors = validatorResults.errors;
            res.redirect('/admin/products/edit-product/'+id);
        }else{
            Product.findOne({slug:slug, _id:{'$ne':id}}, (err, p)=>{
                if(err) console.log(err);
                if(p){
                    req.flash('danger', 'Product title exists, choose another');
                    res.redirect('/admin/products/edit-product/'+id);
                }else{
                    Product.findById(id, (err,p)=>{
                        if(err) console.log(err);
                        p.title = title;
                        p.slug = slug;
                        p.desc = desc;
                        p.price = parseFloat(price).toFixed(2);
                        p.category = category;

                        if(imageFile){
                            p.image = imageFile;
                        }

                        p.save((err)=>{
                            if(err) console.log(err);
                            if(imageFile){
                                if(pimage){
                                    fs.remove('public/product_images/'+id+'/'+pimage, (err)=>{
                                        if(err) console.log(err);
                                    });
                                }

                                let productImage = req.files.image;
                                let path = 'public/product_images/' + id + '/' +imageFile;

                                productImage.mv(path, (err)=>{
                                    return console.log(err);
                                });
                            }

                            req.flash('success', 'Product edited!');
                            res.redirect('/admin/products/edit-product/'+id);
                        });  //p.save End

                    })

                }  // if(p)구문 End
            })

        }  // validatorResults if구문 End

    });

[ admin_products.js]

 

 

 

 

4-9 라우트 (Post) :: /product-gallery/:id

'/product-gallery/:id'로 라우터는 갤러리의 수정을 구현한다.(제품 상세란 하단부분)

 


  //Post product gallery  
    router.post('/product-gallery/:id', (req, res)=>{
        let productImage = req.files.file;
        let id = req.params.id;
        let path = 'public/product_images/'+id+'/gallery/'+req.files.file.name;
        let thumbsPath = 'public/product_images/' + id + '/gallery/thumbs/' + req.files.file.name;

        productImage.mv(path, (err)=>{
            if(err) console.log(err);

            resizeImg(fs.readFileSync(path), {width:100, height:100}).then((buf)=>{
                fs.writeFileSync(thumbsPath, buf);
            });
        });
        res.sendStatus(200);
    });

[ admin_products.js ]

 

 

 

 

4-10 라우트 (Get) :: /delete-image/:image

'delete-image/:image'라우터는 하단 갤러리 부분의 이미지를 하나씩 삭제하는 기능을 구현한다. DB에 저장된 파일을 삭제할 필요는 없기 때문에 fs모듈을 사용한다.

 


  //Get delete image
    router.get('/delete-image/:image', (req, res)=>{
        let originalImagePath = 'public/product_images/'+req.query.id+'/gallery/'+req.params.image;
        let thumbImgPath = 'public/product_images/' + req.query.id + '/gallery/thumbs/' + req.params.image;

        fs.remove(originalImagePath, (err)=>{
            if(err) {
                console.log(err);
            }else{
                fs.remove(thumbImgPath, (err)=>{
                    if(err){
                       console.log(err);
                    }else{
                        req.flash('success', 'Image Deleted!');
                        res.redirect('/admin/products/edit-product/'+req.query.id);
                    }
                })
            }
        })
    });

 

 

 

 

4-11 라우트 (Get) :: /delete-product/:id

'/delete-product/:id'라우터는 제품을 보여주는 메인 페이지에서 관리자가 특정 제품을 삭제할 수 있는 기능을 구현한다. DB에서 해당 doc전체를 삭제하므로 Product.findByIdAndRemove()메서드를 사용한다.

 


  //Get delete product
    router.get('/delete-product/:id', (req, res)=>{
        let id = req.params.id;
        let path = 'public/product_images/'+ id;

        fs.remove(path,(err)=>{
            if(err){
                console.log(err);
            }else{
                Product.findByIdAndRemove(id, (err)=>{
                    console.log(err);
                })

                req.flash('success', 'Product Deleted!');
                res.redirect('/admin/products');
            }
        })
    });

//Exports
module.exports = router;

 

 

 

 

 

 

 

 

 

 

댓글

최신글 전체

이미지
제목
글쓴이
등록일