Sparta/What I Learned

23.1.2

코딩하는 또롱이 2023. 1. 3. 08:52
숙련 마지막 강의
📌 목표
페이징 및 정렬 기능을 추가하면서 페이지 처리에 대해 이해한다.
폴더 기능을 추가하면서 JPA 연관관계에 대해 이해한다.

 


 

 📌 기능

  1. 키워드로 상품 검색하고 그 결과를 목록으로 보여주기
  2. 회원가입
  3. 로그인
  4. 로그인 성공 시 토큰 발급
  5. 로그아웃
  6. 로그인 한 유저만 관심상품 등록, 조회, 최저가 등록 가능
  7. ADMIN 계정은 모든 상품 조회 가능
  8. 관심상품 목록 페이징 및 정렬 가능
  9. 폴더 생성 및 조회 가능
  10. 관심상품에 폴더 추가 기능
  11. 폴더 별 관심상품 조회 가능

 


 

📌 페이징?

어떠한 정보를 찾았을 때 너무 많은 데이터들이 한 페이지에 나타나게 되면 오류가 발생할 확률이 매우 높기 때문에 페이징 처리를 해주어서 정보를 제한해서 보여주는 것.

 

 

📌 API

관심상품 조회하기가 DTO가 아니라 PAGE<Product>을 통해 전체 반환하는 것을 알 수 있다. 강의에서는 코드가 길어지고 이해하기 어려울까봐 전체 반환하는 것이고, 실무에 가서는 프론트 엔드가 원하는 대로 DTO를 써서 반환해야 할 수도 있다. 그러니까 나중에 혼자서 DTO로 한 번 변경해보는 것도 나쁘지 않을 것 같다.

 

📌 UI 설계

 

  1. 페이징
    1. page : 조회할 페이지 번호 (1부터 시작)
    2. size : 한 페이지에 보여줄 상품 개수 (10개로 고정!)
  2. 정렬
    1. sortBy (정렬 항목)
      1. id : Product 테이블의 id
      2. title : 상품명
      3. lprice : 최저가
    2. isAsc (오름차순?)
      1. true: 오름차순 (asc)
      2. false : 내림차순 (desc)

  • number: 조회된 페이지 번호 (0부터 시작)
  • content: 조회된 상품 정보 (배열)
  • size: 한 페이지에 보여줄 상품 개수
  • numberOfElements: 실제 조회된 상품 개수
  • totalElements: 전체 상품 개수 (회원이 등록한 모든 상품의 개수)
  • totalPages: 전체 페이지 수
totalPages = totalElement / size 결과를 소수점 올림
1 / 10 = 0.1 => 총 1 페이지
9 / 10 = 0.9 => 총 1페이지
10 / 10 = 1 => 총 1페이지
11 / 10 => 1.1 => 총 2페이지
  • first: 첫 페이지인지? (boolean)
  • last: 마지막 페이지인지? (boolean)

 

📌 API 변경

 


 

페이징 정렬 및 구현

📌 Client 구현

 

[index.html]

더보기
<!doctype html>
<html lang="ko" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="preconnect" href="https://fonts.gstatic.com">
  <link href="https://fonts.googleapis.com/css2?family=Nanum+Gothic&display=swap" rel="stylesheet">
  <link th:href="@{/style.css}" rel="stylesheet">

  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/paginationjs/2.1.4/pagination.min.js"></script>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/paginationjs/2.1.4/pagination.css"/>


  <script type="text/javascript" th:src="@{/basic.js}"></script>
  <title>나만의 셀렉샵</title>
</head>
<body>
<div class="header" style="position:relative;">
  <!--headr 추가 부분-->
  <div id="login-true" style="display: none">
    <div id="header-title-login-user">
      <div id="username"></div> 님의
    </div>
    <div id="header-title-select-shop">
      Select Shop
    </div>
    <a id="login-text" href="javascript:logout()">
      로그아웃
    </a>
  </div>
  <div id="login-false" >
    <div id="header-title-select-shop">
      Select Shop
    </div>
    <a id="sign-text" href="/api/user/signup">
      회원가입
    </a>
    <a id="login-text" href="/api/user/login">
      로그인
    </a>
  </div>

  <!--/headr 추가 부분-->
</div>
<div class="nav">
  <div class="nav-see active">
    모아보기
  </div>
  <div class="nav-search">
    탐색하기
  </div>
</div>
<div id="see-area">
  <div class="pagination">
    정렬:
    <select id="sorting" onchange="showProduct()">
      <option value="id">ID</option>
      <option value="title">상품명</option>
      <option value="lprice">최저가</option>
    </select>
    <input type="radio" name="isAsc" value="true" onchange="showProduct()" checked /> 오름차순
    <input type="radio" name="isAsc" value="false" onchange="showProduct()" /> 내림차순
  </div>
  <div id="pagination" class="pagination"></div>
  <ul id="product-container">
  </ul>
</div>
<div id="search-area">
  <div>
    <input type="text" id="query">
  </div>
  <div id="search-result-box">

  </div>
  <div id="container" class="popup-container">
    <div class="popup">
      <button id="close" class="close">
        X
      </button>
      <h1>⏰최저가 설정하기</h1>
      <p>최저가를 설정해두면 선택하신 상품의 최저가가 떴을 때<br/> 표시해드려요!</p>
      <div>
        <input type="text" id="myprice" placeholder="200,000">원
      </div>
      <button class="cta" onclick="setMyprice()">설정하기</button>
    </div>
  </div>
</div>
</body>
</html>

 

[basic.js]

더보기
let targetId;

$(document).ready(function () {
    // cookie 여부 확인하여 로그인 확인
    const auth = getToken();

    if(auth !== '') {
        $('#username').text('수강생');
        $('#login-true').show();
        $('#login-false').hide();
    } else {
        $('#login-false').show();
        $('#login-true').hide();
    }

    // id 가 query 인 녀석 위에서 엔터를 누르면 execSearch() 함수를 실행하라는 뜻입니다.
    $('#query').on('keypress', function (e) {
        if (e.key == 'Enter') {
            execSearch();
        }
    });
    $('#close').on('click', function () {
        $('#container').removeClass('active');
    })

    $('.nav div.nav-see').on('click', function () {
        $('div.nav-see').addClass('active');
        $('div.nav-search').removeClass('active');

        $('#see-area').show();
        $('#search-area').hide();
    })
    $('.nav div.nav-search').on('click', function () {
        $('div.nav-see').removeClass('active');
        $('div.nav-search').addClass('active');

        $('#see-area').hide();
        $('#search-area').show();
    })

    $('#see-area').show();
    $('#search-area').hide();

    showProduct();
})


