[mini-project] 쇼핑 앱 결제 시스템 미니 프로젝트

김민재·2024년 4월 18일

mini_project

목록 보기
5/5

쇼핑 결제 시스템 프로젝트

(중요한 것만 넣어야겠다.)

1. 필요한 모듈 설치

  • npm i connect-flash method-override express-fileupload express-session fs-extra mkdirp resize-img

express-fileupload: 파일 업로드를 multer를 이용해서 했는데, 얘도 똑같다.

express-session: 세션 데이터를 서버에 저장하고 세션 식별자를 클라이언트에 저장한다. (cookie-session 차이점)

fs-extra: 파일시스템 작업을 수행하는데 파일 및 디렉토리를 생성, 복사, 이동, 삭제하는 등의 작업을 더 쉽게 수행할 수 있습니다. 또한 파일 및 디렉토리의 존재 여부를 확인하고, 파일 내용을 읽고 쓰는 작업도 할 수 있습니다.

mkdirp: 폴더 생성

resize-img: 썸네일 만들어 올리는 것(원래 크기보다 작게)

2. 기본 폴더 구성

admin: 관리자 페이지
checkout: 계산하는 페이지

3. API 요청을 처리하기 위한 파일 생성

4. admin category cretae

// admin-categories.router.js
router.post('/add-category', checkAdmin, async (req, res) => {
    try {
        const title = req.body.title;
        const slug = title.replace(/\s+/g, '-').toLowerCase();
        // 제목 입니다. 제목-입니다. 로 만들어준다.
        const category = await Category.findOne({ slug: slug });
        // slug에 같은 title + slug가 있는지 찾는다.
        if (category) {
            req.flash('error', '이미 존재하는 카테고리입니다.');
            res.redirect('back');
        }

        const newCategory = new Category({
            title,
            slug
        })
        
        await newCategory.save();

        req.flash('success', '카테고리가 생성되었습니다.');
        res.redirect('/admin/categories')
    } catch {
        console.error(error);
        next(error);
    }
})

5. admin category show

router.get('/', checkAdmin, async (req, res) => {
    try {
        const categories = await Category.find();
        res.render('admin/categories', { categories: categories });
    } catch {
        console.error(error);
        next(error);
    }
})

6. admin product UI FileReader API 소스코드

// UI add-product.ejs
<%- include('../partials/header') %>

<div class="d-flex justify-content-between align-items-center">
    <h2>상품 생성하기</h2>
    <a href="/admin/products" class="btn btn-primary">뒤로</a>
</div>
<br>

<!-- 이미지 파일을 보내기 위해서는 enctype 필수 -->
<form method="POST" action="/admin/products" enctype="multipart/form-data">
    <div class="row mb-3">
        <label for="title" class="form-label">Title</label>
        <input type="text" id="title" class="form-control" name="title" placeholder="Title" required>
    </div>

    <div class="row mb-3">
        <label for="description" class="form-label">Description</label>
        <textarea name="desc" id="description" class="form-control" cols="30" rows="10" placeholder="Description" required></textarea>
    </div>

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

    <div class="row mb-3">
        <label for="price" class="form-label">Price</label>
        <input type="number" class="form-control" name="price" id="price" placeholder="Price" required>
    </div>

    <div class="row mb-3">
        <label for="img" class="form-label">Image</label>
        <!--  multiple 해주면 여러개 올릴 수 있다. -->
        <input multiple type="file" class="form-control" name="image" id="img" required>
        <img src="#" id="imgPreview" alt="" class="mt-3" style="width: 100px;">
    </div>
    <button class="btn btn-primary mb-3">생성하기</button>
</form>

<!-- img src 태그에 내가 올린 이미지 파일이 올라갈 수 있도록  -->
<script>
    function readFile(inputEl) {
        if (inputEl.files && inputEl.files[0]) {
            let reader = new FileReader();
            reader.readAsDataURL(inputEl.files[0]);
            reader.onload = function(e) {
                document.querySelector('#imgPreview').setAttribute('src', e.target.result);
            }
        }
    }

    document.querySelector('#img').addEventListener('change', function() {
        readFile(this);
    })
</script>

<%- include('../partials/footer') %>

7. 상품 생성 로직 FileReader Api 사용

