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 : 파일업로드 관련 기능(위치 지정, 이동, 삭제, 변경, 기타...)
|| mkdirp : 서버의 파일경로 생성
|| fs-extra :: 파일 복사
|| path :: 파일명 변경
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파일에 추가한다.
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 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=""> <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'라이브러리를 사용했다.
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; |
'웹개발 자료실 > node & Express 쇼핑몰제작Code' 카테고리의 다른 글
페이팔기반, e커머스 플랫폼 제작하기 [6편. 전역객체 카테고리 구현] (0) | 2021.11.08 |
---|---|
페이팔기반, e커머스 플랫폼 제작 [5편. 전역객체 page구현하기 ] (0) | 2021.11.08 |
페이팔기반, e커머스 플랫폼 제작하기 [3편. 카테고리 CRUD구현] (0) | 2021.11.03 |
페이팔기반, e커머스 플랫폼 제작하기 [2편. 페이지 CRUD 구현] (0) | 2021.10.30 |
페이팔기반, e커머스 플랫폼 제작하기 [1편. 설치 & 구조설계] (2) | 2021.10.29 |
댓글