본문 바로가기

JS

[Next JS] Commerce Project #2

What to do?

상품 목록 상세페이지 및 수정페이지 뼈대 만들기

Carousel 구현하기

상품 조회하기

상품 설명수정하기


데모 영상

 

Index 페이지 - Carousel

조회 페이지 - 상품 설명 조회

수정 페이지 - 상품 설명 수정


Back-End Project

 

이전 포스팅에서 Notion API를 사용했었다.

하지만 막상 사용해보니 너무 불편했다;;

그래서 Spring Boot로 간단한 Back-End Project를 만들어보았다.

 

  • application.yaml
    • Local PC에 My SQL을 설치하고, Commerce라는 데이터 베이스를 미리 생성
    • 해당 데이터 베이스 연결정보를 적음
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/commerce
    username: root
    password: 1221
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    open-in-view: true
    defer-datasource-initialization: true
    hibernate.ddl-auto: create
    show-sql: true
    properties:
      hibernate.format_sql: true
      hibernate.default_batch_fetch_size: 100
  sql.init.mode: always

 

  • Auditing Fields
    • 메타 데이터는 JPA Auditing을 사용해 자동으로 입력되도록 함 
    • 생성한 시간, 생성한 사람, 수정한 시간, 수정한 사람, 삭제된 시간
@Getter
@ToString
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public abstract class AuditingFields {
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) @CreatedDate
    @Column(updatable = false, name = "created_at")
    private LocalDateTime createdAt;
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) @LastModifiedDate
    @Column(name = "modified_at")
    private LocalDateTime modifiedAt;
    @CreatedBy
    @Column(updatable = false, length = 100, name = "created_by")
    private String createdBy;
    @LastModifiedBy
    @Column(length = 100, name = "modified_by")
    private String modifiedBy;
    @Column(name = "removed_at") @Setter
    private LocalDateTime removedAt;
}

 

  • Product Entity
    • Fields
      • id, name, imgUrl, price, description
    • Soft Delete
      • 삭제되면 실제 DB에서 데이터가 삭제되지 않고, removed_at 컬럼에 삭제요청한 시간이 입력
      • Select 쿼리 사용시에는 removed_at 컬럼이 NULL인 데이터만 가져옴
@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;
    @Column(name="category_id")
    private Long categoryId;
    @Column(name="description", columnDefinition = "TEXT") @Setter
    private String description;
    @Column(name="price") @Setter
    private Long price;
}

 

  • JpaConfig
    • 아직 인증기능을 구현하지 않아서 created_by, modified_by와 같은 컬럼은 JPA Auditing을 사용할 수 없음
    • 일단 test라는 값으로 하드코딩하도록 설정
@EnableJpaAuditing
@Configuration
public class JpaConfig {
    @Bean
    public AuditorAware<String> auditorAware() {
        return () -> Optional.of("test");
    }
}

 

  • Product DTO
    • DTO는 Record를 사용해 정의
public record ProductDto(
        Long id,
        String name,
        String imgUrl,
        Long categoryId,
        String description,
        Long price,
        LocalDateTime createdAt,
        String createdBy,
        LocalDateTime modifiedAt,
        String modifiedBy
) {
    public static ProductDto of(
            String name,
            String imgUrl,
            Long categoryId,
            String description,
            Long price
    ){
        return new ProductDto(
                null,
                name,
                imgUrl,
                categoryId,
                description,
                price,
                null,
                null,
                null,
                null
        );
    }
    public static ProductDto from(ProductEntity entity){
        return new ProductDto(
                entity.getId(),
                entity.getName(),
                entity.getImgUrl(),
                entity.getCategoryId(),
                entity.getDescription(),
                entity.getPrice(),
                entity.getCreatedAt(),
                entity.getCreatedBy(),
                entity.getModifiedAt(),
                entity.getModifiedBy()
        );
    }
}

 

  • Repository
@Repository
public interface ProductRepository extends JpaRepository<ProductEntity, Long> {
    List<ProductEntity> findAll();
}

 

  • Service
    • getProducts : 상품 List 가져오기
    • getProduct : 상품 단건 조회하기
    • updateProduct : 상품 설명 수정하기