router.post('/', async (req, res, next) => {
    try {
    const {title, desc, price, category} = req.body
    const slug = title.replace(/\s+/g, '-').toLowerCase();
    const imageFile = req.files.image.name; // filereader api를 이용한다.

    const product = await Product.findOne({ slug })

    if (product) {
        req.flash('error', '이미 존재하는 상품입니다.');
        res.redirect('/admin/products');
    }

    const newProduct = new Product({
        title,
        slug,
        desc,
        category,
        price,
        image: imageFile,
    })

        await newProduct.save();

        // 이미지를 담을 폴더를 생성한다.
        await fs.mkdirp('src/public/products-images/' + newProduct._id); // 1경로에 이런 파일이 생긴다?
        await fs.mkdirp('src/public/products-images/' + newProduct._id + '/gallery');
        await fs.mkdirp('src/public/products-images/' + newProduct._id + '/gallery/thumbs');

        // 이미지 파일을 폴더에 넣어주기
        const productImage = req.files.image;
        const path = 'src/public/products-images/' + newProduct._id + '/' + imageFile;
        // 이미지를 path 경로로 옮긴다.
        await productImage.mv(path);

        req.flash('success', ' 상품이 생성되었습니다.');
        res.redirect('/admin/products');
    } catch (err) {
        console.error(err);
        next(err)
    }
})

8. 상품의 이미지 수정(dropzone)

// admin-products.router.js
// 상품 수정 render
router.get('/:id/edit', checkAdmin, async (req, res, next) => {
    try {
    const categories = await Category.find();
    const { _id, title, desc, category, price, image } = await Product.findById(req.params.id);

    const galleryDir = 'src/public/products-images/' + _id + '/gallery';

    const galleryImages = await fs.readdir(galleryDir);

    res.render('admin/edit-product',{galleryImages, title, desc, categories, category: category.replace(/\s+/g, '-').toLowerCase(), price, image, id: _id})
    } catch (err) {
        console.error(err);
        next(err);
   }
})

// 상품 이미지 수정
router.post('/product-gallery/:id', checkAdmin, async (req, res, next) => {
    const { id } = req.params;

    const productImage = req.files.file; // req.files 사용가능한데 우리는 img tag name이 file이라 이렇게 해야된다.
    const path = 'src/public/products-images/' + id + '/gallery/' + req.files.file.name; // 원본 이미지 경로
    const thumbsPath = 'src/public/products-images/' + id + '/gallery/thumbs/' + req.files.file.name; // 섬네일 이미지 경로

    try {
        // 원본 이미지를 gallery 폴더에 넣어주기
        await productImage.mv(path);

        // 이미지를 리사이즈.
        const buf = await ResizeImg(fs.readFileSync(path), { width: 100, height: 100 });

        fs.writeFileSync(thumbsPath, buf);

        res.sendStatus(200);
    } catch (err) {
        console.error(err);
        next(err);
    }
})

8-1. 상품 이미지 수정 dropzone 및 UI

<%- include('../partials/header') %>

<div class="d-flex justify-content-between align-items-center">
    <h2> 상품 수정하기</h2>
    <a href="/admin/products" class="btn btn-primary">뒤로</a>
</div>

<p>갤러리(세부 이미지들)</p>
<div class="gallery row">
    <% galleryImages.forEach(image => { %>
        <% if (image !== 'thumbs') { %>
        <div class="col">
            <form action="/admin/products/<%= id %>/image/<%= image %>?_method=DELETE" method="POST">
                <button type="submit">
                    <img src="/products-images/<%= id %>/gallery/thumbs/<%= image %>" alt="">
                </button>
            </form>
        </div>
        <% } %>
    <% }) %>
</div>
<br>

<!-- 이미지를 드래그 해서 올릴 수 있다. -->
<form enctype="multipart/form-data" method="post" action="/admin/products/product-gallery/<%= id %>" class="dropzone" id="dropzoneForm">
    <div class="fallback">
        <input type="file" name="file" multiple>
        <input type="submit" value="upload">
    </div>
</form>


<script src="https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" type="text/css" />


<!-- 이미지를 올리면 바로 올라간다. -->
<script>
    Dropzone.options.dropzoneForm = {
        acceptedFiles: "image/*", // 이미지만 올릴 수 있게.
        init: function() { // 1초 뒤에 새로고침된다.
            this.on("queuecomplete", file => {
                setTimeout(() => {
                    location.reload();
                }, 1000);
            });
        }
    }
</script>

<%- include('../partials/footer') %>

9. 상품 이미지 클릭 시 삭제

// 상품 이미지 삭제
router.delete('/:id/image/:imageId', checkAdmin, async (req, res, next) => {
    const originalImage = 'src/public/products-images/' + req.params.id + '/gallery/' + req.params.imageId;
    const thumbImage = 'src/public/products-images/' + req.params.id + '/gallery/thumbs/' + req.params.imageId;

    try {
        console.log(originalImage)
        await fs.remove(originalImage); // 이미지 삭제
        await fs.remove(thumbImage);

        req.flash('success', '이미지 삭제')
        res.redirect('/admin/products/' + req.params.id + '/edit')
    } catch (err) {
        console.error(err);
        next(err);
    }
})

