[프로그래머스] 프론트엔드 심화: 프로젝트(7)

Lina Hongbi Ko·2024년 11월 18일
0

Programmers_BootCamp

목록 보기
58/76
post-thumbnail

2024년 11월 18일

✏️ 장바구니 목록 (1)

  • 구현 계획

    • 사용자가 장바구니에 담은 내역을 확인
    • 선택한 도서 아이템의 수량과 가격 합계를 표시
    • 장바구니 담은 도서 아이템을 삭제
    • 각 도서 아이템을 선택하여 주문서 작성
  • 장바구니 페이지 틀 만들기 + 체크 아이콘 버튼 만들기

// App.tsx

... 생략 ...
,{
    path: "/carts",
    element: <Layout><Cart ></Cart></Layout>
  }
  
  ... 생략 ...
// pages / Cart.tsx

import styled from 'styled-components'
import Title from '../components/common/Title'
import CartItem from '../components/cart/CartItem';
import { useCart } from '../hooks/useCart';

const Cart = () => {
  const { carts } = useCart();

  return (
    <>
      <Title size="large">장바구니</Title>
      <CartStyle>
        <div className="content">
          {
            carts.map((item) => (<CartItem key={item.id} cart={item} />))
          }
        </div>
        <div className="summary">

        </div>
      </CartStyle>
    </>
  )
}

const CartStyle = styled.div`

`;

export default Cart
// api / carts.api.ts

... 생략 ...

export const fetchCart = async () => {
  const response = await httpClient.get<Cart[]>("/carts");
  return response.data;
}
// hooks / useCart.ts

import { useEffect, useState } from 'react'
import { Cart } from '../models/cart.model'
import { fetchCart } from '../api/carts.api';

export const useCart = () => {
  const [carts, setCarts] = useState<Cart[]>([]);
  const [isEmpty, setIsEmpty] = useState(true);

  useEffect(()=>{
    fetchCart().then((carts)=>{
      setCarts(carts);
      setIsEmpty(carts.length === 0);
    })
  }, [])

  return { carts, isEmpty };
}
// components / cart / CartIem.tsx

import styled from 'styled-components'
import { Cart } from '../../models/cart.model';
import Button from '../common/Button';
import Title from '../common/Title';
import { formatNumber } from '../../utils/format';
import CheckIconButton from './CheckIconButton';

interface Props {
  cart: Cart;
}

const CartItem = ({cart}:Props) => {
  return (
    <CartItemStyle>
      <div className="info">
        <CheckIconButton />
        <div>
          <Title size="large" color="text">{cart.title}</Title>
          <p className="summary">{cart.summary}</p>
          <p className="price">{formatNumber(cart.price)} 원</p>
          <p className="quantity">{cart.quantity} 권</p>
        </div>
      </div>
      <Button size="medium" scheme="normal">장바구니 삭제</Button>
    </CartItemStyle>
  )
}

const CartItemStyle = styled.div`
  display: flex;
  justify-content: space-between;
  align-items: start;
  border: 1px solid ${({theme}) => theme.colors.border};
  border-radius: ${({theme}) => theme.borderRadius.default};
  padding: 12px;

  p {
    padding: 0 0 8px 0;
    margin: 0;
  }
`;

export default CartItem
// components / cart / CheckIconButton.tsx

import styled from 'styled-components'
import { FaRegCircle, FaRegCheckCircle } from "react-icons/fa";

interface Props {
  isChecked: boolean;
  onCheck: () => void;
}

const CheckIconButton = ({isChecked, onCheck} : Props) => {
  return (
    <CheckIconButtonStyle onClick={onCheck}>
      {
        isChecked ? (<FaRegCheckCircle />) : (<FaRegCircle />)
      }
    </CheckIconButtonStyle>
  )
}

const CheckIconButtonStyle = styled.button`
	background: none;
  border: 0;
  cursor: pointer;

  svg {
  width : 24px;
  height: 24px;
  }
`;

export default CheckIconButton

✏️ 장바구니 목록 (2)

  • 장바구니 아이템 체크 로직 만들기

  • 루프 되고 있는 아이템들을 체크하는데는 여러 전략이 있음

    1. 루프 되고 있는 아이템에 추가 타입을 넣어서 설정
    2. 체크된 항목들을 배열을 통해 목록 보여주기 → 사용예정
  • 체크 상태인 장바구니 아이템 보여주기

// pages / Cart.tsx