function numberWithCommas(x) {
    return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

function execSearch() {
    /**
     * 검색어 input id: query
     * 검색결과 목록: #search-result-box
     * 검색결과 HTML 만드는 함수: addHTML
     */
        // 1. 검색창의 입력값을 가져온다.
    let query = $('#query').val();

    // 2. 검색창 입력값을 검사하고, 입력하지 않았을 경우 focus.
    if (query == '') {
        alert('검색어를 입력해주세요');
        $('#query').focus();
        return;
    }
    // 3. GET /api/search?query=${query} 요청
    $.ajax({
        type: 'GET',
        url: `/api/search?query=${query}`,
        success: function (response) {
            $('#search-result-box').empty();
            // 4. for 문마다 itemDto를 꺼내서 HTML 만들고 검색결과 목록에 붙이기!
            for (let i = 0; i < response.length; i++) {
                let itemDto = response[i];
                let tempHtml = addHTML(itemDto);
                $('#search-result-box').append(tempHtml);
            }
        }
    })

}

function addHTML(itemDto) {
    /**
     * class="search-itemDto" 인 녀석에서
     * image, title, lprice, addProduct 활용하기
     * 참고) onclick='addProduct(${JSON.stringify(itemDto)})'
     */
    return `<div class="search-itemDto">
        <div class="search-itemDto-left">
            <img src="${itemDto.image}" alt="">
        </div>
        <div class="search-itemDto-center">
            <div>${itemDto.title}</div>
            <div class="price">
                ${numberWithCommas(itemDto.lprice)}
                <span class="unit">원</span>
            </div>
        </div>
        <div class="search-itemDto-right">
            <img src="../images/icon-save.png" alt="" onclick='addProduct(${JSON.stringify(itemDto)})'>
        </div>
    </div>`
}

function addProduct(itemDto) {
    /**
     * modal 뜨게 하는 법: $('#container').addClass('active');
     * data를 ajax로 전달할 때는 두 가지가 매우 중요
     * 1. contentType: "application/json",
     * 2. data: JSON.stringify(itemDto),
     */

    const auth = getToken();

    // 1. POST /api/products 에 관심 상품 생성 요청
    $.ajax({
        type: "POST",
        url: '/api/products',
        contentType: "application/json",
        data: JSON.stringify(itemDto),
        beforeSend: function (xhr) {
            xhr.setRequestHeader("Authorization", auth);
        },
        success: function (response) {
            // 2. 응답 함수에서 modal을 뜨게 하고, targetId 를 reponse.id 로 설정
            $('#container').addClass('active');
            targetId = response.id;
        }
    })
}

function showProduct() {
    /**
     * 관심상품 목록: #product-container
     * 검색결과 목록: #search-result-box
     * 관심상품 HTML 만드는 함수: addProductItem
     */
    const auth = getToken();

    var sorting = $("#sorting option:selected").val();
    var isAsc = $(':radio[name="isAsc"]:checked').val();

    $('#product-container').empty();
    $('#search-result-box').empty();
    $('#pagination').pagination({
        dataSource: `/api/products?sortBy=${sorting}&isAsc=${isAsc}`,
        locator: 'content',
        alias: {
            pageNumber: 'page',
            pageSize: 'size'
        },
        totalNumberLocator: (response) => {
            return response.totalElements;
        },
        pageSize: 10,
        showPrevious: true,
        showNext: true,
        ajax: {
            beforeSend: function(xhr) {
                xhr.setRequestHeader("Authorization", auth);
                $('#product-container').html('상품 불러오는 중...');
            }
        },
        callback: function(data, pagination) {
            console.log(data);
            $('#product-container').empty();
            for (let i = 0; i < data.length; i++) {
                let product = data[i];
                let tempHtml = addProductItem(product);
                $('#product-container').append(tempHtml);
            }
        }
    });
}

function addProductItem(product) {
    // link, image, title, lprice, myprice 변수 활용하기
    return `<div class="product-card" onclick="window.location.href='${product.link}'">
                <div class="card-header">
                    <img src="${product.image}"
                         alt="">
                </div>
                <div class="card-body">
                    <div class="title">
                        ${product.title}
                    </div>
                    <div class="lprice">
                        <span>${numberWithCommas(product.lprice)}</span>원
                    </div>
                    <div class="isgood ${product.lprice > product.myprice ? 'none' : ''}">
                        최저가
                    </div>
                </div>
            </div>`;
}

function setMyprice() {
    /**
     * 1. id가 myprice 인 input 태그에서 값을 가져온다.
     * 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
     * 3. PUT /api/product/${targetId} 에 data를 전달한다.
     *    주의) contentType: "application/json",
     *         data: JSON.stringify({myprice: myprice}),
     *         빠뜨리지 말 것!
     * 4. 모달을 종료한다. $('#container').removeClass('active');
     * 5, 성공적으로 등록되었음을 알리는 alert를 띄운다.
     * 6. 창을 새로고침한다. window.location.reload();
     */
        // 1. id가 myprice 인 input 태그에서 값을 가져온다.
    let myprice = $('#myprice').val();
    // 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
    if (myprice == '') {
        alert('올바른 가격을 입력해주세요');
        return;
    }
    const auth = getToken();
    // 3. PUT /api/product/${targetId} 에 data를 전달한다.
    $.ajax({
        type: "PUT",
        url: `/api/products/${targetId}`,
        contentType: "application/json",
        data: JSON.stringify({myprice: myprice}),
        beforeSend: function (xhr) {
            xhr.setRequestHeader("Authorization", auth);
        },
        success: function (response) {
            // 4. 모달을 종료한다. $('#container').removeClass('active');
            $('#container').removeClass('active');
            // 5. 성공적으로 등록되었음을 알리는 alert를 띄운다.
            alert('성공적으로 등록되었습니다.');
            // 6. 창을 새로고침한다. window.location.reload();
            window.location.reload();
        }
    })
}

function logout() {
    // 토큰 값 ''으로 덮어쓰기
    document.cookie =
        'Authorization' + '=' + '' + ';path=/';
    window.location.reload();
}

function  getToken() {
    let cName = 'Authorization' + '=';
    let cookieData = document.cookie;
    let cookie = cookieData.indexOf('Authorization');
    let auth = '';
    if(cookie !== -1){
        cookie += cName.length;
        let end = cookieData.indexOf(';', cookie);
        if(end === -1)end = cookieData.length;
        auth = cookieData.substring(cookie, end);
    }
    return auth;
}

 

<결과>

상품이 없어서 페이징은 구현이 안 되어 있지만, 정렬이 반영 된 것을 알 수 있다.

 

📌 Server 구현

 

[ProductController]

수정 전에는 로그인을 확인하기 위해서 request 객체만 받아왔다.

수정 후에는 RequestParam 방식으로 query가 날라오고 그대로 반환하게 변경되었다.

인덱스가 0부터 시작되므로 page-1을 써주어야한다.

더보기
// 관심 상품 조회하기
@GetMapping("/products")
public Page<Product> getProducts(
        @RequestParam("page") int page,
        @RequestParam("size") int size,
        @RequestParam("sortBy") String sortBy,
        @RequestParam("isAsc") boolean isAsc,
        HttpServletRequest request
) {
    // 응답 보내기
    return productService.getProducts(request, page-1, size, sortBy, isAsc);
}

 

[ProductService]

수정 전에는 Token만 확인하는 코드였었다.

수정 후에는 페이징 처리 코드가 추가되고 Token값을 받아 오는 코드도 조금 변경되었다.

Controller로부터 받은 값들을 이용하여 페이징을 처리해주고 상품명, 최저가, ID 순으로 정렬이 가능하게끔 해주는 코드들이 추가됐다.(Pageable은 구글링을 통해 좀 더 알아보기!)

Token코드는 원래는 userId만 받아왔었는데 지금은 pageable도 같이 받게하고, admin 계정도 페이징 및 정렬 처리할 수 있도록 pageable받아오는 것으로 변경되었다.

더보기
@Transactional(readOnly = true)
public Page<Product> getProducts(HttpServletRequest request,
                                 int page, int size, String sortBy, boolean isAsc) {
    // 페이징 처리
    Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
    Sort sort = Sort.by(direction, sortBy);
    Pageable pageable = PageRequest.of(page, size, sort);

    // Request에서 Token 가져오기
    String token = jwtUtil.resolveToken(request);
    Claims claims;

    // 토큰이 있는 경우에만 관심상품 조회 가능
    if (token != null) {
        // Token 검증
        if (jwtUtil.validateToken(token)) {
            // 토큰에서 사용자 정보 가져오기
            claims = jwtUtil.getUserInfoFromToken(token);
        } else {
            throw new IllegalArgumentException("Token Error");
        }

        // 토큰에서 가져온 사용자 정보를 사용하여 DB 조회
        User user = userRepository.findByUsername(claims.getSubject()).orElseThrow(
                () -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
        );

        // 사용자 권한 가져와서 ADMIN 이면 전체 조회, USER 면 본인이 추가한 부분 조회
        UserRoleEnum userRoleEnum = user.getRole();
        System.out.println("role = " + userRoleEnum);

        Page<Product> products;

        if (userRoleEnum == UserRoleEnum.USER) {
            // 사용자 권한이 USER일 경우
            products = productRepository.findAllByUserId(user.getId(), pageable);
        } else {
            products = productRepository.findAll(pageable);
        }

        return products;

    } else {
        return null;
    }
}

 

[ProductRepository]

원래는 List<Product> ... 이었다면 전부 Page<Product>로 변경되었고, pageable을 받게끔 수정 되었다. 또한, import 부붕니 domain이 제대로 설정되어 있는지 확인 꼭 해주어야한다.

더보기
package com.sparta.myselectshop.repository;

import com.sparta.myselectshop.entity.Product;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface ProductRepository extends JpaRepository<Product, Long> {
    Page<Product> findAllByUserId(Long userId, Pageable pageable);
    Optional<Product> findByIdAndUserId(Long id, Long userId);
    Page<Product> findAll(Pageable pageable);
}

 

<결과>

ID, 상품명, 최저가 순으로 오름차순, 내림차순 정렬이 잘 되는 것을 알 수 있고, 페이징은 상품 추가가 귀찮아서 안 했지만 강의를 보면 상품 30개 넘게 했을 때 페이지당 10개씩 잘 들어가있는 것을 알 수 있다.

 


 

📌 폴더

여러 상품이 섞여 있어서 보기 불편하므로 폴더를 생성하고 그 안에 상품추가하여 페이징 및 정렬을 할 수 있게끔 코드 수정할 것이다. 하지만 전체를 누르면 전체 조회도 가능하게끔 구현해 볼 것이다.

 

📌 Client

 

[index.html]

더보기
<!doctype html>
<html lang="ko" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="preconnect" href="https://fonts.gstatic.com">
  <link href="https://fonts.googleapis.com/css2?family=Nanum+Gothic&display=swap" rel="stylesheet">
  <link th:href="@{/style.css}" rel="stylesheet">

  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/paginationjs/2.1.4/pagination.min.js"></script>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/paginationjs/2.1.4/pagination.css"/>


  <script type="text/javascript" th:src="@{/basic.js}"></script>
  <title>나만의 셀렉샵</title>
</head>
<body>
<div class="header" style="position:relative;">
  <!--headr 추가 부분-->
  <div id="login-true" style="display: none">
    <div id="header-title-login-user">
      <!--            <span th:value="${username}"></span> 님의-->
      <div id="username"></div> 님의
    </div>
    <div id="header-title-select-shop">
      Select Shop
    </div>
    <a id="login-text" href="javascript:logout()">
      로그아웃
    </a>
  </div>
  <div id="login-false" >
    <div id="header-title-select-shop">
      Select Shop
    </div>
    <a id="sign-text" href="/api/user/signup">
      회원가입
    </a>
    <a id="login-text" href="/api/user/login">
      로그인
    </a>
  </div>
  <!--/headr 추가 부분-->
</div>
<div class="nav">
  <div class="nav-see active">
    모아보기
  </div>
  <div class="nav-search">
    탐색하기
  </div>
</div>
<div id="see-area">
  <div class="folder-bar folder-black">
    <div>
      <button id="folder-all" class="folder-bar-item folder-button product-folder folder-active" onclick="openFolder()">전체</button>
    </div>
    <div id="fragment">
      <div th:each="folder : ${folders}">
        <button class="folder-bar-item folder-button product-folder"
                th:value="${folder.id}"
                th:utext="${folder.name}"
                th:attr="onclick=|openFolder(${folder.id})|">
        </button>
      </div>
    </div>
    <div>
      <button id="folder-add" class="folder-bar-item folder-button product-folder" onclick="openAddFolderPopup()">
        <svg xmlns="http://www.w3.org/2000/svg" width="30px" fill="currentColor" class="bi bi-folder-plus" viewBox="0 0 16 16">
          <path d="M.5 3l.04.87a1.99 1.99 0 0 0-.342 1.311l.637 7A2 2 0 0 0 2.826 14H9v-1H2.826a1 1 0 0 1-.995-.91l-.637-7A1 1 0 0 1 2.19 4h11.62a1 1 0 0 1 .996 1.09L14.54 8h1.005l.256-2.819A2 2 0 0 0 13.81 3H9.828a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 6.172 1H2.5a2 2 0 0 0-2 2zm5.672-1a1 1 0 0 1 .707.293L7.586 3H2.19c-.24 0-.47.042-.684.12L1.5 2.98a1 1 0 0 1 1-.98h3.672z"/>
          <path d="M13.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H14v1.5a.5.5 0 0 1-1 0V13h-1.5a.5.5 0 0 1 0-1H13v-1.5a.5.5 0 0 1 .5-.5z"/>
        </svg>
      </button>
    </div>
  </div>
  <div class="pagination">
    정렬:
    <select id="sorting" onchange="showProduct()">
      <option value="id">ID</option>
      <option value="title">상품명</option>
      <option value="lprice">최저가</option>
    </select>
    <input type="radio" name="isAsc" value="true" onchange="showProduct()" checked /> 오름차순
    <input type="radio" name="isAsc" value="false" onchange="showProduct()" /> 내림차순
  </div>
  <div id="pagination" class="pagination"></div>
  <ul id="product-container">
  </ul>
  <div id="container2" class="popup-container">
    <div class="popup" style="width:410px; height:auto">
      <button id="close2" class="close">
        X
      </button>
      <h1>🗂 폴더 추가하기</h1>
      <p>폴더를 추가해서 관심상품을 관리해보세요!</p>
      <div id="folders-input">
        <input type="text" class="folderToAdd" placeholder="추가할 폴더명">
        
      </div>
      <div onclick="addFolderInput()">
        <svg xmlns="http://www.w3.org/2000/svg" width="30px" fill="currentColor" class="bi bi-folder-plus" viewBox="0 0 16 16">
          <path d="M.5 3l.04.87a1.99 1.99 0 0 0-.342 1.311l.637 7A2 2 0 0 0 2.826 14H9v-1H2.826a1 1 0 0 1-.995-.91l-.637-7A1 1 0 0 1 2.19 4h11.62a1 1 0 0 1 .996 1.09L14.54 8h1.005l.256-2.819A2 2 0 0 0 13.81 3H9.828a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 6.172 1H2.5a2 2 0 0 0-2 2zm5.672-1a1 1 0 0 1 .707.293L7.586 3H2.19c-.24 0-.47.042-.684.12L1.5 2.98a1 1 0 0 1 1-.98h3.672z"/>
          <path d="M13.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H14v1.5a.5.5 0 0 1-1 0V13h-1.5a.5.5 0 0 1 0-1H13v-1.5a.5.5 0 0 1 .5-.5z"/>
        </svg>
      </div>
      <button id="add-folder-btn" class="cta2" onclick="addFolder()">추가하기</button>
    </div>
  </div>
</div>
<div id="search-area">
  <div>
    <input type="text" id="query">
  </div>
  <div id="search-result-box">

  </div>
  <div id="container" class="popup-container">
    <div class="popup">
      <button id="close" class="close">
        X
      </button>
      <h1>⏰최저가 설정하기</h1>
      <p>최저가를 설정해두면 선택하신 상품의 최저가가 떴을 때<br/> 표시해드려요!</p>
      <div>
        <input type="text" id="myprice" placeholder="200,000">원
      </div>
      <button class="cta" onclick="setMyprice()">설정하기</button>
    </div>
  </div>
</div>
</body>
</html>

 

[style.css]

더보기
* {
    font-family: 'Nanum Gothic', sans-serif;
}

body {
    margin: 0px;
}

#search-result-box {
    margin-top: 15px;
}