@Service
@RequiredArgsConstructor
@Transactional
public class ProductService {

    private final ProductRepository productRepository;

    @Transactional(readOnly = true)
    public List<ProductDto> getProducts() {
        return productRepository.findAll().stream().map(ProductDto::from).collect(Collectors.toList());
    }

    @Transactional(readOnly = true)
    public ProductDto getProduct(Long id){
        return ProductDto.from(productRepository.findById(id).orElseThrow(()->{
            throw new EntityNotFoundException("Invalid product id is given");
        }));
    }

    public ProductDto updateProduct(Long id, String description){
        ProductEntity entity = productRepository.findById(id).orElseThrow(()->{
            throw new EntityNotFoundException("Invalid product id is given");
        });
        entity.setDescription(description);
        return ProductDto.from(productRepository.save(entity));
    }
}

 

  • Requset
    • 상품설명 Request를 수정요청은 id와 description이 옴
@Data
public class UpdateProductRequest {
    private Long id;
    private String description;
}

 

  • Controller
    • getProducts : 상품 List 가져오기
    • getProduct : 상품 단건 조회하기
    • updateProduct : 상품 설명 수정하기
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/product")
public class ProductController {
    private final ProductService productService;

    @GetMapping
    public List<ProductDto> getProducts(){
        return productService.getProducts();
    }

    @GetMapping("/{productId}")
    public ProductDto getProduct(@PathVariable Long productId){
        return productService.getProduct(productId);
    }

    @PutMapping
    public ProductDto getProduct(@RequestBody UpdateProductRequest req){
        return productService.updateProduct(req.getId(), req.getDescription());
    }
}

 

  • data.sql
    • 앱 실행 시 다음의 쿼리를 실행해서 자동으로 예제 데이터가 들어가도록 함
insert into product
(id, name, img_url, category_id, description, price, created_at, created_by, modified_at, modified_by)
values
(1, '전기 자전거', NULL, 1, NULL, 1000000, now(), 'test', now(), 'test');

insert into product
(id, name, img_url, category_id, description, price, created_at, created_by, modified_at, modified_by)
values
(2, '맥북', NULL, 2, NULL, 2000000, now(), 'test', now(), 'test')

 

  • 앱 실행하기

Intellij에서 백엔드 프로젝트 실행한 모습


Carousel

 

  • 라이브러리 설치
    • nuka-carousel 라이브러리를 사용

 

  • 구현할 모습
    • Carousel은 10초에 한장씩 자동으로 넘어가도록 설정
    • 썸네일을 클릭하면 해당 사진으로 이동

 

  • 구현할 방법
    1. Carousel이미지, Thumbnail 이미지 링크를 images라는 배열에 저장
    2. 현재 보여주고 있는 이미지를 나타낼 수 있는 slideIndex라는 state를 만듬
    3. nuka 라이브러리를 사용해 Carousel 만들기
    4. Thumbnail 만들고, onClick 이벤트 구현하기

 

  • Products.tsx
import Image from 'next/image'
import Carousel from 'nuka-carousel/lib/carousel'
import { useState } from 'react'

const images = [
  {
    original: 'https://picsum.photos/id/1000/1000/600/',
    thumbnail: 'https://picsum.photos/id/1000/150/150/',
  },
  {
    original: 'https://picsum.photos/id/1001/1000/600/',
    thumbnail: 'https://picsum.photos/id/1001/150/150/',
  },
  {
    original: 'https://picsum.photos/id/1002/1000/600/',
    thumbnail: 'https://picsum.photos/id/1002/150/150/',
  },
]