10. 장바구니에 상품 추가

// 장바구니에 상품 추가
router.post('/:product', async (req, res, next) => {
    const productSlug = req.params.product;
    const product = await Product.findOne({ slug: productSlug});
    try {
        // 세션에 카트를 담아두고 장바구니에 새로운 상품이 담길 때
        if (req.session.cart.length === 0) {
            req.session.cart = [];
            req.session.cart.push({
                title: productSlug,
                qty: 1,
                price: product.price,
                image: '/products-images/' + product._id + '/' + product.image,
            });
            // 세션의 카트에 같은 상품이 있을때
        } else {
            let cart = req.session.cart;
            let newItem = true;

            // 이미 카트에 있는 상품이면 한 개 추가하고 loop break
            for (let i = 0; i < cart.length; i++){
                if (cart[i].title === productSlug) {
                    cart[i].qty += 1;
                    newItem = false;
                    break;
                }
            }
            // 새로운 상품이 담길 때
            if (newItem) {
                cart = [];
                cart.push({
                    title: productSlug,
                    qty: 1,
                    price: product.price,
                    image: '/products-images/' + product._id + '/' + product.image,
                });
            }
        }

        req.flash('success', '장바구니에 상품이 담겼습니다.');
        res.redirect('back');
    } catch (err) {
        console.error(err);
        next(err);
    }
})

11. 장바구니 상품 개수 업데이트

// ejs
   <td>
                <!-- 일반적으로 a tag는 get 요청이다.  -->
                <a class="btn btn-primary" href="/cart/update/<%= product.title %>?action=add">+</a>&nbsp;
                <a class="btn btn-danger" href="/cart/update/<%= product.title %>?action=remove">-</a>&nbsp;
                <a class="btn btn-dark" href="/cart/update/<%= product.title %>?action=clear">clear</a>&nbsp;
            </td>

// router
router.get('/update/:product', async (req, res, next) => {
    try {
         const slug = req.params.product;
         let cart = req.session.cart;
         const operator = req.query.action;
        for (let i = 0; i < cart.length; i++){
            if (cart[i].title === slug) {
                if (operator === 'add') {
                    cart[i].qty += 1;
                    req.flash('success', '상품 개수가 추가되었습니다.');
                } else if (operator === 'remove') {
                    cart[i].qty -= 1;
                    if (cart[i].qty <= 0) {
                        cart.splice(i, 1);
                    };
                    req.flash('success', '상품 개수가 제거되었습니다.');
                } else if (operator === 'clear') {
                    cart.splice(i, 1);
                    req.flash('success', '상품이 제거되었습니다.');
                }
            }
            break;
        }
        res.redirect('/cart/checkout'); // 변경된 카트를 다시 표시하기 위해 리디렉션합니다.
    } catch (err) {
        console.error(err);
        next(err);
    }
})

12. 장바구니 비우기

router.delete('/', async (req, res, next) => {
    let cart = req.session.cart;
    try {
        if (cart.length > 0) {
            delete req.session.cart;
            // req.session.cart = [];
            req.flash('success','장바구니가 비어졌습니다.')
        } else {
            req.flash('error','장바구니가 비어있습니다.')
        }

        res.redirect('/cart/checkout')
    } catch (err) {
        console.error(err);
        next(err);
    }
})

13. 결제 시스템 (PORT ONE) 이용하기

HEADER.ejs
    <script src="https://cdn.iamport.kr/v1/iamport.js"></script>
    
결제할 창 ejs
<script>
    IMP.init("가맹점 ID"); // 예: imp00000000a

    function requestPay() {
        IMP.request_pay({
            pg: "PG PROVIDER.PGID", // 관리자 콘솔 결제 연동에 있음 
            pay_method: "card",
            merchant_uid: "ORD2018012321-00003011", // 주문번호
            name: "노르웨이 회전 의자",
            amount: 100, // 숫자 타입
            buyer_email: "gildong@gmail.com",
            buyer_name: "홍길동",
            buyer_tel: "010-4242-4242",
            buyer_addr: "서울특별시 강남구 신사동",
            buyer_postcode: "01181"
        }, function(rsp) { // callback
            if (rsp.success) {
                // 결제 성공 시 로직
                fetch('/cart/complete-order')
                    .then(res => {
                        location.reload();
                    })
                    .catch(err => {
                        console.error(err);
                    })
            } else {
                // 결제 실패 시 로직
                console.log('실패', rsp);
            }
        });
    }
</script>
profile
개발 경험치 쌓는 곳

0개의 댓글