.search-itemDto {
    width: 530px;
    display: flex;
    flex-direction: row;
    align-content: center;
    justify-content: space-around;
}

.search-itemDto-left img {
    width: 159px;
    height: 159px;
}

.search-itemDto-center {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: space-evenly;
}

.search-itemDto-center div {
    width: 280px;
    height: 23px;
    font-size: 18px;
    font-weight: normal;
    font-stretch: normal;
    font-style: normal;
    line-height: 1.3;
    letter-spacing: -0.9px;
    text-align: left;
    color: #343a40;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}

.search-itemDto-center div.price {
    height: 27px;
    font-size: 27px;
    font-weight: 600;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: -0.54px;
    text-align: left;
    color: #E8344E;
}

.search-itemDto-center span.unit {
    width: 17px;
    height: 18px;
    font-size: 18px;
    font-weight: 500;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: -0.9px;
    text-align: center;
    color: #000000;
}

.search-itemDto-right {
    display: inline-block;
    height: 100%;
    vertical-align: middle
}

.search-itemDto-right img {
    height: 25px;
    width: 25px;
    vertical-align: middle;
    margin-top: 60px;
    cursor: pointer;
}

input#query {
    padding: 15px;
    width: 526px;
    border-radius: 2px;
    background-color: #e9ecef;
    border: none;

    background-image: url('../images/icon-search.png');
    background-repeat: no-repeat;
    background-position: right 10px center;
    background-size: 20px 20px;
}

input#query::placeholder {
    padding: 15px;
}

button {
    color: white;
    border-radius: 4px;
    border-radius: none;
}

.popup-container {
    display: none;
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    background-color: rgba(0, 0, 0, 0.5);
    align-items: center;
    justify-content: center;
}

.popup-container.active {
    display: flex;
}

.popup {
    padding: 20px;
    box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.3);
    position: relative;
    width: 370px;
    height: 209px;
    border-radius: 11px;
    background-color: #ffffff;
}

.popup h1 {
    margin: 0px;
    font-size: 22px;
    font-weight: 500;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: -1.1px;
    color: #000000;
}

.popup input {
    width: 320px;
    height: 22px;
    border-radius: 2px;
    border: solid 1.1px #dee2e6;
    margin-right: 9px;
    margin-bottom: 10px;
    padding-left: 10px;
}

.add-folder-btn {
    width: 330px;
}