import styled from 'styled-components'
import Title from '../components/common/Title'
import CartItem from '../components/cart/CartItem';
import { useCart } from '../hooks/useCart';
import { useState } from 'react';

const Cart = () => {
  const { carts } = useCart();

  const [ checkedItems, setCheckedItems ] = useState<number[]>([1]);
  // 아이템의 아이디만 넣을 것이기 때문에 넘버 배열타입으로 지정해줌
  // checkedItems의 상태에 따라 CartItem, CheckIconButton까지 상태가 전달되어야 함

  return (
    <>
      <Title size="large">장바구니</Title>
      <CartStyle>
        <div className="content">
          {
            carts.map((item) => (<CartItem key={item.id} cart={item} 
            checkedItems={checkedItems} />))
          }
        </div>
        <div className="summary">

        </div>
      </CartStyle>
    </>
  )
}

const CartStyle = styled.div``;

export default Cart
// components / cart /cartItem

import styled from 'styled-components'
import { Cart } from '../../models/cart.model';
import Button from '../common/Button';
import Title from '../common/Title';
import { formatNumber } from '../../utils/format';
import CheckIconButton from './CheckIconButton';
import { useMemo } from 'react';

interface Props {
  cart: Cart;
  checkedItems: number[];
}

const CartItem = ({ cart, checkedItems }:Props) => {
  // checkedItems 목록에 id가 있는지 판단 = checked
  const isChecked = useMemo(() => {
    return checkedItems.includes(cart.id);
  }, [checkedItems, cart.id]);

  return (
    <CartItemStyle>
      <div className="info">
        <CheckIconButton isChecked={isChecked} />
        <div>
          <Title size="large" color="text">{cart.title}</Title>
          <p className="summary">{cart.summary}</p>
          <p className="price">{formatNumber(cart.price)} 원</p>
          <p className="quantity">{cart.quantity} 권</p>
        </div>
      </div>
      <Button size="medium" scheme="normal">장바구니 삭제</Button>
    </CartItemStyle>
  )
}

const CartItemStyle = styled.div`
  display: flex;
  justify-content: space-between;
  align-items: start;
  border: 1px solid ${({theme}) => theme.colors.border};
  border-radius: ${({theme}) => theme.borderRadius.default};
  padding: 12px;

  p {
    padding: 0 0 8px 0;
    margin: 0;
  }
`;

export default CartItem
// components / cart / CartItem.tsx

import styled from 'styled-components'
import { Cart } from '../../models/cart.model';
import Button from '../common/Button';
import Title from '../common/Title';
import { formatNumber } from '../../utils/format';
import CheckIconButton from './CheckIconButton';
import { useMemo } from 'react';

interface Props {
  cart: Cart;
  checkedItems: number[];
  onCheck:(id:number) => void;
}