export default function MyCarousel() {

  const [slideIndex, setSlideIndex] = useState<number>(0)
  const handleSlideIndex = (idx: number) => () => setSlideIndex(idx)

  return (
    <>
      {/* Carousel */}
      <Carousel
        animation="zoom"
        autoplay
        withoutControls
        speed={10}
        slideIndex={slideIndex}
      >
        {images.map((item) => (
          <Image
            key={item.original}
            src={item.original}
            width={1000}
            height={600}
            alt="big-image"
            layout="reponsive"
          />
        ))}
      </Carousel>

      {/* Thumbnails */}
      <div style={{ display: 'flex' }}>
        {images.map((item, idx) => (
          <div key={idx}>
            <Image
              src={item.thumbnail}
              alt="small-image"
              width={100}
              height={60}
              onClick={handleSlideIndex(idx)}
            />
          </div>
        ))}
        <div/>
       
      </div>
    </>
  )
}

Editing Text Box

 

  • 라이브러리 
    • 상품 설명을 단순한 텍스트가 아니라 서식이 있는 텍스트를 보여주고 싶다.
    • 또한 수정 폼에서도 서식 있는 텍스트로 수정하고 싶다.
    • 이 경우 사용할 수 있는게 페이스북에서 만든 draft.js라는 라이브러리다.

 

  • 고민할 점
    • 상품 정보를 결국 데이터베이스에 저장해야 되는데, 서식 있는 텍스트는 어떻게 저장할 수 있을까?
    • 테이블에 서식 있는 텍스트를 표현할 수 있는 Json 객체를 stringify 해서 저장하면 된다

 

예를 들어 아래와 같이 id가 1번인 상품의 설명을 다음과 같이 수정하고 저장 버튼을 눌르면

 

데이터베이스를 조회할 때 Stringify된 Json 객체가 들어간걸 볼 수 있다

{"blocks":[{"key":"cl5a","text":"tesatsfdsafdsafdsafadsf","type":"unstyled","depth":0,"inlineStyleRanges":[{"offset":7,"length":16,"style":"BOLD"}],"entityRanges":[],"data":{}},{"key":"13rn5","text":"dsafdsafdsf","type":"unstyled","depth":0,"inlineStyleRanges":[{"offset":0,"length":11,"style":"BOLD"}],"entityRanges":[],"data":{}}],"entityMap":{}}

select descriptoin from product where id = 1

 

  • Editor Component
import { Dispatch, SetStateAction } from "react"
import { EditorState } from "draft-js"
import dynamic from "next/dynamic"
import { EditorProps } from "react-draft-wysiwyg"
import 'react-draft-wysiwyg/dist/react-draft-wysiwyg.css'

const Editor = dynamic<EditorProps>(
    ()=>import('react-draft-wysiwyg').then((module)=>module.Editor),
    {
        ssr:false
    }
)

export default function MyEditor({
    editorState, 
    readOnly=false,
    onSave,
    onEditorStateChange
    }
    :{
        editorState:EditorState, 
        readOnly?:boolean,
        onSave?:()=>void,
        onEditorStateChange?:Dispatch<SetStateAction<EditorState|undefined>>
    }){
        console.log(readOnly)
    return (
        <div>
            <Editor 
                readOnly={readOnly}
                toolbarHidden={readOnly}
                editorState={editorState}
                toolbarClassName="editor-toolbar-hidden"
                wrapperClassName="wrapper-class"
                editorClassName="editor-class"
                toolbar={{
                    options:['inline', 'list', 'textAlign', 'link']
                }}
                localization={{
                    local:'ko'
                }}
                onEditorStateChange={onEditorStateChange}
            />

            {readOnly?? <button onClick={onSave}>Save</button>}
        </div>
    )
}

 

  • Text Editing Box
    • useRouter를 사용해 edit, productId를 받아 readOnly인지 판단
    • readOnly인 경우 수정화면이 나오지 않음
import CustomEditor from '../../../components/Editor'
import { EditorState, convertFromRaw, convertToRaw } from 'draft-js'
import { useRouter } from 'next/router'
import { useState, useEffect } from 'react'