.popup button.close {
    position: absolute;
    top: 15px;
    right: 15px;
    color: #adb5bd;
    background-color: #fff;
    font-size: 19px;
    border: none;
}

.popup button.cta {
    width: 369.1px;
    height: 43.9px;
    border-radius: 2px;
    background-color: #15aabf;
    border: none;
}

.popup button.cta2 {
    width: 352.1px;
    height: 43.9px;
    border-radius: 2px;
    background-color: #15aabf;
    border: none;
}

#search-area, #see-area {
    width: 530px;
    margin: auto;
}

.nav {
    width: 530px;
    margin: 30px auto;
    display: flex;
    align-items: center;
    justify-content: space-around;
}

.nav div {
    cursor: pointer;
}

.nav div.active {
    font-weight: 700;
}

.header {
    height: 255px;
    box-sizing: border-box;
    background-color: #15aabf;
    color: white;
    text-align: center;
    padding-top: 80px;
    /*padding: 50px;*/
    font-size: 45px;
    font-weight: bold;
}

#header-title-login-user {
    font-size: 36px;
    letter-spacing: -1.08px;
}

#header-title-select-shop {
    margin-top: 20px;
    font-size: 45px;
    letter-spacing: 1.1px;
}

#product-container {
    grid-template-columns: 100px 50px 100px;
    grid-template-rows: 80px auto 80px;
    column-gap: 10px;
    row-gap: 15px;
}

.product-card {
    width: 300px;
    margin: auto;
    cursor: pointer;
}

.product-card .card-header {
    width: 300px;
}

.product-card .card-header img {
    width: 300px;
}

.product-card .card-body {
    margin-top: 15px;
}

.product-card .card-body .title {
    font-size: 11px;
    font-weight: normal;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: -0.75px;
    text-align: left;
    color: #343a40;
    margin-bottom: 10px;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}

.product-card .card-body .lprice {
    font-size: 15.8px;
    font-weight: normal;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: -0.79px;
    color: #000000;
    margin-bottom: 10px;
}

.product-card .card-body .lprice span {
    font-size: 13.4px;
    font-weight: 600;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: -0.43px;
    text-align: left;
    color: #E8344E;
}

.product-card .card-body .isgood {
    margin-top: 10px;
    padding: 10px 20px;
    color: white;
    border-radius: 2.6px;
    background-color: #ff8787;
    width: 48px;
}

.pagination {
    font-size: 14px;
    margin-top: 12px;
    margin-left: auto;
    margin-right: auto;
    width: 65%;
}

.folder-active {
    background-color: crimson !important;;
}

.none {
    display: none;
}

.folder-bar {
    width: 100%;
    overflow: hidden;
}

.folder-black, .folder-black:hover {
    color: #fff!important;
    background-color: #000!important;
}

.folder-bar .folder-button {
    white-space: normal;
}
.folder-bar .folder-bar-item {
    font-size: 17px;
    padding: 8px 16px;
    float: left;
    width: auto;
    border: none;
    display: block;
    outline: 0;
}

.folder-btn, .folder-button {
    border: none;
    display: inline-block;
    padding: 8px 16px;
    vertical-align: middle;
    overflow: hidden;
    text-decoration: none;
    color: inherit;
    background-color: inherit;
    text-align: center;
    cursor: pointer;
    white-space: nowrap;
}

#login-form {
    width: 538px;
    height: 710px;
    margin: 70px auto 141px auto;
    display: flex;
    flex-direction: column;
    justify-content: flex-start;
    align-items: center;
    /*gap: 96px;*/
    padding: 56px 0 0;
    border-radius: 10px;
    box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.15);
    background-color: #ffffff;
}

#login-title {
    width: 303px;
    height: 32px;
    /*margin: 56px auto auto auto;*/
    flex-grow: 0;
    font-family: SpoqaHanSansNeo;
    font-size: 32px;
    font-weight: bold;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: -0.96px;
    text-align: left;
    color: #212529;
}

#login-kakao-btn {
    border-width: 0;
    margin: 96px 0 8px;
    width: 393px;
    height: 62px;
    flex-grow: 0;
    display: flex;
    flex-direction: row;
    justify-content: center;
    align-items: center;
    gap: 10px;
    /*margin: 0 0 8px;*/
    padding: 11px 12px;
    border-radius: 5px;
    background-color: #ffd43b;

    font-family: SpoqaHanSansNeo;
    font-size: 20px;
    font-weight: bold;
    font-stretch: normal;
    font-style: normal;
    color: #414141;
}

#login-id-btn {
    width: 393px;
    height: 62px;
    flex-grow: 0;
    display: flex;
    flex-direction: row;
    justify-content: center;
    align-items: center;
    gap: 10px;
    /*margin: 8px 0 0;*/
    padding: 11px 12px;
    border-radius: 5px;
    border: solid 1px #212529;
    background-color: #ffffff;

    font-family: SpoqaHanSansNeo;
    font-size: 20px;
    font-weight: bold;
    font-stretch: normal;
    font-style: normal;
    color: #414141;
}

.login-input-box {
    border-width: 0;

    width: 370px !important;
    height: 52px;
    margin: 14px 0 0;
    border-radius: 5px;
    background-color: #e9ecef;
}

.login-id-label {
    /*width: 44.1px;*/
    /*height: 16px;*/
    width: 382px;
    padding-left: 11px;
    margin-top: 40px;
    /*margin: 0 337.9px 14px 11px;*/
    font-family: NotoSansCJKKR;
    font-size: 16px;
    font-weight: normal;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: -0.8px;
    text-align: left;
    color: #212529;
}

#login-id-submit {
    border-width: 0;
    width: 393px;
    height: 62px;
    flex-grow: 0;
    display: flex;
    flex-direction: row;
    justify-content: center;
    align-items: center;
    gap: 10px;
    margin: 40px 0 0;
    padding: 11px 12px;
    border-radius: 5px;
    background-color: #15aabf;

    font-family: SpoqaHanSansNeo;
    font-size: 20px;
    font-weight: bold;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: normal;
    text-align: center;
    color: #ffffff;
}

#sign-text {
    position:absolute;
    top:48px;
    right:110px;
    font-size: 18px;
    font-family: SpoqaHanSansNeo;
    font-size: 18px;
    font-weight: 500;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: 0.36px;
    text-align: center;
    color: #ffffff;
}

#login-text {
    position:absolute;
    top:48px;
    right:50px;
    font-size: 18px;
    font-family: SpoqaHanSansNeo;
    font-size: 18px;
    font-weight: 500;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: 0.36px;
    text-align: center;
    color: #ffffff;
}

