본문 바로가기

JS

[Next JS] Commerce Project #4

What to do?

전체 상품목록 화면 2

정렬기능

검색기능


데모 영상

 

 


예제데이터

 

모카루 사이트를 활용해서 예제 데이터 생성

https://www.mockaroo.com/

 

다운 받은 쿼리를 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);

...

data.sql


Front End

 

  • get-products.ts
    • GET요청 Endpoint는 다음의 paramters를 조합
      • ① category(상품유형)
      • ② searchType(검색유형)
      • ③ keyword(검색어)
      • ④ sort(정렬기준)
      • ⑤ page(페이지수) 
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 코드에서 수행)
@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