export default function EditProduct() {
  const router = useRouter()
  const {id:productId, edit} = router.query
  const [editorState, setEditorState] = useState<EditorState|undefined>(undefined)
  const [readOnly, setReadOnly] = useState<boolean>(false);  

  // edit모드 & productId가 존재 → 수정가능
  useEffect(()=>{
    setReadOnly(!(edit&&productId))
  }, [edit])

  useEffect(()=>{
    if (productId == null){
      return
    }
    // 상품 정보 가져오기
    const endPoint = `/api/get-product?id=${productId}`
    fetch(endPoint)
      .then(res=>res.json())
      .then(data => data.data.description)
      .then((description) => {
        description
        // description 필드의 값이 기존에 있는 경우 → update state
        ?setEditorState(EditorState.createWithContent(convertFromRaw(JSON.parse(description))))
        // description 필드의 값이 기존에 없는 경우 → Empty State
        :setEditorState(EditorState.createEmpty())        
      })
      .catch(err=>console.error(err))
  }, [productId])

  // 수정내용 저장
  const handleSave = () => {
   if (!editorState){
    return
   }
   fetch('/api/update-product', {
    method:'POST',
    body: JSON.stringify({
      id : productId,
      description : JSON.stringify(convertToRaw(editorState.getCurrentContent()))
    })
   }).then(res=>res.json())
   .then((data)=>{
      alert(data.message)
      return;
   }).catch(err=>console.error(err))
  }


  return (
    <>
      {editorState!=null &&
            (
              <CustomEditor
              editorState={editorState}
              onSave = {handleSave}
              onEditorStateChange = {setEditorState}
              readOnly={readOnly}
            />
            )
      }

      {/* edit모드인 경우에만 저장버튼 보이기 */}
      {
        !readOnly&&<button onClick={handleSave}>Save</button>
      }      
    </>
  )
}

 

  • Home 화면
export default function Home() {

  return (
    <>
      <Head>
        <title>Commerce</title>
        <meta name="description" content="Commerce" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icons" href="/favicon.ico" />
      </Head>
      <MyCarousel/>
      <EditProduct/>
    </>
  )
}

API

 

  • 상품설명 조회하기

import type { NextApiRequest, NextApiResponse } from 'next'
import { get } from 'http'
import axios from 'axios'

// API response
type Data = {
  message : String,
  data : {
    id:Number,
    name:String,
    imgUrl:String,
    categoryId:String,
      descripion : String,
  price : Number
  } | undefined
}

async function getProduct(id:number) {
  try {
    const endPoint = `http://localhost:8080/api/product/${id}`
    const res = await axios.get(endPoint)
    console.debug(res.data??res);
    return res;
  } catch (e) {
    console.error(JSON.stringify(e))
  }
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  const { id } = req.query
  if (id == null){
    return res.status(400).json({ message: 'product id is not given', data : undefined })
  }
  try {
    const items = await getProduct(Number(id))
    res.status(200).json({ message: 'Get item success', data: items?.data })
  } catch (e) {     
    console.error(e)
    return res.status(400).json({ message: 'Fail to get item' , data : undefined})
  }
}

 

 

  • 상품설명 수정하기

import type { NextApiRequest, NextApiResponse } from 'next'
import axios from 'axios'

async function updateProduct(id:Number, description:String) {
  try {
    const endPoint = "http://localhost:8080/api/product"
    const data = {id, description}
    const res = await axios.put(endPoint, data)
    console.debug(res);
    return res.data;
  } catch (e) {
    console.error(JSON.stringify(e))
  }
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  try {
    const { id, description } = JSON.parse(req.body);
    await updateProduct(id ,description)
    .then(()=>{
      res.status(200).json({ message: 'Update item success'})
    })
    .catch((err)=>{
      res.status(500).json({message:'Fail to update item'})
      console.error(err);
    })    
  } catch (e) {     
    console.error(e)
    return res.status(400).json({ message: 'Fail to update item' })
  }
}

'JS' 카테고리의 다른 글

[Next JS] Commerce Project #6  (0) 2023.04.15
[Next JS] Commerce Project #5  (0) 2023.04.13
[Next JS] Commerce Project #4  (0) 2023.04.13
[Next JS] Commerce Project #3  (0) 2023.04.09
[Next JS] Commerce Project #1  (0) 2023.04.05