@media ( max-width: 768px ) {
    body {
        margin: 0px;
    }

    #search-result-box {
        margin-top: 15px;
    }

    .search-itemDto {
        width: 330px;
        display: flex;
        flex-direction: row;
        align-content: center;
        justify-content: space-around;
    }

    .search-itemDto-left img {
        width: 80px;
        height: 80px;
    }

    .search-itemDto-center {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: space-evenly;
    }

    .search-itemDto-center div {
        width: 190px;
        height: 23px;
        font-size: 13px;
        font-weight: normal;
        font-stretch: normal;
        font-style: normal;
        line-height: 1.3;
        letter-spacing: -0.9px;
        text-align: left;
        color: #343a40;
        overflow: hidden;
        white-space: nowrap;
        text-overflow: ellipsis;
    }

    .search-itemDto-center div.price {
        height: 27px;
        font-size: 17px;
        font-weight: 600;
        font-stretch: normal;
        font-style: normal;
        line-height: 1;
        letter-spacing: -0.54px;
        text-align: left;
        color: #E8344E;
    }

    .search-itemDto-center span.unit {
        width: 17px;
        height: 18px;
        font-size: 14px;
        font-weight: 500;
        font-stretch: normal;
        font-style: normal;
        line-height: 1;
        letter-spacing: -0.9px;
        text-align: center;
        color: #000000;
    }

    .search-itemDto-right {
        display: inline-block;
        height: 100%;
        vertical-align: middle
    }

    .search-itemDto-right img {
        height: 14px;
        width: 14px;
        vertical-align: middle;
        margin-top: 26px;
        cursor: pointer;
        padding-right: 20px;
    }

    input#query {
        padding: 15px;
        width: 290px;
        border-radius: 2px;
        background-color: #e9ecef;
        border: none;

        background-image: url('../images/icon-search.png');
        background-repeat: no-repeat;
        background-position: right 10px center;
        background-size: 20px 20px;
    }

    input#query::placeholder {
        padding: 15px;
    }

    button {
        color: white;
        border-radius: 4px;
        border-radius: none;
    }

    .popup-container {
        display: none;
        position: fixed;
        top: 0;
        left: 0;
        bottom: 0;
        right: 0;
        background-color: rgba(0, 0, 0, 0.5);
        align-items: center;
        justify-content: center;
    }

    .popup-container.active {
        display: flex;
    }

    .popup {
        position: absolute;
        bottom: 0px;
        width: 333px;
        border-radius: 11px 11px 0px 0px;
    }

    .popup input {
        width: 278px !important;
        height: 39px;
        border-radius: 2px;
        border: solid 1.1px #dee2e6;
        margin-right: 9px;
        margin-bottom: 10px;
        padding-left: 10px;
    }

    .popup p {
        font-size: 14px;
    }

    .popup button.close {
        position: absolute;
        top: 15px;
        right: 15px;
        color: #adb5bd;
        background-color: #fff;
        font-size: 19px;
        border: none;
    }

    .popup button.cta {
        width: 319px;
        height: 43.9px;
        border-radius: 2px;
        background-color: #15aabf;
        border: none;
    }

    .popup button.cta2 {
        width: 302.1px;
        height: 43.9px;
        border-radius: 2px;
        background-color: #15aabf;
        border: none;
    }

    #search-area, #see-area {
        width: 330px;
        margin: auto;
    }

    .nav {
        width: 330px;
        margin: 30px auto;
        display: flex;
        align-items: center;
        justify-content: space-around;
    }

    .nav div {
        cursor: pointer;
    }

    .nav div.active {
        font-weight: 700;
    }

    .header {
        height: 255px;
        box-sizing: border-box;
        background-color: #15aabf;
        color: white;
        text-align: center;
        padding-top: 80px;
        /*padding: 50px;*/
        font-size: 45px;
        font-weight: bold;
    }

    /*#product-container {*/
    /*    grid-template-columns: 100px 50px 100px;*/
    /*    grid-template-rows: 80px auto 80px;*/
    /*    column-gap: 10px;*/
    /*    row-gap: 15px;*/
    /*}*/

    /*.product-card {*/
    /*    width: 300px;*/
    /*    margin: auto;*/
    /*    cursor: pointer;*/
    /*}*/

    /*.product-card .card-header {*/
    /*    width: 300px;*/
    /*}*/

    /*.product-card .card-header img {*/
    /*    width: 300px;*/
    /*}*/

    /*.product-card .card-body {*/
    /*    margin-top: 15px;*/
    /*}*/

    /*.product-card .card-body .title {*/
    /*    font-size: 15px;*/
    /*    font-weight: normal;*/
    /*    font-stretch: normal;*/
    /*    font-style: normal;*/
    /*    line-height: 1;*/
    /*    letter-spacing: -0.75px;*/
    /*    text-align: left;*/
    /*    color: #343a40;*/
    /*    margin-bottom: 10px;*/
    /*    overflow: hidden;*/
    /*    white-space: nowrap;*/
    /*    text-overflow: ellipsis;*/
    /*}*/

    /*.product-card .card-body .lprice {*/
    /*    font-size: 15.8px;*/
    /*    font-weight: normal;*/
    /*    font-stretch: normal;*/
    /*    font-style: normal;*/
    /*    line-height: 1;*/
    /*    letter-spacing: -0.79px;*/
    /*    color: #000000;*/
    /*    margin-bottom: 10px;*/
    /*}*/

    /*.product-card .card-body .lprice span {*/
    /*    font-size: 21.4px;*/
    /*    font-weight: 600;*/
    /*    font-stretch: normal;*/
    /*    font-style: normal;*/
    /*    line-height: 1;*/
    /*    letter-spacing: -0.43px;*/
    /*    text-align: left;*/
    /*    color: #E8344E;*/
    /*}*/

    /*.product-card .card-body .isgood {*/
    /*    margin-top: 10px;*/
    /*    padding: 10px 20px;*/
    /*    color: white;*/
    /*    border-radius: 2.6px;*/
    /*    background-color: #ff8787;*/
    /*    width: 48px;*/
    /*}*/

    .none {
        display: none;
    }

    input#kakao-login {
        padding: 15px;
        width: 526px;
        border-radius: 2px;
        background-color: #e9ecef;
        border: none;

        background-repeat: no-repeat;
        background-position: right 10px center;
        background-size: 20px 20px;
    }
}

.autocomplete {
    /*the container must be positioned relative:*/
    position: relative;
    display: inline-block;
}

input {
    border: 1px solid transparent;
    background-color: #f1f1f1;
    padding: 10px;
    font-size: 16px;
}

input[type=text] {
    background-color: #f1f1f1;
    /*width: 100%;*/
}

input[type=password] {
    background-color: #f1f1f1;
    /*width: 100%;*/
}
input[type=submit] {
    background-color: DodgerBlue;
    color: #fff;
}
.autocomplete-items {
    position: absolute;
    border: 1px solid #d4d4d4;
    border-bottom: none;
    border-top: none;
    z-index: 99;
    /*position the autocomplete items to be the same width as the container:*/
    top: 100%;
    left: 0;
    right: 0;
}
.autocomplete-items div {
    padding: 10px;
    cursor: pointer;
    background-color: #fff;
    border-bottom: 1px solid #d4d4d4;
}
.autocomplete-items div:hover {
    /*when hovering an item:*/
    background-color: #e9e9e9;
}
.autocomplete-active {
    /*when navigating through the items using the arrow keys:*/
    background-color: DodgerBlue !important;
    color: #ffffff;
}

.alert-danger {
    color: #721c24;
    background-color: #f8d7da;
    border-color: #f5c6cb;
}

.alert {
    width: 300px;
    margin-top: 22px;
    padding: 1.75rem 1.25rem;
    border: 1px solid transparent;
    border-radius: .25rem;
}

 

[basic.js]

더보기
let targetId;

$(document).ready(function () {
    // cookie 여부 확인하여 로그인 확인
    const auth = getToken();


    // 처음 로딩 시 사용자 정보 가져오기 (이름 및 폴더)
    if(auth !== '') {
        $.ajax({
            type: 'GET',
            url: `/api/user-folder`,
            beforeSend: function (xhr) {
                xhr.setRequestHeader("Authorization", auth);
            },
        }).done(function (fragment) {
            // console.log(fragment);
            $('#fragment').replaceWith(fragment);
        });
    }

    if(auth !== '') {
        $('#username').text('수강생');
        $('#login-true').show();
        $('#login-false').hide();
    } else {
        $('#login-false').show();
        $('#login-true').hide();
    }

    // id 가 query 인 녀석 위에서 엔터를 누르면 execSearch() 함수를 실행하라는 뜻입니다.
    $('#query').on('keypress', function (e) {
        if (e.key == 'Enter') {
            execSearch();
        }
    });
    $('#close').on('click', function () {
        $('#container').removeClass('active');
    })
    $('#close2').on('click', function () {
        $('#container2').removeClass('active');
    })
    $('.nav div.nav-see').on('click', function () {
        $('div.nav-see').addClass('active');
        $('div.nav-search').removeClass('active');

        $('#see-area').show();
        $('#search-area').hide();
    })
    $('.nav div.nav-search').on('click', function () {
        $('div.nav-see').removeClass('active');
        $('div.nav-search').addClass('active');

        $('#see-area').hide();
        $('#search-area').show();
    })

    $('#see-area').show();
    $('#search-area').hide();

    showProduct();
})