const CartItem = ({ cart, checkedItems, onCheck }:Props) => {

  // checkedItems 목록에 id가 있는지 판단 = checked
  const isChecked = useMemo(() => {
    return checkedItems.includes(cart.id);
  }, [checkedItems, cart.id]);

  const handleCheck = () => {
    onCheck(cart.id);
  }

  return (
    <CartItemStyle>
      <div className="info">
        <CheckIconButton isChecked={isChecked} onCheck={handleCheck}/>
        
        ... 생략 ...
// pages / Cart.tsx

import styled from 'styled-components'
import Title from '../components/common/Title'
import CartItem from '../components/cart/CartItem';
import { useCart } from '../hooks/useCart';
import { useState } from 'react';

const Cart = () => {
  const { carts } = useCart();

  const [ checkedItems, setCheckedItems ] = useState<number[]>([]);

  const handleCheckItem = (id:number) => {
    if(checkedItems.includes(id)){
      // 언체크
      setCheckedItems(checkedItems.filter((itemId) => itemId !== id))
    } else {
      // 체크
      setCheckedItems([ ...checkedItems, id]);
      return;
    }
  }

  return (
    <>
      <Title size="large">장바구니</Title>
      <CartStyle>
        <div className="content">
          {
            carts.map((item) => (<CartItem key={item.id} cart={item} 
            checkedItems={checkedItems} onCheck={handleCheckItem}/>))
          }
        </div>
        
        ... 생략 ...

  • 장바구니 삭제 버튼 눌러서 삭제시키기
// api / carts.api.ts

... 생략 ...

export const deleteCart = async (cartId: number) => {
  const response = await httpClient.delete(`/carts/${cartId}`);
  return response.data;
}
// hooks / useCart.ts

... 생략 ...

  const deleteCartItem = (id:number) => {
    deleteCart(id).then(() => {
      setCarts(carts.filter((cart) => cart.id !== id));
    })
  }

  useEffect(()=>{
    fetchCart().then((carts)=>{
      setCarts(carts);
      setIsEmpty(carts.length === 0);
    })
  }, [])

  return { carts, isEmpty, deleteCartItem };
}
// pages / Cart.tsx

... 생략 ...

const Cart = () => {
  const { carts, deleteCartItem } = useCart();
  
  ... 생략 ...
  
  const handleItemDelete = (id:number) => {
    // 삭제
    deleteCartItem(id);
  }

  return (
    <>
      <Title size="large">장바구니</Title>
      <CartStyle>
        <div className="content">
          {
            carts.map((item) => (<CartItem key={item.id} cart={item} 
            checkedItems={checkedItems} onCheck={handleCheckItem} 
            onDelete={handleItemDelete}/>))
          }
        </div>
        
        ... 생략 ...
// components / cart / CartItem.tsx

... 생략 ...

interface Props {
  cart: Cart;
  checkedItems: number[];
  onCheck:(id:number) => void;
  onDelete: (id:number) => void;
}

const CartItem = ({ cart, checkedItems, onCheck, onDelete }:Props) => {

  const isChecked = useMemo(() => {
    return checkedItems.includes(cart.id);
  }, [checkedItems, cart.id]);

  const handleCheck = () => {
    onCheck(cart.id);
  }

  const handleDelete = () => {
    showConfirm("정말 삭제하시겠습니까?", () => {
      onDelete(cart.id);
    });
  }
  return (
  
  ... 생략 ...
			  
			  <Button size="medium" scheme="normal" 
			  onClick={handleDelete}>장바구니 삭제</Button>
			  
			  ... 생략 ...
// hooks / useAlert.ts

import { useCallback } from 'react'

export const useAlert = () => {
  const showAlert = useCallback((message: string) => {
    window.alert(message);
  }, []);

  const showConfirm = useCallback((message: string, onConfirm: () => void) => 
  {
    if(window.confirm(message)) {
      onConfirm();
    }
  }, []);

  return {showAlert, showConfirm};
}

✏️ 장바구니 목록 (3)

  • 장바구니가 비어있는 상태 보여주기
    • 이전에 작성했던 '책 검색 결과가 없습니다'를 보여주는 bookEmpty랑 비슷한 화면 구조임
    • 따라서, 공통으로 Empty 컴포넌트를 만들어서 재사용해줄 것임
// components / common / Empty.tsx

import styled from 'styled-components'
import Title from '../common/Title';

interface Props {
  icon?: React.ReactNode;
  title: string;
  description?: React.ReactNode;
}

const Empty = ({ icon, title, description }:Props) => {
  return (
    <EmptyStyle>
      {
        icon && (<div className="icon">{icon}</div>)
      }
      <Title size="large" color="secondary">
        {title}
      </Title>
      {
        description && (<p>{description}</p>)
      }
    </EmptyStyle>
  )
}

const EmptyStyle = styled.div`
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 12px;
  padding: 120px 0;

  .icon {
    svg {
      font-size: 4rem;
      fill: #ccc;
    }
  }
`;

export default Empty
// components / books / BooksEmpty.tsx

import { FaSmileWink } from 'react-icons/fa';
import { Link } from 'react-router-dom';
import Empty from '../common/Empty';
const BooksEmpty = () => {
  return (
    <Empty title="검색 결과가 없습니다." icon={<FaSmileWink />} description={<Link to="/books">전체 검색 결과로 이동</Link>}/>
  )
}


export default BooksEmpty
// pages . Cart.tsx

... 생략 ...

return (
    <>
      <Title size="large">장바구니</Title>
      <CartStyle>
        {
          carts.length !== 0 &&
          (
            <>
              <div className="content">
              {
                carts.map((item) => (<CartItem key={item.id} cart={item} checkedItems={checkedItems} onCheck={handleCheckItem} onDelete={handleItemDelete}/>))
              }
              </div>
              <div className="summary">
                  <CartSummary totalPrice={totalPrice} totalQuantity={totalQuantity}/>
              </div>
            </>
          )
        } 
        {
          carts.length === 0 && <Empty title="장바구니가 비었습니다." icon={<FaShoppingCart />} description={<>장바구니를 채워보세요.</>} />
        }
      </CartStyle>
    </>
  )
}

... 생략 ...

✏️ 장바구니 목록 (4)

// components / cart / CartSummary.tsx

import styled from 'styled-components';
import { formatNumber } from '../../utils/format';

interface Props {
  totalQuantity: number;
  totalPrice: number;
}

const CartSummary = ({totalQuantity, totalPrice} : Props) => {
  return (
    <CartSummaryStyle>
      <h1>주문 요약</h1>
      <dl>
        <dt>총 수량</dt>
        <dd>{totalQuantity} 권</dd>
      </dl>
      <dl>
        <dt>총 금액</dt>
        <dd>{formatNumber(totalPrice)} 원</dd>
      </dl>
    </CartSummaryStyle>
  )
}

const CartSummaryStyle = styled.div`
  border: 1px solid ${({theme}) => theme.colors.border};
  border-radius: ${({theme}) => theme.borderRadius.default};
  padding: 12px;
  width: 240px;

  h1 {
    font-size: 1.5rem;
    margin-bottom: 12px;
  }

  dl {
    display: flex;
    justify-content: space-between;
    margin-bottom: 12px;
    dd {
      font-weight: 700;
    }
  }
`;

export default CartSummary
// pages / Cart.tsx

... 생략 ...

const totalQuantity = useMemo(() => {
    return carts.reduce((acc, cart) => {
      if(checkedItems.includes(cart.id)) {
        return acc + cart.quantity;
      }
      return acc; 
    }, 0); 
  }, [carts, checkedItems]);

  const totalPrice = useMemo(() => {
    return carts.reduce((acc, cart) => {
      if(checkedItems.includes(cart.id)) {
        return acc + cart.price * cart.quantity;
      }
      return acc;
    }, 0);
  }, [carts, checkedItems]);
  
  
  .... 생략 ...
  
  
        <div className="summary">
           <CartSummary totalPrice={totalPrice} totalQuantity={totalQuantity}/>
        </div>
        
        ... 생략 ...

✏️ 장바구니 목록 (5)

  • 체크된 아이템 주문서로 넘기기
// models / order.model.ts

... 생략 ...

export interface OrderSheet {
  items: number[];
  totalQuantity: number;
  totalPrice: number;
  firstBookTitle: string;
  delivery: {
    address: string;
    receiver: string;
    contact: string;
  };
}
// App.tsx

... 생략 ...

	{
    path: "/order",
    element: <Layout><Order /></Layout>
  }
  
  ... 생략 ...
// pages / Order.tsx

import { useLocation } from 'react-router-dom';
import styled from 'styled-components'

const Order = () => {
  const location = useLocation();
  const orderDataFromCart = location.state;

  console.log(orderDataFromCart);
  return (
    <OrderStyle>Order</OrderStyle>
  )
}

const OrderStyle = styled.div`
`;

export default Order
// pages / Cart.tsx

... 생략 ...

const navigate = useNavigate();


... 생략 ...

const totalQuantity = useMemo(() => {
    return carts.reduce((acc, cart) => {
      if(checkedItems.includes(cart.id)) {
        return acc + cart.quantity;
      }
      return acc; 
    }, 0); 
  }, [carts, checkedItems]);

  const totalPrice = useMemo(() => {
    return carts.reduce((acc, cart) => {
      if(checkedItems.includes(cart.id)) {
        return acc + cart.price * cart.quantity;
      }
      return acc;
    }, 0);
  }, [carts, checkedItems]);

const handleOrder = () => {
    if(checkedItems.length === 0) {
      showAlert("주문할 상품을 선택해주세요.");
      return;
    }

    // 주문서 작성으로 데이터 전달
    const orderData: Omit<OrderSheet, "delivery"> = {
      items: checkedItems,
      totalPrice,
      totalQuantity,
      firstBookTitle: carts[0].title,
    }

    showConfirm("주문하시겠습니까?", () => {
      navigate("/order", { state: orderData });
      // orderData가 페이지 이동시에 같이 전달됨
    })
  }
  
  ... 생략 ...
  
	  <div className="summary">
	    <CartSummary totalPrice={totalPrice} totalQuantity={totalQuantity}/>
      <Button size="large" scheme="primary" onClick={handleOrder}>주문하기</Button>
    </div>
    
    ... 생략 ...


✏️ 주문서 작성 (1)

  • 주문서 작성 구현 계획
    • 장바구니로부터 넘어온 정보를 화면에 표시하고 보존
    • 주소, 수령인, 전화번호를 입력 및 검증
    • 데이터를 가공하여 서버에 전달
  • 주문서 작성 페이지 만들기
// order.model.ts

export interface Order {
  id: number;
  createdAt: string;
  address: string;
  receiver: string;
  contact: string;
  totalQuantity: number;
  totalPrice: number;
}

export interface OrderSheet {
  items: number[];
  totalQuantity: number;
  totalPrice: number;
  firstBookTitle: string;
  delivery: {
    address: string;
    receiver: string;
    contact: Delivery;
  };
}

export interface Delivery {
  address: string;
  receiver: string;
  contact: string;
}
// pages / Order.tsx

import { useLocation } from 'react-router-dom';
import Title from '../components/common/Title';
import { CartStyle } from './Cart';
import CartSummary from '../components/cart/CartSummary';
import Button from '../components/common/Button';
import InputText from '../components/common/InputText';
import { useForm } from 'react-hook-form';
import { Delivery, OrderSheet } from '../models/order.model';

interface DeliveryForm extends Delivery {
  addressDetail : string;
}

const Order = () => {
  const location = useLocation();
  const orderDataFromCart = location.state;
  const { totalQuantity, totalPrice, firstBookTitle } = orderDataFromCart;

  const { register, handleSubmit, formState: {errors},} = useForm<DeliveryForm>();

  const handlePay = (data:DeliveryForm) => {
  const orderData: OrderSheet = {
    ...orderDataFromCart,
    delivery: {
      ...data,
      address : `${data.address} ${data.addressDetail}`
    }
  }
    // 서버로 넘겨준다
    console.log(orderData);
  }

  return (
    <>
      <Title size="large">주문서 작성</Title>
      <CartStyle>
        <div className="content">
          <div className="order-info">
            <Title size="medium" color="text">배송 정보</Title>
            <form className='delivery'>
              <fieldset>
                <label>주소</label>
                <div className="input">
                  <InputText inputType='text' {...register("address", { required: true })}/>
                </div>
                <Button size="medium" scheme="normal">주소 찾기</Button>
              </fieldset>
              {errors.address && <p className="error-text">주소를 입력해주세요</p>}
              <fieldset>
                <label>상세 주소</label>
                <div className="input">
                  <InputText inputType='text' {...register("addressDetail", { required: true })}/>
                </div>
              </fieldset>
              {errors.addressDetail && <p className="error-text">상세 주소를 입력해주세요</p>}
              <fieldset>
                <label>수령인</label>
                <div className="input">
                  <InputText inputType='text'{...register("receiver", { required: true })}/>
                </div>
              </fieldset>
              {errors.receiver && <p className="error-text">수령인을 입력해주세요</p>}
              <fieldset>
                <label>전화번호</label>
                <div className="input">
                  <InputText inputType='text' {...register("contact", { required: true })}/>
                </div>
              </fieldset>
              {errors.contact && <p className="error-text">전화번호를 입력해주세요</p>}
            </form>
          </div>
          <div className="order-info">
            <Title size="medium" color="text">주문 상품</Title>
            <strong>{firstBookTitle} 등 총 {totalQuantity} 권</strong>
          </div>
        </div>
        <div className="summary">
          <CartSummary totalPrice={totalPrice} totalQuantity={totalQuantity}/>
          <Button size="large" scheme="primary" onClick={handleSubmit(handlePay)}>결제하기</Button>
        </div>
      </CartStyle>
    </>
  )
}

export default Order
// pages . Cart.tsx

... 생략 ...

.order-info {
    h1 {
      padding: 0 0 24px 0;
    }

    border: 1px solid ${({theme}) => theme.colors.border};
    border-radius: ${({theme}) => theme.borderRadius.default};
    padding: 12px;
  }

  .delivery {
    fieldset {
      border: 0;
      margin: 0;
      padding: 0 0 12px 0;
      display: flex;
      justify-content: start;
      gap: 8px;
      label {
        width: 80px;
      }

      .input {
        flex: 1;
        
        input {
          width: 100%;
        }
      }
    }

    .error-text {
      color: red;
      margin: 0;
      padding: 0 0 12px 0;
      text-align: right;
    }
  }
`;

export default Cart

✏️ 주문서 작성 (2)

  • 주소 찾기 기능
    • open api 써서 주소찾기 기능 가져오기 → daum 주소 가져오기 사용할 것임
    • form 안에 있는 button은 기본적으로 sumbit 기능(어딘가로 제출)을 하기 때문에 일반적인 버튼의 기능을 구현하게 하려면 타입을(type=button)을 꼭 써줘야함!
// components / order / FindAddressButton.tsx

import styled from 'styled-components'
import Button from '../common/Button';
import { useEffect } from 'react';

interface Props {
  onCompleted: (address: string) => void;
}

const SCRIPT_URL = "//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js";

const FindAddressButton = ({onCompleted}: Props) => {

  // 스크립트 로드

  // 핸들러

  // 입력
  const handleOpen = () => {
    new window.daum.Postcode({
      oncomplete: (data:any) => {
        console.log(data);
      },
    }).open();
  }

  useEffect(()=> {
    const script = document.createElement("script");
    script.src = SCRIPT_URL;
    script.async = true;
    document.head.appendChild(script);

    return () => {
      document.head.removeChild(script);
    }
  },[]);

  return (
    <FindAddressButtonStyle>
      <Button type="button" size="medium" scheme="normal" 
      onClick={handleOpen}>주소 찾기</Button>
    </FindAddressButtonStyle>
  )
}

const FindAddressButtonStyle = styled.div`
`;

export default FindAddressButton
// src / window.d.ts

interface Window {
  daum: {
    Postcode: any;
  }
}

  • 얻은 정보(address)를 부모컴포넌트인 Order에게 전달해주기
// components / order / FindAddressButton.tsx

import styled from 'styled-components'
import Button from '../common/Button';
import { useEffect } from 'react';

interface Props {
  onCompleted: (address: string) => void;
}

const SCRIPT_URL = "//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js";

const FindAddressButton = ({onCompleted}: Props) => {

  // 스크립트 로드

  // 핸들러

  // 입력
  const handleOpen = () => {
    new window.daum.Postcode({
      oncomplete: (data:any) => {
        onCompleted(data.address as string);
      },
    }).open();
  }
  
  ... 생략 ...
  
	  <Button type="button" size="medium" scheme="normal" 
	  onClick={handleOpen}>주소 찾기</Button>
// pages / Order.tsx

... 생략 ...

	<FindAddressButton onCompleted={(address)=>{
    console.log("전달된 주소", address)}}/>

... 생략 ...

  • 받은 정보를 가지고 useForm의 setValue를 통해 등록해주기 → 받아온 주소를 화면에 보여주기
// pages / Order.tsx

... 생략 ...

const { register, handleSubmit, setValue, formState: {errors},} = 
useForm<DeliveryForm>();

	... 생략 ...
	
  <fieldset>
    <label>주소</label>
    <div className="input">
       <InputText inputType='text' {...register("address", { required: true })}/>
    </div>	
		<FindAddressButton onCompleted={(address)=>setValue("address", address)}/>
  </fieldset>
   {errors.address && <p className="error-text">주소를 입력해주세요</p>}
   
   ... 생략 ...

  • 받은 정보(주소)를 서버에 넘겨주기
// pages / Order.tsx

... 생략 ...

const Order = () => {
  const { showAlert, showConfirm } = useAlert();
  const navigate = useNavigate();
  
  ... 생략 ...
  
  const handlePay = (data:DeliveryForm) => {
  const orderData: OrderSheet = {
    ...orderDataFromCart,
    delivery: {
      ...data,
      address : `${data.address} ${data.addressDetail}`
    }
  }
    // 서버로 넘겨준다
    showConfirm("주문을 진행하시겠습니까?", () => {
      order(orderData).then(()=> {
        showAlert("주문이 처리되었습니다");
        navigate('/orderlist');
      });
    })
  }
  
  ... 생략 ...
  
				<div className="summary">
          <CartSummary totalPrice={totalPrice} totalQuantity={totalQuantity}/>
          <Button size="large" scheme="primary" 
          onClick={handleSubmit(handlePay)}>결제하기</Button>
        </div>
        
        ... 생략 ....

✏️ 주문 내역

  • 구현 계획

    • 저장된 주문 내역을 목록(table)로 표시
    • [자세히] 버튼으로 상세 정보 패널을 토글
  • 주문 내역 페이지 만들기

// pages / OrderList.tsx

import styled from 'styled-components'
import Title from '../components/common/Title';
import { useOrder } from '../hooks/useOrders';

const OrderList = () => {

  const { orders } = useOrder();

  console.log(orders);

  return (
    <>
      <Title size="large">주문 내역</Title>
      <OrderListStyle>
        <table>
          <thead>
            <tr>
              <th>id</th>
              <th>주문일자</th>
              <th>주소</th>
              <th>수령인</th>
              <th>전화번호</th>
              <th>전화번호</th>
              <th>대표상품명</th>
              <th>수량</th>
              <th>금액</th>
            </tr>
          </thead>
          <tbody>
            <tr></tr>
          </tbody>
        </table>
      </OrderListStyle>
    </>
  )
}

const OrderListStyle = styled.div`
  
`;

export default OrderList
// App.tsx

... 생략 ...

	{
    path: "/orderlist",
    element: <Layout><OrderList /></Layout>
  }
  
  ... 생략 ...
// api / order.api.ts

... 생략 ...

export const fetchOrders = async () => {
  const response = await httpClient.get<Order[]>("/orders");
  return response.data;
}
// hooks / useOrders.ts

import { useEffect, useState } from 'react'
import { Order } from '../models/order.model';
import { fetchOrders } from '../api/order.api';

export const useOrder = () => {
  const [orders, setOrders] = useState<Order[]>([]);

  useEffect(()=>{
    fetchOrders().then((orders) => {
      setOrders(orders);
    })
  }, []);

  return { orders };
}

  • 조회된 주문 화면에 표시하기
// pages / OrderList.tsx

import styled from 'styled-components'
import Title from '../components/common/Title';
import { useOrder } from '../hooks/useOrders';
import { formatNumber, formatDate } from '../utils/format';

const OrderList = () => {

  const { orders } = useOrder();

  return (
    <>
      <Title size="large">주문 내역</Title>
      <OrderListStyle>
        <table>
          <thead>
            <tr>
              <th>id</th>
              <th>주문일자</th>
              <th>주소</th>
              <th>수령인</th>
              <th>전화번호</th>
              <th>대표상품명</th>
              <th>수량</th>
              <th>금액</th>
            </tr>
          </thead>
          <tbody>
            {
              orders.map((order)=>(
              <tr key={order.id}>
                <td>{order.id}</td>
                <td>{formatDate(order.created_at, "YYYY.MM.DD")}</td>
                <td>{order.address}</td>
                <td>{order.receiver}</td>
                <td>{order.contact}</td>
                <td>{order.book_title}</td>
                <td>{order.total_quantity} 권</td>
                <td>{formatNumber(order.total_price)} 원</td>
              </tr>
              ))
            }
          </tbody>
        </table>
      </OrderListStyle>
    </>
  )
}

const OrderListStyle = styled.div`
  padding: 24px 0 0 0;

  table {
    width: 100%;
    border-collapse: collapse;
    border-top: 1px solid ${({theme}) => theme.colors.border};
    border-bottom: 1px solid ${({theme}) => theme.colors.border};

    th, td {
      padding: 16px;
      border-bottom: 1px solid ${({theme}) => theme.colors.border};
      text-align: center;
    }
  }
`;

export default OrderList

  • 자세히 클릭하면 주문내역(orderdetail) 서버에서 받아오기
// models /order.model.ts

... 생략 ...

export interface OrderDetailItem {
  book_id: number;
  title: string;
  author: string;
  price: number;
  quantity: number;
}

... 생략 ...
// api / order.api.ts

... 생략 ...

export const fetchOrder = async (orderId: number) => {
  const response = await httpClient.get<OrderDetailItem[]>(`/orders/${orderId}`);
  return response.data;
}
// hooks / useOrders.ts

import { useEffect, useState } from 'react'
import { Order } from '../models/order.model';
import { fetchOrder, fetchOrders } from '../api/order.api';

export const useOrder = () => {
  const [orders, setOrders] = useState<Order[]>([]);

  const [selectedItemId, setSelectedItemId] = useState<number | null>(null);

  useEffect(()=>{
    fetchOrders().then((orders) => {
      setOrders(orders);
    })
  }, []);

  const selectOrderItem = (orderId: number) => {
    fetchOrder(orderId).then((orderDetail)=>{
      console.log("orderDetail", orderDetail);
    })
  }

  return { orders, selectedItemId, selectOrderItem };
}
// pages / OrderList.tsx

... 생략 ...

const OrderList = () => {

  const { orders, selectedItemId, selectOrderItem } = useOrder();

	... 생략 ...
	
			<td>
        <Button size="small" scheme="normal" onClick={()=>{
          selectOrderItem(order.id)
        }}>자세히</Button>
      </td>
      
      
      ... 생략 ...

  • 서버에서 받아온 주문 내역 아이템 화면(자세히 클릭 하면)에서 보여주기
    • Order(interface) 타입에 detail 정보를 받아서 주문 내역에서 보여줘야함 → Order를 상속하는 새로운 타입 선언
    • 자세히를 여러번 클릭하면 계속 요청이 발생함 → 그 요청을 계속 하는 것이 아니라, 한 번 리스트의 데이터에 바인딩 시키면 , 즉 데이터가 존재한다면 더 이상 요청하지 않음 → 요청 방어를 해준다
// models / order.model.ts

... 생략 ...

export interface OrderListItem extends Order {
  detail?: OrderDetailItem[];
}
// hooks / useOrders.ts

import { useEffect, useState } from 'react'
import { OrderListItem } from '../models/order.model';
import { fetchOrder, fetchOrders } from '../api/order.api';

export const useOrder = () => {
  const [orders, setOrders] = useState<OrderListItem[]>([]);

  const [selectedItemId, setSelectedItemId] = useState<number | null>(null);

  useEffect(()=>{
    fetchOrders().then((orders) => {
      setOrders(orders);
    })
  }, []);

  const selectOrderItem = (orderId: number) => {
    // 요청 방어
    if(orders.filter((item) => item.id === orderId)[0].detail) {
      setSelectedItemId(orderId);
      return;
    }
    fetchOrder(orderId).then((orderDetail) => {
	    setSelectedItemId(orderId);
      setOrders(
        orders.map((item) => {
          if(item.id === orderId) {
            return {
              ...item,
              detail: orderDetail,
            };
          }
          return item;
        })
      )
    })
  }

  return { orders, selectedItemId, selectOrderItem };
}

자세히 누르면 네트워크 요청에 데이터가 잘 들어가 있는 것 확인 가능

  • 훅(useOrders)에서 가져온 정보를 화면에 뿌려주기 → slsectedItemId 사용해서 화면 구현 → 화면 자세히 정보 노출
// pages / OrderList.tsx

import styled from 'styled-components';
import Title from '../components/common/Title';
import { useOrder } from '../hooks/useOrders';
import { formatNumber, formatDate } from '../utils/format';
import Button from '../components/common/Button';
import React from 'react';

const OrderList = () => {

  const { orders, selectedItemId, selectOrderItem } = useOrder();

  return (
    <>
      <Title size="large">주문 내역</Title>
      <OrderListStyle>
        <table>
          <thead>
            <tr>
              <th>id</th>
              <th>주문일자</th>
              <th>주소</th>
              <th>수령인</th>
              <th>전화번호</th>
              <th>대표상품명</th>
              <th>수량</th>
              <th>금액</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            {
              orders.map((order)=>(
                <React.Fragment key={order.id}>
                  <tr>
                    <td>{order.id}</td>
                    <td>{formatDate(order.created_at, "YYYY.MM.DD")}</td>
                    <td>{order.address}</td>
                    <td>{order.receiver}</td>
                    <td>{order.contact}</td>
                    <td>{order.book_title}</td>
                    <td>{order.total_quantity} 권</td>
                    <td>{formatNumber(order.total_price)} 원</td>
                    <td>
                      <Button size="small" scheme="normal" onClick={() => {
                        selectOrderItem(order.id)
                      }}>자세히</Button>
                    </td>
                  </tr>
                  {
                    selectedItemId === order.id && (
                      <tr>
                        <td></td>
                        <td colSpan={8}>
                          <ul className='detail'>
                            {
                              order?.detail && order.detail.map((item) => (
                                <li key={item.book_id}>
                                  <div>
                                    <span>{item.book_id}</span>
                                    <span>{item.author}</span>
                                    <span>{formatNumber(item.price)} 원</span>
                                  </div>
                                </li>
                              ))
                            }
                          </ul>
                        </td>
                      </tr>
                    )
                  }
                </React.Fragment>
              ))
            }
          </tbody>
        </table>
      </OrderListStyle>
    </>
  )
}

const OrderListStyle = styled.div`
  padding: 24px 0 0 0;

  table {
    width: 100%;
    border-collapse: collapse;
    border-top: 1px solid ${({theme}) => theme.colors.border};
    border-bottom: 1px solid ${({theme}) => theme.colors.border};

    th, td {
      padding: 16px;
      border-bottom: 1px solid ${({theme}) => theme.colors.border};
      text-align: center;
    }

    .detail {
      margin: 0;
      li {
        list-style: square;
        text-align: left;
        div {
          display: flex;
          padding: 8px 12px;
          gap: 8px;
        }
      }
    }
  }
`;

export default OrderList

profile
프론트엔드개발자가 되고 싶어서 열심히 땅굴 파는 자

0개의 댓글