이번시간에는 찜기능과 페이징을 이용해 찜한 가게의 목록을 보여주는 페이지를 구현해보도록
하겠습니다. 일단 찜정보를 저장할 테이블을 하나 생성해주도록 하겠습니다
-- 찜하기 테이블
CREATE TABLE DL_LIKES (
USER_ID NUMBER,
STORE_ID NUMBER,
LIKES_DATE TIMESTAMP DEFAULT SYSDATE
);
ALTER TABLE DL_LIKES
ADD CONSTRAINT LIKES_USER_ID
FOREIGN KEY (USER_ID)
REFERENCES DL_USER(ID)
on delete cascade;
ALTER TABLE DL_LIKES
ADD CONSTRAINT LIKES_STORE_ID
FOREIGN KEY (STORE_ID)
REFERENCES DL_STORE(ID)
on delete cascade;
찜테이블에는 별다른 데이터가 필요하지 않습니다 단순히 유저번호와 가게번호만 저장합니다
//찜하기
@PostMapping("/api/store/likes")
public ResponseEntity<String> likes(long id, String likes, @AuthenticationPrincipal CustomUserDetails principal) {
if(principal == null) {
return ResponseEntity.badRequest().body("회원만 가능합니다");
}
long userId = principal.getId();
storeService.likes(id, likes, userId);
return ResponseEntity.ok().body("완료");
}
사용자가 찜버튼 클릭시 화면에서는 찜버튼으로 바뀜과 동시에 찜갯수도 올라가야 합니다
사용자가 찜해제 버튼 클릭시 위와 반대로 화면에 보여줘야 합니다. 즉 페이지를 다시 로딩하지않고
변화를 보여줘야되기 때문에 ajax를 이용하여 비동기 방식으로 구현하기 위해 apiController에
코드를 추가합니다. 현재 사용자가 로그인하지 않았다면 body에 메시지를 넣어 badRequest를
응답하여 body에 심어준 메시지를 경고창에 띄우도록 해주겠습니다
프론트에서 넘어온 likes에 담겨진 문자열을 통해 현재 찜상태인지 아닌지를 판단할겁니다
@Transactional
public void likes(long storeId, String likes, long userId) {
Map<String, Long> map = new HashMap<>();
map.put("storeId", storeId);
map.put("userId", userId);
if(likes.equals("on")) {
storeMapper.addLikes(map);
} else {
storeMapper.deleteLikes(map);
}
}
likes가 on일경우 찜하기 버튼을 클릭한 경우이므로 insert를
likes가 그외의 경우 찜해제 버튼을 클릭한 경우이므로 delete를 해주도록 하겠습니다
//찜하기
public void addLikes(Map<String, Long> map);
//찜하기 해제
public void deleteLikes(Map<String, Long> map);
<insert id="addLikes">
INSERT INTO DL_LIKES (
USER_ID
,STORE_ID
) VALUES (
#{userId }
,#{storeId }
)
</insert>
<delete id="deleteLikes">
DELETE DL_LIKES WHERE
USER_ID = #{userId } AND
STORE_ID = #{storeId }
</delete>
매장의 상세화면에 가서 찜버튼을 클릭하면 정상적으로 테이블에 데이터가 추가/삭제 되는것을
볼수 있습니다. 하지만 현재 매장상세정보를 가져올때 찜상태에 대한것은 추가하지 않았기에
찜버튼을 클릭하고 나서 새로고침 할경우 찜버튼이 제대로 표시되지 않습니다
따라서 기존 매장상세정보를 가져올때 현재 로그인된 사용자의 입장에서 찜을 한 가게인지
아닌지를 판단하여 화면에 찜ON상태 또는 찜OFF상태를 보여줘야 합니다
모든 레이어에서 기존 storeDetail에 userId를 추가해주도록 합시다
@GetMapping("/store/{id}/detail")
public String storeDetail(@PathVariable long id, Model model, @AuthenticationPrincipal CustomUserDetails principal ) {
long userId = 0;
if(principal != null) {
userId = principal.getId();
}
StoreDetailDto storeDetailDto = storeService.storeDetail(id, userId);
System.out.println(storeDetailDto.getStoreInfo().getIsLikes());
model.addAttribute("store", storeDetailDto);
return "store/detail";
}
public StoreDetailDto storeDetail(long storeId, long userId) {
Map<String, Long> map = new HashMap<>();
map.put("storeId", storeId);
map.put("userId", userId);
StoreDto storeDto = storeMapper.storeDetail(map);
List<FoodDto> foodList = storeMapper.foodList(storeId);
List<ReviewDto> reviewList = storeMapper.reviewList(storeId);
return new StoreDetailDto(storeDto, foodList, reviewList);
}
public StoreDto storeDetail(Map<String, Long> map);
<select id="storeDetail" resultType="com.han.delivery.dto.StoreDto">
SELECT RESULT.*
,CASE WHEN TO_CHAR(SYSDATE,'HH24') BETWEEN OPENING_TIME AND CLOSING_TIME THEN 'true' ELSE 'false' END IS_OPEN, L.IS_LIKES
FROM (SELECT S.*,
C.*
FROM DL_STORE S
,(SELECT * FROM
(SELECT ROUND(AVG(SCORE),1) SCORE
,COUNT(REVIEW_CONTENT) REVIEW_COUNT
,COUNT(BOSS_COMMENT) BOSS_COMMENT_COUNT
,COUNT(CASE WHEN SCORE=1 THEN 1 END) SCORE1
,COUNT(CASE WHEN SCORE=2 THEN 1 END) SCORE2
,COUNT(CASE WHEN SCORE=3 THEN 1 END) SCORE3
,COUNT(CASE WHEN SCORE=4 THEN 1 END) SCORE4
,COUNT(CASE WHEN SCORE=5 THEN 1 END) SCORE5
FROM DL_REVIEW WHERE STORE_ID = #{storeId } )
, (SELECT COUNT(*) LIKES_COUNT FROM DL_LIKES WHERE STORE_ID = #{storeId } )
,(SELECT SUM(ORDER_COUNT) ORDER_COUNT FROM (
SELECT COUNT(*) ORDER_COUNT FROM DL_ORDER_USER WHERE STORE_ID = #{storeId }
UNION ALL
SELECT COUNT(*) ORDER_COUNT FROM DL_ORDER_NON_USER WHERE STORE_ID = #{storeId } ))
) C
WHERE ID = #{storeId }
) RESULT
LEFT JOIN (SELECT STORE_ID, 1 IS_LIKES FROM DL_LIKES WHERE EXISTS(SELECT 1 FROM DL_LIKES WHERE USER_ID = #{userId } AND STORE_ID = #{storeId } )) L
ON RESULT.ID = L.STORE_ID
</select>
쿼리는 기존코드에서 LEFT JOIN만 추가해줬습니다. 찜테이블에서 현재 로그인된 사용자와
접근한 매장의 ID가 일치하는 컬럼이 있을경우 1, 없을경우 0을 IS_LIKES라는 이름으로
기존 매장상세페이지 데이터 칼럼에 붙여줍니다 현재 StoreDto에는 IS_LIKES 값을 넣을
변수가 존재하지 않기 때문에 하나 추가해주도록 합시다
//찜 상태
private int isLikes;
이제 화면에 찜상태를 보여주기 위한 코드를 추가하도록 하겠습니다
<div class="inf2">
<c:choose>
<c:when test="${info.isLikes == 1 }">
<span><i class="fas fa-heart" ></i> 찜 </span>
</c:when>
<c:otherwise>
<span><i class="far fa-heart" ></i> 찜 </span>
</c:otherwise>
</c:choose>
<span class="likes_count" data-count=${info.likesCount } >${info.likesCount }</span>
</div>
// 찜하기
$(".inf2 i").click(function(){
let likes ="";
if($(this).hasClass("far")) {
likes = "on";
} else {
likes = "off";
}
const data = {
id : $("#store_id").val(),
likes : likes
}
$.ajax({
url: "/api/store/likes",
type: "POST",
data: data
})
.done(function(result){
console.log(result.body);
let likesCount = $(".likes_count").data("count");
if(likes == "on") {
$(".inf2 i").removeClass("far").addClass("fas");
$(".likes_count").text(likesCount+1);
$(".likes_count").data("count", likesCount+1 );
} else {
$(".inf2 i").removeClass("fas").addClass("far");
$(".likes_count").text(likesCount-1);
$(".likes_count").data("count", likesCount-1 );
}
})
.fail(function(error){
alert(error.responseText);
})
}) // 찜
찜기능과 상세화면에서 찜표시까지 구현이 완료되었습니다 이제 마지막으로
주문목록페이지와 같이 내가 현재 찜등록을 한 모든 매장정보를 볼수 있도록
찜목록페이지를 추가하겠습니다 이 찜목록페이지에서는 매장목록에서와 똑같이
무한스크롤 비동기 페이징을 사용할겁니다. 화면에 표시할 데이터도 매장목록과 똑같습니다
단순히 기존 매장목록에서 userId를 이용하여 Likes테이블만 join하는것이므로
자세한 설명은 생략하도록 하겠습니다
// 찜한 가게 목록
@GetMapping("/likes/store")
public String likes(Model model, @AuthenticationPrincipal CustomUserDetails principal) {
if (principal == null) {
System.out.println("비로그인");
} else {
System.out.println("로그인");
List<StoreDto> likesList = storeService.likesList(principal.getId());
model.addAttribute("likesList", likesList);
}
return "/store/likes";
}
//찜목록 페이징
@GetMapping("/api/store/likesList")
public ResponseEntity<List<StoreDto>> likesStore(@AuthenticationPrincipal CustomUserDetails principal, int page) {
List<StoreDto> storeList = storeService.likesList(principal.getId(), page);
return ResponseEntity.ok().body(storeList);
}
Controller는 최초 찜목록페이지 접근시 1~10번까지의 데이터를
ApiController는 그 이후 추가적인 데이터인 11번 이후 데이터를 가져올때 사용합니다
//찜한 가게 목록 첫 접근시
public List<StoreDto> likesList(long userId) {
return likesList(userId,1);
}
//찜한 가게 목록 추가 데이터 비동기
@Transactional
public List<StoreDto> likesList(long userId, int page) {
Page p = new Page(page, 10);
Map<String, Object> map = new HashMap<>();
map.put("firstList", p.getFirstList());
map.put("lastList", p.getLastList());
map.put("userId", userId);
System.out.println("페이지 시작 = " + p.getFirstList() + " 페이지 끝 = " + p.getLastList());
return storeMapper.likesList(map);
}
//찜한 가게 목록
public List<StoreDto> likesList(Map<String, Object> map);
<select id="likesList" resultType="com.han.delivery.dto.StoreDto">
WITH R_COUNT AS (
SELECT STORE_ID
,ROUND(AVG(SCORE), 1) SCORE
,COUNT(REVIEW_CONTENT) REVIEW_COUNT
,COUNT(BOSS_COMMENT) BOSS_COMMENT_COUNT
FROM DL_REVIEW
GROUP BY STORE_ID
),
STORE AS (
SELECT S.*,
T.*,
L.USER_ID,
L.LIKES_DATE
FROM DL_STORE S
LEFT JOIN R_COUNT T
ON S.ID = T.STORE_ID
LEFT JOIN DL_LIKES L
ON S.ID = L.STORE_ID
WHERE USER_ID = #{userId})
SELECT * FROM
(SELECT ROWNUM RN,
RESULT.*
FROM
(SELECT C.*
,'true' IS_OPEN
FROM STORE C
WHERE TO_CHAR(SYSTIMESTAMP, 'HH24') BETWEEN OPENING_TIME AND CLOSING_TIME
UNION ALL
SELECT C.*
,'false' IS_OPEN
FROM STORE C
WHERE TO_CHAR(SYSTIMESTAMP, 'HH24') NOT BETWEEN OPENING_TIME AND CLOSING_TIME
) RESULT
)
WHERE RN BETWEEN #{firstList } AND ${lastList }
ORDER BY LIKES_DATE DESC
</select>
기존 매장 목록 쿼리에 DL_LIKES테이블을 join한후 LIKES_DATE순으로 정렬하는 쿼리만
추가해줬습니다 그 외 쿼리는 모두 동일합니다
이제 데이터를 가져와 화면에 뿌려주기 위해 store폴더안에 likes.jsp , likes.css, likes.js를
추가해주세요
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@ include file="/WEB-INF/views/layout/link.jsp" %>
<link rel="stylesheet" href="/css/layout/nav.css" >
<link rel="stylesheet" href="/css/store/likes.css" >
<link rel="stylesheet" href="/css/store/store-li.css">
<%@ include file="/WEB-INF/views/layout/header.jsp" %>
<div class="wrap">
<c:if test="${!empty likesList }">
<style>body {background: #fff; }</style>
<section class="title">
<h1>찜</h1>
</section>
</c:if>
<main>
<div class="box">
<c:if test="${empty likesList }">
<div class="temp"><img alt="이미지" src="/img/jjim.png"> </div>
</c:if>
<ul class="store">
<c:set var="store_admin" value="/store" />
<c:forEach items="${likesList }" var="storeList">
<%@ include file="/WEB-INF/views/store/store-li.jsp" %>
</c:forEach>
</ul>
</div>
</main>
</div>
<!-- 하단 메뉴 -->
<%@ include file="/WEB-INF/views/layout/nav.jsp" %>
<!-- 하단 메뉴 -->
<!-- 푸터 -->
<%@ include file="/WEB-INF/views/layout/footer.jsp" %>
<!-- 푸터 -->
<script type="text/javascript" src="/js/store/likes.js" ></script>
</body>
</html>
body {
background: #F6F6F6;
}
.wrap {
margin: 0px auto;
min-height: calc(100vh - 210px);
}
section.title {
box-shadow: 0px 2px 3px 0px rgb(0 0 0/ 25%);
}
section.title h1 {
text-align: center;
margin: 30px 0 0 0;
padding-bottom: 5%;
}
/* main */
main {
width: 100%;
}
main .temp img {
margin: 20px auto;
display: block;
width: 90%;
max-width: 500px;
}
@media ( max-width :1024px) {
.container {
width: 100%;
}
}
$(document).ready(function() {
let winHeight = 0;
let docHeight = 0;
let page = 1;
let run = false;
$(window).scroll(function(){
winHeight = $(window).height();
docHeight = $(document).height();
const top = $(window).scrollTop();
if(docHeight <= winHeight + top + 10 ) {
if(run) {
return;
}
console.log("페이지 추가");
page++;
run = true;
const data = {
page : page
}
$.ajax({
url: "/api/store/likesList",
type: "GET",
data : data
})
.done(function(result){
const storeHtml = storeList(result);
$(".store").append(storeHtml);
if(storeHtml != "") {
run = false;
}
})
.fail(function(data, textStatus, errorThrown){
swal("다시 시도해주세요");
})
} // if
}) // scroll
function storeList(result){
let html = "";
for(var i=0;i<result.length;i++) {
const store = result[i];
const id = store.id;
const storeImg = store.storeImg;
const storeThumb = store.storeThumb;
const storeName = store.storeName;
const deliveryTime = store.deliveryTime;
const mindelivery = store.mindelivery;
const deliveryTip = store.deliveryTip;
const score = store.score.toFixed(1);
const reviewCount = store.reviewCount;
const bossCommentCount = store.bossCommentCount;
const openingTime = store.openingTime;
const closingTime = store.closingTime;
let scoreHtml = "";
for(var j=0;j<5;j++) {
if(Math.round(score) > j) {
scoreHtml += "<i class='fas fa-star'></i> ";
} else {
scoreHtml += "<i class='far fa-star'></i> ";
}
}
let isOpenHtml = "";
if(store.isOpen == "false") {
isOpenHtml = `<div class="is_open">
<a href="/store/${id }/detail">지금은 준비중입니다</a>
</div>`;
}
html +=
`<li >
<div class="img_box">
<a href="/store/${id }/detail"><img src="${storeImg }" alt="이미지"></a>
</div>
<div class="info_box">
<h2><a href="/store/${id }/detail">${storeName }</a></h2>
<a href="/store/${id }/detail">
<span>
<span>평점 ${score }</span>
<span class="score_box">
${scoreHtml}
</span>
</span>
<span>
<span>리뷰 ${reviewCount }</span>
<span>사장님 댓글 ${bossCommentCount }</span>
</span>
<span>
<span>최소주문금액 ${mindelivery }원</span>
<span>배달팁 ${deliveryTip }원</span>
</span>
<span>배달시간 ${deliveryTime }분</span>
</a>
</div>
${isOpenHtml}
</li>`;
}
return html;
}
});
자바스크립트 코드 같은경우 매장목록과 완전 동일하므로 생략하도록 하겠습니다