function numberWithCommas(x) {
    return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

function execSearch() {
    /**
     * 검색어 input id: query
     * 검색결과 목록: #search-result-box
     * 검색결과 HTML 만드는 함수: addHTML
     */
        // 1. 검색창의 입력값을 가져온다.
    let query = $('#query').val();

    // 2. 검색창 입력값을 검사하고, 입력하지 않았을 경우 focus.
    if (query == '') {
        alert('검색어를 입력해주세요');
        $('#query').focus();
        return;
    }
    // 3. GET /api/search?query=${query} 요청
    $.ajax({
        type: 'GET',
        url: `/api/search?query=${query}`,
        success: function (response) {
            $('#search-result-box').empty();
            // 4. for 문마다 itemDto를 꺼내서 HTML 만들고 검색결과 목록에 붙이기!
            for (let i = 0; i < response.length; i++) {
                let itemDto = response[i];
                let tempHtml = addHTML(itemDto);
                $('#search-result-box').append(tempHtml);
            }
        }
    })

}

function addHTML(itemDto) {
    /**
     * class="search-itemDto" 인 녀석에서
     * image, title, lprice, addProduct 활용하기
     * 참고) onclick='addProduct(${JSON.stringify(itemDto)})'
     */
    return `<div class="search-itemDto">
        <div class="search-itemDto-left">
            <img src="${itemDto.image}" alt="">
        </div>
        <div class="search-itemDto-center">
            <div>${itemDto.title}</div>
            <div class="price">
                ${numberWithCommas(itemDto.lprice)}
                <span class="unit">원</span>
            </div>
        </div>
        <div class="search-itemDto-right">
            <img src="../images/icon-save.png" alt="" onclick='addProduct(${JSON.stringify(itemDto)})'>
        </div>
    </div>`
}

function addProduct(itemDto) {
    /**
     * modal 뜨게 하는 법: $('#container').addClass('active');
     * data를 ajax로 전달할 때는 두 가지가 매우 중요
     * 1. contentType: "application/json",
     * 2. data: JSON.stringify(itemDto),
     */

    const auth = getToken();

    // 1. POST /api/products 에 관심 상품 생성 요청
    $.ajax({
        type: "POST",
        url: '/api/products',
        contentType: "application/json",
        data: JSON.stringify(itemDto),
        beforeSend: function (xhr) {
            xhr.setRequestHeader("Authorization", auth);
        },
        success: function (response) {
            // 2. 응답 함수에서 modal을 뜨게 하고, targetId 를 reponse.id 로 설정
            $('#container').addClass('active');
            targetId = response.id;
        }
    })
}

function showProduct(folderId = null) {
    /**
     * 관심상품 목록: #product-container
     * 검색결과 목록: #search-result-box
     * 관심상품 HTML 만드는 함수: addProductItem
     */
    const auth = getToken();

    let dataSource = null;

    var sorting = $("#sorting option:selected").val();
    var isAsc = $(':radio[name="isAsc"]:checked').val();

    // folder 기능 추가
    if (folderId) {
        dataSource = `/api/folders/${folderId}/products?sortBy=${sorting}&isAsc=${isAsc}`;
    } else {
        dataSource = `/api/products?sortBy=${sorting}&isAsc=${isAsc}&folderId=${folderId}`;
    }

    $('#product-container').empty();
    $('#search-result-box').empty();
    $('#pagination').pagination({
        dataSource,
        locator: 'content',
        alias: {
            pageNumber: 'page',
            pageSize: 'size'
        },
        totalNumberLocator: (response) => {
            return response.totalElements;
        },
        pageSize: 10,
        showPrevious: true,
        showNext: true,
        ajax: {
            beforeSend: function(xhr) {
                xhr.setRequestHeader("Authorization", auth);
                $('#product-container').html('상품 불러오는 중...');
            }
        },
        callback: function(data, pagination) {
            console.log(data);
            $('#product-container').empty();
            for (let i = 0; i < data.length; i++) {
                let product = data[i];
                let tempHtml = addProductItem(product);
                $('#product-container').append(tempHtml);
            }
        }
    });
}

// Folder 관련 기능
function openFolder(folderId) {
    $("button.product-folder").removeClass("folder-active");
    if (!folderId) {
        $("button#folder-all").addClass('folder-active');
    } else {
        $(`button[value='${folderId}']`).addClass('folder-active');
    }
    showProduct(folderId);
}
// 폴더 추가 팝업
function openAddFolderPopup() {
    $('#container2').addClass('active');
}
// 폴더 Input 추가
function addFolderInput() {
    $('#folders-input').append(
        `<input type="text" class="folderToAdd" placeholder="추가할 폴더명">
       <span onclick="closeFolderInput(this)" style="margin-right:5px">
            <svg xmlns="http://www.w3.org/2000/svg" width="30px" fill="red" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
              <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
            </svg>
       </span>
      `
    );
}
function closeFolderInput(folder) {
    $(folder).prev().remove();
    $(folder).next().remove();
    $(folder).remove();
}
function addFolder() {
    const auth = getToken();
    const folderNames = $('.folderToAdd').toArray().map(input => input.value);
    try {
        folderNames.forEach(name => {
            if (name === '') {
                alert('올바른 폴더명을 입력해주세요');
                throw new Error("stop loop");
            }
        });
    }catch (e) {
        console.log(e);
        return;
    }
    console.log('folderNames',folderNames);
    $.ajax({
        type: "POST",
        url: `/api/folders`,
        contentType: "application/json",
        data: JSON.stringify({
            folderNames
        }),
        beforeSend: function(xhr) {
            xhr.setRequestHeader("Authorization", auth);
        },
        success: function (response) {
            $('#container2').removeClass('active');
            alert('성공적으로 등록되었습니다.');
            window.location.reload();
        }
    })
}

function addProductItem(product) {
    const folders = product.folderList.map(folder =>
        `
            <span onclick="openFolder(${folder.id})">
                #${folder.name}
            </span>       
        `
    );
    return `<div class="product-card">
                <div onclick="window.location.href='${product.link}'">
                    <div class="card-header">
                        <img src="${product.image}"
                             alt="">
                    </div>
                    <div class="card-body">
                        <div class="title">
                            ${product.title}
                        </div>
                        <div class="lprice">
                            <span>${numberWithCommas(product.lprice)}</span>원
                        </div>
                        <div class="isgood ${product.lprice > product.myprice ? 'none' : ''}">
                            최저가
                        </div>
                    </div>
                </div>
                <div class="product-tags" style="margin-bottom: 20px;">
                    ${folders}
                    <span onclick="addInputForProductToFolder(${product.id}, this)">
                        <svg xmlns="http://www.w3.org/2000/svg" width="30px" fill="currentColor" class="bi bi-folder-plus" viewBox="0 0 16 16">
                            <path d="M.5 3l.04.87a1.99 1.99 0 0 0-.342 1.311l.637 7A2 2 0 0 0 2.826 14H9v-1H2.826a1 1 0 0 1-.995-.91l-.637-7A1 1 0 0 1 2.19 4h11.62a1 1 0 0 1 .996 1.09L14.54 8h1.005l.256-2.819A2 2 0 0 0 13.81 3H9.828a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 6.172 1H2.5a2 2 0 0 0-2 2zm5.672-1a1 1 0 0 1 .707.293L7.586 3H2.19c-.24 0-.47.042-.684.12L1.5 2.98a1 1 0 0 1 1-.98h3.672z"/>
                            <path d="M13.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H14v1.5a.5.5 0 0 1-1 0V13h-1.5a.5.5 0 0 1 0-1H13v-1.5a.5.5 0 0 1 .5-.5z"/>
                        </svg>
                    </span>
                </div>
            </div>`;
}

function addInputForProductToFolder(productId, button) {
    const auth = getToken();
    $.ajax({
        type: 'GET',
        url: `/api/folders`,
        beforeSend: function(xhr) {
            xhr.setRequestHeader("Authorization", auth);
        },
        success: function (folders) {
            const options = folders.map(folder => `<option value="${folder.id}">${folder.name}</option>`)
            const form = `
                <span>
                    <form id="folder-select" method="post" autocomplete="off" action="/api/products/${productId}/folder">
                        <select name="folderId" form="folder-select">
                            ${options}
                        </select>
                        <input type="submit" value="추가" style="padding: 5px; font-size: 12px; margin-left: 5px;">
                    </form>
                </span>
            `;
            $(form).insertBefore(button);
            $(button).remove();
            $("#folder-select").on('submit', function(e) {
                e.preventDefault();
                $.ajax({
                    type: $(this).prop('method'),
                    url : $(this).prop('action'),
                    data: $(this).serialize(),
                    beforeSend: function(xhr) {
                        xhr.setRequestHeader("Authorization", auth);
                    },
                }).done(function() {
                    alert('성공적으로 등록되었습니다.');
                    window.location.reload();
                });
            });
        }
    });
}

function addProductToFolder() {

}


function setMyprice() {
    /**
     * 1. id가 myprice 인 input 태그에서 값을 가져온다.
     * 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
     * 3. PUT /api/product/${targetId} 에 data를 전달한다.
     *    주의) contentType: "application/json",
     *         data: JSON.stringify({myprice: myprice}),
     *         빠뜨리지 말 것!
     * 4. 모달을 종료한다. $('#container').removeClass('active');
     * 5, 성공적으로 등록되었음을 알리는 alert를 띄운다.
     * 6. 창을 새로고침한다. window.location.reload();
     */
        // 1. id가 myprice 인 input 태그에서 값을 가져온다.
    let myprice = $('#myprice').val();
    // 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
    if (myprice == '') {
        alert('올바른 가격을 입력해주세요');
        return;
    }
    const auth = getToken();

    // 3. PUT /api/product/${targetId} 에 data를 전달한다.
    $.ajax({
        type: "PUT",
        url: `/api/products/${targetId}`,
        contentType: "application/json",
        data: JSON.stringify({myprice: myprice}),
        beforeSend: function (xhr) {
            xhr.setRequestHeader("Authorization", auth);
        },
        success: function (response) {
            // 4. 모달을 종료한다. $('#container').removeClass('active');
            $('#container').removeClass('active');
            // 5. 성공적으로 등록되었음을 알리는 alert를 띄운다.
            alert('성공적으로 등록되었습니다.');
            // 6. 창을 새로고침한다. window.location.reload();
            window.location.reload();
        }
    })
}

function logout() {
    // 토큰 값 ''으로 덮어쓰기
    document.cookie =
        'Authorization' + '=' + '' + ';path=/';
    window.location.reload();
}

function  getToken() {
    let cName = 'Authorization' + '=';
    let cookieData = document.cookie;
    let cookie = cookieData.indexOf('Authorization');
    let auth = '';
    if(cookie !== -1){
        cookie += cName.length;
        let end = cookieData.indexOf(';', cookie);
        if(end === -1)end = cookieData.length;
        auth = cookieData.substring(cookie, end);
    }
    return auth;
}

 

<결과>

폴더가 생성 된 것을 알 수 있다.


 

폴더 설계

 

📌 폴더 테이블 설계

  • 폴더 테이블에 필요한 정보
    1. 폴더명: 회원이 등록한 폴더 이름을 저장
    2. 회원ID: 폴더를 등록한 회원의 ID 를 저장
      • A 회원이 생성한 폴더는 A 회원에게만 보여야 함
      • 회원과 폴더의 관계
        • '회원과 폴더'의 관계는 '회원과 관심상품' 관계와 동일

 

📌 기존 관계 설정 방법의 문제점 ('회원과 폴더')

  • 기존 관계설정 방법으로 구현한 Folder 객체
@Entity
public class Folder {
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Id
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Long userId;
}
  • 폴더가 가지고 있는 userId 가 "회원의 Id 값"이라는 사실은 "개발을 진행한 사람"만 알 수 있음
  • 객체 (Folder Entity 클래스)와 DB 모두에서 회원과 상품이 어떤 관계인지를 알 수 없음
  • 객체) 회원과 폴더의 관계

  • DB) 회원과 폴더의 관계

  • 회원 → 폴더 조회

