What to do?
전체 상품목록 화면 2
정렬기능
검색기능
데모 영상
예제데이터
모카루 사이트를 활용해서 예제 데이터 생성
다운 받은 쿼리를 data.sql에 넣어주면, 앱이 실행될 때마다 해당 쿼리가 실행
insert into product (id, name, img_url, category, description, created_at, modified_at, created_by, modified_by, price) values (1, 'Cheese - Mozzarella, Buffalo', 'https://picsum.photos/id/1000/1000/600/', 'ELECTRONICS', '{"blocks":[{"key":"hfme","text":"test edit","type":"unstyled","depth":0,"inlineStyleRanges":[{"offset":0,"length":9,"style":"BOLD"}],"entityRanges":[],"data":{}}],"entityMap":{}}', '2023-03-14 09:52:43', '2022-04-19 07:51:47', 'test', 'test', 282813);
insert into product (id, name, img_url, category, description, created_at, modified_at, created_by, modified_by, price) values (2, 'Icecream - Dstk Strw Chseck', 'https://picsum.photos/id/1000/1000/600/', 'CLOTH', '{"blocks":[{"key":"hfme","text":"test edit","type":"unstyled","depth":0,"inlineStyleRanges":[{"offset":0,"length":9,"style":"BOLD"}],"entityRanges":[],"data":{}}],"entityMap":{}}', '2022-10-14 16:17:50', '2022-12-27 23:07:08', 'test', 'test', 351234);
insert into product (id, name, img_url, category, description, created_at, modified_at, created_by, modified_by, price) values (3, 'Island Oasis - Wildberry', 'https://picsum.photos/id/1000/1000/600/', 'CLOTH', '{"blocks":[{"key":"hfme","text":"test edit","type":"unstyled","depth":0,"inlineStyleRanges":[{"offset":0,"length":9,"style":"BOLD"}],"entityRanges":[],"data":{}}],"entityMap":{}}', '2022-10-10 15:12:32', '2022-08-14 08:58:31', 'test', 'test', 765025);
insert into product (id, name, img_url, category, description, created_at, modified_at, created_by, modified_by, price) values (4, 'Sauce - Sesame Thai Dressing', 'https://picsum.photos/id/1000/1000/600/', 'SHOES', '{"blocks":[{"key":"hfme","text":"test edit","type":"unstyled","depth":0,"inlineStyleRanges":[{"offset":0,"length":9,"style":"BOLD"}],"entityRanges":[],"data":{}}],"entityMap":{}}', '2022-06-10 02:44:45', '2023-02-28 15:03:54', 'test', 'test', 917700);
...
Front End
- get-products.ts
- GET요청 Endpoint는 다음의 paramters를 조합
- ① category(상품유형)
- ② searchType(검색유형)
- ③ keyword(검색어)
- ④ sort(정렬기준)
- ⑤ page(페이지수)
- GET요청 Endpoint는 다음의 paramters를 조합
import type { NextApiRequest, NextApiResponse } from 'next'
import axios from 'axios'
import productModel from 'model/productModel'
type Data = {
message: String
items: {
content: productModel[]
pageable: any
totalElements: Number
totalPages: Number
}[]
}
async function getProducts({
page,
category,
sort,
keyword,
searchType,
}: {
page: Number
category: String
sort: String
keyword: String
searchType: String
}) {
// construct end point
const endPoint =
'http://localhost:8080/api/product?page=' +
(page ? `${Number(page) - 1}` : 0) +
(sort ? `&sort=${sort}` : '') +
(category !== 'ALL' ? `&category=${category}` : '') +
(searchType && keyword
? `&searchType=${searchType}&keyword=${keyword}`
: '')
console.debug(endPoint)
// axios
try {
const res = await axios.get(endPoint)
return res.data
} catch (e) {
console.error(JSON.stringify(e))
}
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
const { page, category, sort, keyword, searchType } = JSON.parse(req.body)
try {
const items = await getProducts({
page,
category,
sort,
keyword,
searchType,
})
res.status(200).json({ message: 'Get item success', items: items })
} catch (e) {
console.error(e)
return res.status(400).json({ message: 'Fail to get item', items: [] })
}
}
- products/index.ts (상품 조회 화면)
- 기존코드에서 정렬바, 검색창을 추가
- 정렬바
- 정렬기준 (sort) : 가격낮은순 / 가격높은순 / 최신순
- 검색창
- 검색유형 (seachType) : 제목 / 해시태그 / 상품설명
import productModel from 'model/productModel'
import { useEffect, useState } from 'react'
import { css } from '@emotion/css'
import Image from 'next/image'
import MyEditor from '../../components/Editor'
import { Button, CloseButton, Input, Pagination, SegmentedControl, StarIcon, TextInput } from '@mantine/core'
import { EditorState, convertFromRaw } from 'draft-js'
import { Select } from '@mantine/core'
export default function Products() {
/**
* currentPage : 현재 페이지수
* currentCategory : 현재 선택한 카테고리(default : ALL)
* categories : 카테고리 종류
* currentInput : 검색창에 입력된 키워드
* searchInput : 현재 적용된 검색어
* totoalPage : 전체 페이지수
* products : 상품 List
*/
const [currentPage, setCurrentPage] = useState<number>(1)
const [currentCategory, setCurrentCategory] = useState<string>('ALL')
const [currentSort, setCurrentSort] = useState<string | null>(null)
const [categories, setCategories] = useState<
{ label: String; value: String }[]
>([])
const [currentKeyword, setCurrentKeyword] = useState<string>('')
const [currentSearchType, setCurrentSearchType] = useState<string | null>(null)
const [totoalPage, setTotalPage] = useState<number>(0)
const [products, setProducts] = useState<productModel[]>([])
const searchSelectMenu = [
{ value: 'NAME', label: '상품명' },
{ value: 'HASHTAG', label: '해시태그' },
{ value: 'DESCRIPTION', label: '상품설명' },
]
const sortSelectMenu = [
{ value: 'price,asc', label: '가격 낮은 순' },
{ value: 'price,desc', label: '가격 높은 순' },
{ value: 'createdAt,asc', label: '최신 순' },
]
const handleCurrentInput = (e: React.FormEvent<HTMLInputElement>) => setCurrentKeyword(e.currentTarget.value)
const handleDeleteCurrentKeyword = () => {setCurrentKeyword('')}
const getProducts = () => {
fetch('/api/get-products', {
method: 'POST',
body: JSON.stringify({
sort: currentSort,
page: currentPage,
category: currentCategory,
keyword:currentKeyword,
searchType :currentSearchType
}),
})
.then((res) => res.json())
.then((data) => {
setProducts([...data.items.content])
setTotalPage(data.items.totalPages)
})
.catch(console.error)
}
const getCategories = () => {
fetch('/api/get-category')
.then((res) => res.json())
.then((data) =>
setCategories([{ label: '전체', value: 'ALL' }, ...data.items])
)
.catch(console.error)
}
useEffect(() => {
getCategories()
}, [])
useEffect(()=>{
setCurrentPage(1)
}, [currentCategory])
// When end point change, Get product
useEffect(() => {
getProducts()
}, [currentPage, currentCategory, currentSort])
return (
<>
{/* Header */}
<h1
className={css`
margin-bottom: 20px;
`}
>
Products
</h1>
{/* Search */}
<div
className={css`
align-items: center;
display: flex;
`}
>
<div
className={css`
margin-right: 10px;
`}
>
<Select
placeholder="Search"
data={searchSelectMenu}
value={currentSearchType}
onChange={setCurrentSearchType}
clearable
/>
</div>
<div
className={css`
min-width: 50%;
margin-right: 10px;
`}
>
<Input
value={currentKeyword}
onChange={handleCurrentInput}
rightSection={
<CloseButton
onClick={handleDeleteCurrentKeyword}
aria-label="Close modal"
/>
}
placeholder='검색어를 입력하세요'
/>
</div>
<div>
<Button
onClick={getProducts}
>
Search
</Button>
</div>
</div>
{/* Sort */}
<div
className={css`
max-width: 300px;
margin-bottom: 20px;
`}
>
<Select
clearable
label="Sort"
placeholder="Sort"
data={sortSelectMenu}
value={currentSort}
onChange={setCurrentSort}
/>
</div>
{/* Category */}
<div
className={css`
margin-bottom: 20px;
`}
>
<SegmentedControl
value={currentCategory}
onChange={setCurrentCategory}
data={[
...categories.map((cat) => ({
label: String(cat.label),
value: String(cat.value),
})),
]}
color="dark"
defaultChecked={true}
defaultValue={'ALL'}
/>
</div>
{/* Products */}
<div>
{products &&
products.map((prod) => {
const editorState = EditorState.createWithContent(
convertFromRaw(JSON.parse(prod.description))
)
return (
<div
className={css`
padding: 5px;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 5px;
`}
key={prod.id}
>
<div>
{prod.imgUrl && (
<Image
width={300}
height={200}
src={prod.imgUrl}
alt={prod.name}
// TODO : add blur image
// placeholder="blur"
// blurDataURL=""
className={css`
border-radius: 10%;
`}
/>
)}
</div>
<div
className={css`
padding: 5px;
display: grid;
grid-template-rows: 2fr 1fr 2fr;
grid-gap: 5px;
`}
>
<div>
<p
className={css`
font-weight: 800;
`}
>
{prod.name}
</p>
</div>
<div
className={css`
display: flex;
justify-content: space-between;
width: 300px;
margin-top: 5px;
`}
>
<span>\{prod.price?.toLocaleString('ko-KR')}</span>
<span
className={css`
font-style: italic;
color: gray;
`}
>
{prod.category}
</span>
</div>
<MyEditor editorState={editorState} readOnly />
</div>
<br />
</div>
)
})}
</div>
<div
className={css`
width: 100%;
display: flex;
margin-top: 10px;
justify-content: center;
`}
>
<Pagination
value={currentPage}
onChange={setCurrentPage}
total={totoalPage}
className={css`
margin: 'auto';
`}
/>
</div>
</>
)
}
Back End
- SearchType : 검색유형
@Getter
@AllArgsConstructor
public enum SearchType {
NAME("상품이름"),
DESCRIPTION("상품설명"),
HASHTAG("해시태그");
private final String description;
}
- Category : 상품유형
@Getter
@AllArgsConstructor
public enum Category {
CLOTH("옷"),
SHOES("신발"),
ELECTRONICS("전자기기"),
BOOK("책"),
ETC("기타");
private final String description;
}
- Product Entity
- Catetory, hashtags 필드 추가
@Entity
@Getter
@Table(name = "product")
@SQLDelete(sql = "UPDATE product SET removed_at = NOW() WHERE id=?")
@Where(clause = "removed_at is NULL")
public class ProductEntity extends AuditingFields{
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false) @Setter
private String name;
@Column(name="img_url")
private String imgUrl;
@Enumerated(value = EnumType.STRING)
private Category category;
@Column(name="description", columnDefinition = "TEXT") @Setter
private String description;
@ElementCollection(fetch = FetchType.LAZY)
private Set<String> hashtags = new LinkedHashSet<>();
@Column(name="price") @Setter
private Long price;
}
- Repository
- 검색기능 구현을 위한 메써드 추가
- Hashtag, Category : Exact Match로 조회
- Name, Description : Containing 키워드를 사용해 유사 일치로 조회
- 검색기능 구현을 위한 메써드 추가
@Repository
@Transactional
public interface ProductRepository extends JpaRepository<ProductEntity, Long> {
@Transactional(readOnly = true)
Page<ProductEntity> findAll(Pageable pageable);
@Transactional(readOnly = true)
Page<ProductEntity> findByCategory(Category category, Pageable pageable);
@Transactional(readOnly = true)
Page<ProductEntity> findByNameContaining(String name, Pageable pageable);
@Transactional(readOnly = true)
Page<ProductEntity> findByHashtags(String hashtag, Pageable pageable);
@Transactional(readOnly = true)
Page<ProductEntity> findByDescriptionContaining(String description, Pageable pageable);
@Transactional(readOnly = true)
Page<ProductEntity> findByNameContainingAndCategory(String name, Category category, Pageable pageable);
@Transactional(readOnly = true)
Page<ProductEntity> findByDescriptionContainingAndCategory(String description, Category category, Pageable pageable);
@Transactional(readOnly = true)
Page<ProductEntity> findByHashtagsAndCategory(String hashtag, Category category, Pageable pageable);
}
- Service
- getProducts 메써드 수정
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
public Page<ProductDto> getProducts(Category category, SearchType searchType, String keyword, Pageable pageable) {
// no category & no search
if (category == null && searchType == null) {
return productRepository.findAll(pageable).map(ProductDto::from);
}
// category & no search
if (searchType == null) {
return productRepository.findByCategory(category, pageable).map(ProductDto::from);
}
// no category & search
if (category == null) {
return switch (searchType) {
case NAME -> productRepository.findByNameContaining(keyword, pageable).map(ProductDto::from);
case DESCRIPTION ->
productRepository.findByDescriptionContaining(keyword, pageable).map(ProductDto::from);
case HASHTAG -> productRepository.findByHashtags(keyword, pageable).map(ProductDto::from);
};
}
// category & search
return switch (searchType) {
case NAME ->
productRepository.findByNameContainingAndCategory(keyword, category, pageable).map(ProductDto::from);
case DESCRIPTION ->
productRepository.findByDescriptionContainingAndCategory(keyword, category, pageable).map(ProductDto::from);
case HASHTAG ->
productRepository.findByHashtagsAndCategory(keyword, category, pageable).map(ProductDto::from);
};
}
...
}
- Controller
- getProducts 메써드 수정
- category, searchType, keyword와 같은 parameter는 nullable (분기처리는 service 코드에서 수행)
- getProducts 메써드 수정
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/product")
public class ProductController {
private final ProductService productService;
@GetMapping
public Page<GetProductResponse> getProducts(
@PageableDefault Pageable pageable,
@RequestParam(value = "category", required = false) Category category,
@RequestParam(value = "searchType", required = false) SearchType searchType,
@RequestParam(value = "keyword", required = false) String keyword
){
return productService.getProducts(category, searchType, keyword, pageable).map(GetProductResponse::from);
}
...
}
'JS' 카테고리의 다른 글
[Next JS] Commerce Project #6 (0) | 2023.04.15 |
---|---|
[Next JS] Commerce Project #5 (0) | 2023.04.13 |
[Next JS] Commerce Project #3 (0) | 2023.04.09 |
[Next JS] Commerce Project #2 (0) | 2023.04.08 |
[Next JS] Commerce Project #1 (0) | 2023.04.05 |