1. user1 이 저장한 모든 폴더 조회

// 1. 로그인한 회원 (user1) 의 id 를 조회
Long userId = user1.getId();
// 2. userId 로 저장된 모든 folder 조회
List<Folder> folders = folderRepository.findAllByUserId(userId);

2. 이를 위해, Folder Repository 에 userId 를 기준으로 조회하는 함수 생성 필요

public interface FolderRepository extends JpaRepository<Folder, Long> {
    List<Folder> findAllByUserId(Long userId);
}
  • 폴더 → 회원 조회
        folder1 의 userId 로 회원 조회
// 1. folder1 의 userId 를 조회
Long userId = folder1.getUserId();
// 2. userId 로 저장된 회원 조회
User user = userRepository.findById(userId);

 


 

 JPA 연관관계를 이용한 폴더 테이블 설계

 

📌 회원 Entity 관점

"@OneToMany" : 회원 1명은 여러 개의 폴더를 가질 수 있다.

 

📌 폴더 Entity 관점

"@ManyToOne" : 폴더 여러개는 회원 1명이 가질 수 있다.

 

📌객체의 관계를 맺어주면, DB 의 관계 설정 맺어줌

  • 객체) 회원과 폴더의 관계
    폴더를 소유한 회원 id 가 아닌 객체를 저장

  • DB) 회원과 폴더의 관계
    외래키를 통한 관계 형성

 

📌JPA 연관관계 Column 설정방법

 


 

폴더 생성 및 조회 구현

📌 API

 

📌 Server 구현

 

1. 회원의 폴더 생성

 

[User] 수정

폴더 객체 생성 추가

더보기
@OneToMany
List<Folder> folders = new ArrayList<>();

[Folder] 생성

폴더가 생성 될 때 

더보기
package com.sparta.myselectshop.entity;

import lombok.Getter;
import lombok.NoArgsConstructor;

import jakarta.persistence.*;

@Getter
@Entity
@NoArgsConstructor
public class Folder {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column(nullable = false)
    private String name;

    @ManyToOne
    @JoinColumn(name = "USER_ID", nullable = false)
    private User user;

    public Folder(String name, User user) {
        this.name = name;
        this.user = user;
    }
}

[FolderRequestDto] 생성

폴더 이름들을 Folder 안의 형식으로 리스트로 생성

더보기
package com.sparta.myselectshop.dto;

import lombok.Getter;

import java.util.List;

@Getter
public class FolderRequestDto {
    List<String> folderNames;
}

[FolderController] 생성

폴더의 이름을 FolderRequestDto를 통해 리스트로 받아 오는 메소드를 구현

로그인이 되어야만 폴더 생성이 되므로 로그인이 되었는지 request 가져옴

이 두개를 folderService로 넘겨줌

더보기
package com.sparta.myselectshop.controller;

import com.sparta.myselectshop.dto.FolderRequestDto;
import com.sparta.myselectshop.entity.Folder;
import com.sparta.myselectshop.service.FolderService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletRequest;
import java.util.List;

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class FolderController {

    private final FolderService folderService;

    @PostMapping("/folders")
    public List<Folder> addFolders(
            @RequestBody FolderRequestDto folderRequestDto,
            HttpServletRequest request
    ) {

        List<String> folderNames = folderRequestDto.getFolderNames();

        return folderService.addFolders(folderNames, request);
    }
}

[FolderService] 생성

Token을 검증해 유저가 있는지 확인하고 로그인유저만 폴더를 생성하게 만들어줌

한 유저가 여러개의 폴더를 가질 수 있으므로 folderList를 구현

더보기
package com.sparta.myselectshop.service;

import com.sparta.myselectshop.entity.Folder;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.jwt.JwtUtil;
import com.sparta.myselectshop.repository.FolderRepository;
import com.sparta.myselectshop.repository.UserRepository;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import jakarta.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
public class FolderService {

    private final FolderRepository folderRepository;
    private final UserRepository userRepository;
    private final JwtUtil jwtUtil;


    // 로그인한 회원에 폴더들 등록
    @Transactional
    public List<Folder> addFolders(List<String> folderNames, HttpServletRequest request) {

        // Request에서 Token 가져오기
        String token = jwtUtil.resolveToken(request);
        Claims claims;

        // 토큰이 있는 경우에만 관심상품 조회 가능
        if (token != null) {

            // Token 검증
            if (jwtUtil.validateToken(token)) {
                // 토큰에서 사용자 정보 가져오기
                claims = jwtUtil.getUserInfoFromToken(token);
            } else {
                throw new IllegalArgumentException("Token Error");
            }

            // 토큰에서 가져온 사용자 정보를 사용하여 DB 조회
            User user = userRepository.findByUsername(claims.getSubject()).orElseThrow(
                    () -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
            );

            List<Folder> folderList = new ArrayList<>();

            for (String folderName : folderNames) {
                Folder folder = new Folder(folderName, user);
                folderList.add(folder);
            }

            return folderRepository.saveAll(folderList);
        } else {
            return null;
        }
    }

    // 로그인한 회원이 등록된 모든 폴더 조회
    public List<Folder> getFolders(HttpServletRequest request) {

        // 사용자의 정보를 가져온다
        // Request에서 Token 가져오기
        String token = jwtUtil.resolveToken(request);
        Claims claims;

        // 토큰이 있는 경우에만 관심상품 조회 가능
        if (token != null) {
            // Token 검증
            if (jwtUtil.validateToken(token)) {
                // 토큰에서 사용자 정보 가져오기
                claims = jwtUtil.getUserInfoFromToken(token);
            } else {
                throw new IllegalArgumentException("Token Error");
            }

            // 토큰에서 가져온 사용자 정보를 사용하여 DB 조회
            User user = userRepository.findByUsername(claims.getSubject()).orElseThrow(
                    () -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
            );

            return folderRepository.findAllByUser(user);

        } else {
            return null;
        }
    }

}

[FolderRepository] 생성

DB 연결

더보기
package com.sparta.myselectshop.repository;

import com.sparta.myselectshop.entity.Folder;
import com.sparta.myselectshop.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface FolderRepository extends JpaRepository<Folder, Long> {

    List<Folder> findAllByUser(User user);
}

 

2. 회원이 저장한 폴더 조회

 

[ShopController]

FolderService 객체 추가해주고 getUserInfo() 메소드 추가

로그인 여부를 위해 request 가져오고, model 객체를 가져온 것은 지난 JWT 강의를 떠올리면 된다. 우리는 Server 사이드 렌더링으로 되어있기 때문에 백엔드에서 HTML을 다 만들어준 다음에 Thymleaf를 사용해서 Client에 완성된 HTML을 반환한다. 그래서 이미 보내져있는 HTML에 있는 폴더 부분을 바꿔줘야 해서 만든 메소드이다. 

return "/index :: #fragment";는 비동기적으로 index.html folder의 한 부분을 바꿔주는 것이라고 생각하면 된다.

(return 부분은 Thymleaf에서 ajax로 비동기로 데이터를 처리하는 방법을 구글링하면 더 많은 정보를 알 수 있다)

더보기
private final FolderService folderService;
// 로그인 한 유저가 메인페이지를 요청할 때 가지고있는 폴더를 반환
@GetMapping("/user-folder")
public String getUserInfo(Model model, HttpServletRequest request) {

    model.addAttribute("folders", folderService.getFolders(request));

    return "/index :: #fragment";
}

 


 

관심상품에 폴더 추가 구현

 

 

📌 폴더와 상품의 연관 관계

  • 폴더 하나에 여러 개의 상품을 저장 할 수 있다.
  • 상품 하나에 여러 개의 폴더에 저장 할 수 있다.

    👉👉 상품 : 폴더 = N : N (다대다 관계)

 

📌 API

실제로 구현할 때는 전체 조회 때 user가 보이지 않도록, 필요한 정보만 보일 수 있도록 해야한다

 

📌 Server 구현

 

[FolderController] 수정

로그인을 했는지 안 했는지 확인 되어야하므로 request 객체를 보낸다.

더보기
// 회원이 등록한 모든 폴더 조회
@GetMapping("/folders")
public List<Folder> getFolders(
        HttpServletRequest request
) {
    return folderService.getFolders(request);
}

[ProductController] 수정

더보기
// 상품에 폴더 추가
@PostMapping("/products/{productId}/folder")
public Long addFolder(
        @PathVariable Long productId,
        @RequestParam Long folderId,
        HttpServletRequest request
) {
    Product product = productService.addFolder(productId, folderId, request);
    return product.getId();
}

[ProductService] 수정

더보기
private final FolderRepository folderRepository;
@Transactional
public Product addFolder(Long productId, Long folderId, HttpServletRequest request) {
    // Request에서 Token 가져오기
    String token = jwtUtil.resolveToken(request);
    Claims claims;

    // 토큰이 있는 경우에만 관심상품 최저가 업데이트 가능
    if (token != null) {
        // Token 검증
        if (jwtUtil.validateToken(token)) {
            // 토큰에서 사용자 정보 가져오기
            claims = jwtUtil.getUserInfoFromToken(token);
        } else {
            throw new IllegalArgumentException("Token Error");
        }

        // 토큰에서 가져온 사용자 정보를 사용하여 DB 조회
        User user = userRepository.findByUsername(claims.getSubject()).orElseThrow(
                () -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
        );

        // 1) 관심상품을 조회합니다.
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new NullPointerException("해당 상품 아이디가 존재하지 않습니다."));

        // 2) 폴더를 조회합니다.
        Folder folder = folderRepository.findById(folderId)
                .orElseThrow(() -> new NullPointerException("해당 폴더 아이디가 존재하지 않습니다."));

        // 3) 조회한 폴더와 관심상품이 모두 로그인한 회원의 소유인지 확인합니다.
        Long loginUserId = user.getId();
        if (!product.getUserId().equals(loginUserId) || !folder.getUser().getId().equals(loginUserId)) {
            throw new IllegalArgumentException("회원님의 관심상품이 아니거나, 회원님의 폴더가 아닙니다~^^");
        }

        // 4) 상품에 폴더를 추가합니다.
        product.addFolder(folder);

        return product;
    } else {
        return null;
    }
}

 

[Product] 수정

@ManyToMany : Product에서만 단방향으로 걸려있다.

더보기
@ManyToMany
private List<Folder> folderList = new ArrayList<>();
public void addFolder(Folder folder) {
    this.folderList.add(folder);
}

내가 entity class로 만들지 않은 table이 생성된 것을 알 수 있다.

h2 콘솔에서도 만들어져 있는 것을 확인 할 수 있다. RUN 해보면 값이 2개가 들어가 있는 것도 확인 가능하다. 이처럼 ManyToMany를 걸게 되면 다대다 관계를 풀어주기 위해 JPA가 중간 테이블을 스스로 만들어 내는 것을 확인할 수 있다. 편리한 부분이 있긴하지만 연결 테이블이 단순 연결뿐만 아니라 추가적인 쿼리가 JPA에 의해 자동으로 들어갈 수도 있고, alter 되지도 않기 때문에 오류가 날 수 있다. (JPA 기초에서 Food, Order, Member와 관련해서 JPA 코드 쓴 것을 다시 보자!)

 

 


 

폴더 별 관심 상품 조회 구현

 

📌 요구사항

  • 회원은 폴더 별로 분류한 상품을 볼 수 있다.
  • 폴더별 : 폴더별 저장된 관심상품들을 조회 가능하다.
  • 전체 : 폴더와 상관 없이 회원이 저장한 전체 관심상품들을 조회 가능하다.

 

📌 API

 

📌 Server 구현

 

[FolderController] 수정

더보기
// 회원이 등록한 폴더 내 모든 상품 조회
@GetMapping("/folders/{folderId}/products")
public Page<Product> getProductsInFolder(
        @PathVariable Long folderId,
        @RequestParam int page,
        @RequestParam int size,
        @RequestParam String sortBy,
        @RequestParam boolean isAsc,
        HttpServletRequest request
) {
    return folderService.getProductsInFolder(
            folderId,
            page-1,
            size,
            sortBy,
            isAsc,
            request
    );
}

[FolderService] 수정

더보기
private final ProductRepository productRepository;
@Transactional(readOnly = true)
public Page<Product> getProductsInFolder(Long folderId, int page, int size, String sortBy, boolean isAsc, HttpServletRequest request) {

    // 페이징 처리
    Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
    Sort sort = Sort.by(direction, sortBy);
    Pageable pageable = PageRequest.of(page, size, sort);

    // Request에서 Token 가져오기
    String token = jwtUtil.resolveToken(request);
    Claims claims;

    // 토큰이 있는 경우에만 관심상품 조회 가능
    if (token != null) {
        // Token 검증
        if (jwtUtil.validateToken(token)) {
            // 토큰에서 사용자 정보 가져오기
            claims = jwtUtil.getUserInfoFromToken(token);
        } else {
            throw new IllegalArgumentException("Token Error");
        }

        // 토큰에서 가져온 사용자 정보를 사용하여 DB 조회
        User user = userRepository.findByUsername(claims.getSubject()).orElseThrow(
                () -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
        );

        return productRepository.findAllByUserIdAndFolderList_Id(user.getId(), folderId, pageable);

    } else {
        return null;
    }
}

[ProductRepository] 수정

더보기
Page<Product> findAllByUserIdAndFolderList_Id(Long userId, Long folderId, Pageable pageable);

 

<결과>

폴더를 여러개 생성할 수 있고, 원하는 폴더에 관심 상품을 등록해서 폴더를 확인하면 잘 들어가 있는 것을 확인 할 수 있다. 

 

 


오늘의 스터디

 

HashSet은 List로만 만들수 있다! 오케이! 이해 완!!!

중복 없애고 교집합으로 구할 수 있다.

 

조원 A님 질문에 조원 B님의 추천 URL

 

코딩교육 티씨피스쿨

4차산업혁명, 코딩교육, 소프트웨어교육, 코딩기초, SW코딩, 기초코딩부터 자바 파이썬 등

tcpschool.com

 

  • result = 문자 - '자연수' 를하게 된다면 result 는 integer 값을 얻을 수 있다.

 

프로젝트

 

좋아요 구현을 맡게 되었다. 열심히 해보자!!!

 

 

📌 스프링 시큐리티 토큰?

원래는 쿠키로 값을 넘겨줬는데 이렇게 되면 보안이 취약해져서 토큰으로 만료시간을 설정하여 보완시켜 준 것이다. 

 

 

'Sparta > What I Learned' 카테고리의 다른 글

23.1.4  (0) 2023.01.05
23.1.3  (0) 2023.01.03
23.1.1  (0) 2023.01.02
22.12.30  (2) 2022.12.30
22.12.29  (0) 2022.12.29