๐ŸŽฏ ์žฅ๋ฐ”๊ตฌ๋‹ˆ ๋ชฉ๋ก๊ณผ ์ฃผ๋ฌธ์„œ ์ž‘์„ฑ ํŽ˜์ด์ง€๋ฅผ ์ œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.


๐Ÿ“— Today I Learned

์žฅ๋ฐ”๊ตฌ๋‹ˆ ๋ชฉ๋ก

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 { useMemo, useState } from 'react';
import Empty from '../components/common/Empty';
import { FaShoppingCart } from 'react-icons/fa';
import CartSummary from '../components/cart/CartSummary';
import Button from '../components/common/Button';
import { useAlert } from '../hooks/useAlert';
import { OrderSheet } from '../models/order.model';
import { useNavigate } from 'react-router-dom';
import { Theme } from '../style/theme';

export default function Cart() {
  const { carts, isEmpty, deleteCartItem } = useCart();
  const [checkedItems, setCheckedItems] = useState<number[]>([]);
  const { showAlert, showConfirm } = useAlert();
  const navigate = useNavigate();
 
  const handleCheck = (id: number) => {
    if (checkedItems.includes(id)) {
      setCheckedItems(checkedItems.filter((item) => item !== id));
    } else {
      setCheckedItems([...checkedItems, id]);
    }
  };
  
  const handleItemDelete = (id: number) => {
    deleteCartItem(id);
  };
  
  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 });
    });
  };

  return (
    <>
      <Title size='large'>์žฅ๋ฐ”๊ตฌ๋‹ˆ</Title>
      <StyledCart>
        {!isEmpty && (
          <>
            <div className='content'>
              {carts.map((cart) => (
                <CartItem
                  key={cart.id}
                  cart={cart}
                  checkedItems={checkedItems}
                  onCheck={handleCheck}
                  onDelete={handleItemDelete}
                />
              ))}
            </div>
            <div className='summary'>
              <CartSummary
                totalPrice={totalPrice}
                totalQuantity={totalQuantity}
              />
              <Button size='large' scheme='primary' onClick={handleOrder}>
                ์ฃผ๋ฌธํ•˜๊ธฐ
              </Button>
            </div>
          </>
        )}
        {isEmpty && (
          <Empty
            icon={<FaShoppingCart />}
            title='์žฅ๋ฐ”๊ตฌ๋‹ˆ๊ฐ€ ๋น„์—ˆ์Šต๋‹ˆ๋‹ค.'
            description='์žฅ๋ฐ”๊ตฌ๋‹ˆ๋ฅผ ์ฑ„์›Œ๋ณด์„ธ์š”.'
          />
        )}
      </StyledCart>
    </>
  );
}

export const StyledCart = styled.div<{ theme: Theme }>`
  display: flex;
  gap: 24px;
  justify-content: space-between;
  padding: 24px 0 0 0;

  .content {
    flex: 1;
    display: flex;
    flex-direction: column;
    gap: 12px;
  }

  .summary {
    display: flex;
    flex-direction: column;
    gap: 24px;
  }

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

    border: 1px solid ${({ theme }) => theme.color.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;
    }
  }
`;
  • useCart() : carts(์žฅ๋ฐ”๊ตฌ๋‹ˆ ๋ชฉ๋ก), isEmpty(๋น„์—ˆ๋Š”์ง€ ์—ฌ๋ถ€), deleteCartItem()(์‚ญ์ œ ํ•จ์ˆ˜)๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.

  • useState<number[]>([]) : ์ฒดํฌ๋œ ์ƒํ’ˆ์˜ ID๋ฅผ ์ˆซ์ž๋กœ ๋œ ๋ฐฐ์—ด๋กœ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

  • useAlert() : showAlert()์™€ showConfirm()์œผ๋กœ ์•Œ๋ฆผ์ฐฝ์ด๋‚˜ ํ™•์ธ์ฐฝ์„ ๋„์›๋‹ˆ๋‹ค.

  • useNavigate() : React Router์—์„œ ์ œ๊ณตํ•˜๋Š” ํ›…์œผ๋กœ, ํŽ˜์ด์ง€ ์ด๋™์„ ๋•์Šต๋‹ˆ๋‹ค.

  • handleCheck : ์‚ฌ์šฉ์ž๊ฐ€ ์ฒดํฌ๋ฐ•์Šค๋ฅผ ๋ˆ„๋ฅด๋ฉด ํ•ด๋‹น ์ƒํ’ˆ ID๋ฅผ checkedItems์—์„œ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค.

  • handleItemDelete : ํ›…์„ ํ†ตํ•ด ๊ฐ€์ ธ์˜จ deleteCartItem()(์‚ญ์ œ ํ•จ์ˆ˜)๋ฅผ ์ด์šฉํ•ด์„œ ์ƒํ’ˆ์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.

  • totalQuantity : ์žฅ๋ฐ”๊ตฌ๋‹ˆ๋ชฉ๋ก๊ณผ ์ฒดํฌ๋œ ๋ชฉ๋ก์ด ๋ฐ”๋€Œ๋ฉด, ์žฅ๋ฐ”๊ตฌ๋‹ˆ ์•„์ดํ…œ์ด ์ฒดํฌ๋œ ์•„์ดํ…œ์ด๋ฉด ์ˆ˜๋Ÿ‰์„ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค.

๐Ÿค” useMemo()๋Š” ์–ด๋–ค ํ›…์ด์ง€?

import { useMemo } from 'react';

const ๋ณ€์ˆ˜๋ช… = useMemo(() => {
  // "ํ•จ์ˆ˜"
}, ["์˜์กด์„ฑ ๋ฐฐ์—ด"]);

์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋ฆฌ๋ Œ๋”๋ง๋  ๋•Œ, ๋ถˆํ•„์š”ํ•œ ๊ณ„์‚ฐ์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•œ ํ›…์ž…๋‹ˆ๋‹ค.
"์˜์กด์„ฑ ๋ฐฐ์—ด"์ด ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค "ํ•จ์ˆ˜"๊ฐ€ ์‹คํ–‰๋˜๊ณ , ๋ณดํ†ต ๊ณ„์‚ฐ ๋น„์šฉ์ด ํฐ ์—ฐ์‚ฐ์ด๋‚˜ ๋ฐ์ดํ„ฐ ํ•„ํ„ฐ๋ง์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.

  • totalPrice : ์žฅ๋ฐ”๊ตฌ๋‹ˆ๋ชฉ๋ก๊ณผ ์ฒดํฌ๋œ ๋ชฉ๋ก์ด ๋ฐ”๋€Œ๋ฉด, ์žฅ๋ฐ”๊ตฌ๋‹ˆ ์•„์ดํ…œ์ด ์ฒดํฌ๋œ ์•„์ดํ…œ์ด๋ฉด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค.

  • handleOrder : ์ฒดํฌ๋œ ์ƒํ’ˆ์ด ์—†์œผ๋ฉด ๊ฒฝ๊ณ ์ฐฝ์„ ๋„์šฐ๊ณ , ์ฒดํฌ๋œ ์ƒํ’ˆ์ด ์žˆ๋‹ค๋ฉด, ํ™•์ธ์ฐฝ ๋„์šด ๋’ค์— /order ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜๋ฉด์„œ orderData ๋ฐ์ดํ„ฐ๋ฅผ ํ•จ๊ป˜ ๋„˜๊น๋‹ˆ๋‹ค.




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 { Theme } from '../../style/theme';
import CheckIconButton from './CheckIconButton';
import { useMemo } from 'react';
import { useAlert } from '../../hooks/useAlert';

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

export default function CartItem({
  cart,
  checkedItems,
  onCheck,
  onDelete,
}: Props) {
  const { showConfirm } = useAlert();

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

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

  const handleDelete = () => {
    showConfirm('์ •๋ง ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?', () => {
      onDelete(cart.id);
    });
  };

  return (
    <StyledCartItem>
      <div className='info'>
        <div className='check'>
          <CheckIconButton isChecked={isChecked} onCheck={handleCheck} />
        </div>
        <div>
          <Title size='medium' 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' onClick={handleDelete}>
        ์žฅ๋ฐ”๊ตฌ๋‹ˆ ์‚ญ์ œ
      </Button>
    </StyledCartItem>
  );
}

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

  .info {
    display: flex;
    align-items: start;
    flex: 1;

    .check {
      width: 40px;
      flex-shrink: 0;
      margin-top: 3px;
    }
  }

  p {
    margin: 0;
    padding: 0 0 8px 0;
  }
`;
  • useAlert() : showConfirm()์œผ๋กœ ํ™•์ธ์ฐฝ์„ ๋„์›๋‹ˆ๋‹ค.

  • isChecked : checkedItems ์™€ cart.id ๊ฐ€ ๋ฐ”๋€” ๋•Œ ๋งˆ๋‹ค checkedItems ์•ˆ์— ์ด cart.id๊ฐ€ ๋“ค์–ด์žˆ๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

  • handleCheck : ์‚ฌ์šฉ์ž๊ฐ€ ์ฒดํฌ๋ฐ•์Šค๋ฅผ ํด๋ฆญํ•˜๋ฉด onCheck(cart.id)๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ›ฃ๏ธ ์–ด๋–ป๊ฒŒ ์—ฐ๊ฒฐ๋˜๋Š” ๊ฑธ๊นŒ?

CheckIconButton ํด๋ฆญ โ†’ handleCheck() ์‹คํ–‰ โ†’ onCheck(cart.id) ์‹คํ–‰ โ†’ Cart.tsx์˜ handleCheck(id) ์‹คํ–‰ โ†’ checkedItems ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ

  • handleDelete : ํ™•์ธ์ฐฝ์„ ๋„์šด ํ›„, onDelete(cart.id)๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.

CheckIconButton.tsx

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

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

export default function CheckIconButton({ isChecked, onCheck }: Props) {
  return (
    <StyledCheckIconButton onClick={onCheck}>
      {isChecked ? <FaRegCheckCircle /> : <FaRegCircle />}
    </StyledCheckIconButton>
  );
}

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

  svg {
    width: 24px;
    height: 24px;
  }
`;
  • isChecked : true๋ฉด ์ฒดํฌ๋œ ๋™๊ทธ๋ผ๋ฏธ๋ฅผ ๋ณด์—ฌ์ฃผ๊ณ , false๋ฉด ์ฒดํฌ ์•ˆ ๋œ ๋™๊ทธ๋ผ๋ฏธ๋ฅผ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค.

  • onCheck : ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ์‹คํ–‰๋˜๋Š” ํ•จ์ˆ˜๋กœ CartItem์—์„œ ๋„˜๊ฒจ์ค€ handleCheck๋ฅผ ๋œปํ•ฉ๋‹ˆ๋‹ค.




CartSummary.tsx

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

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

export default function CartSummary({ totalQuantity, totalPrice }: Props) {
  return (
    <StyledCartSummary>
      <h1>์ฃผ๋ฌธ ์š”์•ฝ</h1>
      <dl>
        <dt>์ด ์ˆ˜๋Ÿ‰</dt>
        <dd>{totalQuantity} ๊ถŒ</dd>
      </dl>
      <dl>
        <dt>์ด ๊ธˆ์•ก</dt>
        <dd>{formatNumber(totalPrice)} ์›</dd>
      </dl>
    </StyledCartSummary>
  );
}

const StyledCartSummary = styled.div<{ theme: Theme }>`
  border: 1px solid ${({ theme }) => theme.color.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;
    }
  }
`;
  • Cart.tsx์—์„œ ๊ณ„์‚ฐํ•œ totalQuantity์™€ totalPrice๋ฅผ ๋ฐ›์•„์™€์„œ ์š”์•ฝ ์ •๋ณด๋ฅผ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค.

  • <dl> ํƒœ๊ทธ๋Š” definition list๋ฅผ ๋งํ•˜๊ณ , ์ •๋ณด์˜ ์ œ๋ชฉ(<dt>)๊ณผ ๋‚ด์šฉ(<dd>) ์ง์œผ๋กœ ๊ตฌ์„ฑ๋œ ๋ชฉ๋ก์ž…๋‹ˆ๋‹ค.
    ์ถœ์ฒ˜: mdn


์‹คํ–‰ ํ™”๋ฉด




์ฃผ๋ฌธ์„œ

Order.tsx

import { useLocation, useNavigate } from 'react-router-dom';
import { StyledCart } from './Cart';
import Title from '../components/common/Title';
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';
import FindAddressButton from '../components/order/FindAddressButton';
import { order } from '../api/order.api';
import { useAlert } from '../hooks/useAlert';

interface DeliveryForm extends Delivery {
  addressDetail: string;
}

export default function Order() {
  const { showAlert, showConfirm } = useAlert();
  const navigate = useNavigate();
  const location = useLocation();
  const orderDataFromCart = location.state;
  const { totalPrice, totalQuantity, firstBookTitle } = orderDataFromCart;
  
  const {
    register,
    handleSubmit,
    setValue,
    formState: { errors },
  } = useForm<DeliveryForm>();
  
  const handlePay = (data: DeliveryForm) => {
    const orderData: OrderSheet = {
      ...orderDataFromCart,
      delivery: {
        ...data,
        address: `${data.address} ${data.addressDetail}`,
      },
    };

    showConfirm('์ฃผ๋ฌธ์„ ์ง„ํ–‰ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?', () => {
      order(orderData).then(() => {
        showAlert('์ฃผ๋ฌธ์ด ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.');
        navigate('/orderlist');
      });
    });
  };

  return (
    <>
      <Title size='large'>์ฃผ๋ฌธ์„œ ์ž‘์„ฑ</Title>
      <StyledCart>
        <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>
                <FindAddressButton
                  onCompleted={(address) => {
                    setValue('address', address);
                  }}
                />
              </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>
      </StyledCart>
    </>
  );
}
  • orderDataFromCart : Cart.tsx์—์„œ navigate('/order', { state: orderData })๋กœ ๋„˜๊ธด ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์˜ต๋‹ˆ๋‹ค.

    • items, totalPrice, totalQuantity, firstBookTitle ์ •๋ณด๊ฐ€ ๋‹ด๊ฒจ์žˆ์Šต๋‹ˆ๋‹ค.
  • register๋กœ input ์—ฐ๊ฒฐ, errors๋กœ ์—๋Ÿฌ ์ฒ˜๋ฆฌ, setValue๋Š” ์ฃผ์†Œ ์ฐพ๊ธฐ ํ›„ ๊ฐ’ ๋„ฃ์„ ๋•Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

๐Ÿค” useForm()๋Š” ์–ด๋–ค ํ›…์ด์ง€?

React Hook Form ์—์„œ ์ œ๊ณตํ•˜๋Š” ํผ ์ƒํƒœ ๊ด€๋ฆฌ์šฉ ํ›…์ž…๋‹ˆ๋‹ค.
HTML <form> ์•ˆ์—์„œ ์ž…๋ ฅ๊ฐ’๋“ค์„ ํŽธํ•˜๊ฒŒ ๋‹ค๋ฃจ๊ณ , ๊ฒ€์ฆ๋„ ์‰ฝ๊ฒŒ ํ•  ์ˆ˜ ์žˆ๋„๋ก ๋„์™€์ค๋‹ˆ๋‹ค.

<InputText {...register('address', { required: true })} />
  • input์„ ๋“ฑ๋ก(register)ํ•ด์„œ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๊ฒŒ ํ•ด์ค๋‹ˆ๋‹ค.
onClick={handleSubmit(handlePay)}
  • ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ๋ฅผ ๋จผ์ € ํ•˜๊ณ , ํ†ต๊ณผํ•˜๋ฉด handlePay ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.
  • โœจ handlePay ํ•จ์ˆ˜๋งŒ ๋„˜๊ธฐ๋ฉด ๊ทธ ์•ˆ์— data: DeliveryForm์ด ์ž๋™์œผ๋กœ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค.
{errors.address && <p>์ฃผ์†Œ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.</p>}
  • ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์‹คํŒจ ์‹œ ์—๋Ÿฌ ์ •๋ณด๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.
<FindAddressButton
  onCompleted={(address) => {
    setValue('address', address);
  }}
/>
  • ์ฃผ์†Œ ๊ฒ€์ƒ‰ํ•ด์„œ ์ฐพ์€ ์ฃผ์†Œ๋ฅผ ํผ ํ•„๋“œ์— ์ง์ ‘ ๋„ฃ์–ด์ค„ ๋•Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
  • handlePay : ๋ฐฐ์†ก ์ •๋ณด์™€ ์žฅ๋ฐ”๊ตฌ๋‹ˆ ๋ฐ์ดํ„ฐ๋ฅผ ํ•ฉ์ณ์„œ orderData๋ฅผ ๋งŒ๋“ค๊ณ  API (order()) ํ˜ธ์ถœ ํ›„ ์„ฑ๊ณตํ•˜๋ฉด ์„ฑ๊ณต ๋ฉ”์„ธ์ง€๋ฅผ ๋ณด์—ฌ์ฃผ๊ณ  /orderlist ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.



FindAddressButton.tsx

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';

export default function FindAddressButton({ onCompleted }: Props) {
  const handleOpen = () => {
    new window.daum.Postcode({
      oncompleted: (data: any) => {
        onCompleted(data.address as string);
      },
    }).open();
  };

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

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

  return (
    <Button type='button' size='medium' scheme='normal' onClick={handleOpen}>
      ์ฃผ์†Œ ์ฐพ๊ธฐ
    </Button>
  );
}
  • onCompleted : ์ฃผ์†Œ ์„ ํƒ์ด ์™„๋ฃŒ๋˜์—ˆ์„ ๋•Œ ์‹คํ–‰ํ•  ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋กœ Order.tsx์—์„œ setValue('address', ์ฃผ์†Œ)๋กœ ์—ฐ๊ฒฐํ•ฉ๋‹ˆ๋‹ค.

  • handleOpen : window.daum.Postcode()๋Š” ๋‹ค์Œ์—์„œ ์ œ๊ณตํ•˜๋Š” ์šฐํŽธ๋ฒˆํ˜ธ ๊ฒ€์ƒ‰ ํŒ์—…์ž…๋‹ˆ๋‹ค.

    • ํŒ์—…์—์„œ ์ฃผ์†Œ๋ฅผ ์„ ํƒํ•˜๋ฉด oncompleted ์ฝœ๋ฐฑ์ด ์‹คํ–‰๋˜๊ณ , ๊ทธ ์•ˆ์—์„œ ์„ ํƒํ•œ ์ฃผ์†Œ(data.address)๋ฅผ onCompleted()๋กœ ์ „๋‹ฌํ•ด์ค๋‹ˆ๋‹ค.
  • useEffect : ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ฒ˜์Œ ๋งˆ์šดํŠธ๋  ๋•Œ์—๋งŒ ๋‹ค์Œ ์ฃผ์†Œ ๊ฒ€์ƒ‰ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ต๋‹ˆ๋‹ค. document.head์— ์ง์ ‘ <script> ํƒœ๊ทธ๋ฅผ ์‚ฝ์ž…ํ•˜๊ณ , ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์‚ฌ๋ผ์งˆ ๋•Œ๋Š” ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค.


๐Ÿ“ฌ ๋‹ค์Œ ์šฐํŽธ๋ฒˆํ˜ธ ์„œ๋น„์Šค ์ด์šฉ ๋ฐฉ๋ฒ•

1. cdn ๋งํฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

<head> ํƒœ๊ทธ์— <script src="//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js"></script> ๋ฅผ ์‚ฝ์ž…ํ•ฉ๋‹ˆ๋‹ค.

2. ๊ธ€๋กœ๋ฒŒํ•˜๊ฒŒ ํƒ€์ž…์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

// window.d.ts
interface Window {
  daum: {
    Postcode: any;
  };
}

window.daum์€ Daum ์ฃผ์†Œ ๊ฒ€์ƒ‰ API๊ฐ€ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ํ†ตํ•ด ๋Ÿฐํƒ€์ž„์— ์ „์—ญ์œผ๋กœ ์ฃผ์ž…๋˜๋Š” ๊ฐ์ฒด์ด๋ฏ€๋กœ ์ปดํŒŒ์ผ ์—๋Ÿฌ๊ฐ€ ๋‚˜๊ธฐ ๋•Œ๋ฌธ์— ํƒ€์ž…์„ ์ •์˜ํ•ด์ค๋‹ˆ๋‹ค.

3. oncomeplete : ํŒ์—…์—์„œ ๊ฒ€์ƒ‰๊ฒฐ๊ณผ ํ•ญ๋ชฉ์„ ํด๋ฆญํ–ˆ์„๋•Œ ์‹คํ–‰ํ•  ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

new window.daum.Postcode({
    oncomplete: function(data) {
        //data๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์„ ํƒํ•œ ์ฃผ์†Œ ์ •๋ณด๋ฅผ ๋‹ด๊ณ  ์žˆ๋Š” ๊ฐ์ฒด
      // ํŒ์—…์—์„œ ๊ฒ€์ƒ‰๊ฒฐ๊ณผ ํ•ญ๋ชฉ์„ ํด๋ฆญํ–ˆ์„๋•Œ ์‹คํ–‰ํ•  ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๋Š” ๋ถ€๋ถ„์ž…๋‹ˆ๋‹ค.
    }
}).open();

์‹คํ–‰ ํ™”๋ฉด




์ฃผ๋ฌธ ๋‚ด์—ญ

OrderList.tsx

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

export default function OrderList() {
  const { orders, selectedItemId, selectOrderItem } = useOrders();

  return (
    <>
      <Title size='large'>์ฃผ๋ฌธ ๋‚ด์—ญ</Title>
      <StyledOrderList>
        <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.createdAt, 'YYYY.MM.DD')}</td>
                  <td>{order.address}</td>
                  <td>{order.receiver}</td>
                  <td>{order.contact}</td>
                  <td>{order.bookTitle}</td>
                  <td>{order.totalQuantity} ๊ถŒ</td>
                  <td>{order.totalPrice} ์›</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.bookId}>
                              <div>
                                <span>{item.title}</span>
                                <span>{item.author}</span>
                                <span>{formatNumber(item.price)} ์›</span>
                              </div>
                            </li>
                          ))}
                      </ul>
                    </td>
                  </tr>
                )}
              </React.Fragment>
            ))}
          </tbody>
        </table>
      </StyledOrderList>
    </>
  );
}

const StyledOrderList = styled.div<{ theme: Theme }>`
  padding: 24px 0 0 0;

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

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

  .detail {
    margin: 0;
    li {
      list-style: square;
      text-align: left;
      div {
        display: flex;
        padding: 8px 12px;
        gap: 8px;
      }
    }
  }
`;
  • useOrders()๋ผ๋Š” ์ปค์Šคํ…€ ํ›…์„ ์‚ฌ์šฉํ•ด์„œ orders(์ฃผ๋ฌธ ๋ฆฌ์ŠคํŠธ ๋ฐฐ์—ด), selectedItemId(์„ ํƒ๋œ ์ฃผ๋ฌธ์˜ id), selectOrderItem(์ฃผ๋ฌธ ์ƒ์„ธ ์ •๋ณด ๋ถˆ๋Ÿฌ์˜ค๋Š” ํ•จ์ˆ˜)๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.

  • ์ฒซ๋ฒˆ์งธ <tr> ํƒœ๊ทธ๋Š” ์ฃผ๋ฌธ ์š”์•ฝ ์ •๋ณด๋ฅผ ํ‘œ๊ธฐํ•˜๊ณ , ๋‘ ๋ฒˆ์งธ <tr> ํƒœ๊ทธ๋Š” ํ•ด๋‹น ์ฃผ๋ฌธ์ด ์„ ํƒ๋œ ๊ฒฝ์šฐ์—๋งŒ ์ƒ์„ธ ์•„์ดํ…œ๋“ค ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค.

    • <table>์€ ํ‘œ์˜ ์‹œ์ž‘, <thead>๋Š” ํ…Œ์ด๋ธ”์˜ ์ œ๋ชฉ ์˜์—ญ,<tr>์€ ํ•œ ์ค„, <th>์€ ์ œ๋ชฉ์šฉ ์…€, <tbody>์€ ๋‚ด์šฉ ์˜์—ญ, <td>์€ ์‹ค์ œ ๋ฐ์ดํ„ฐ ๋“ค์–ด๊ฐ€๋Š” ์นธ์„ ๋งํ•ฉ๋‹ˆ๋‹ค.

      ์ถœ์ฒ˜: mdn

โœจ map์œผ๋กœ ๋Œ๋ฆด ๋•Œ์—๋Š” key ๊ฐ’์ด ํ•„์š”ํ•˜๊ธฐ ๋•Œ๋ฌธ์— React.Fragment๋ฅผ ์จ์„œ <tr> 2๊ฐœ๋ฅผ ๋ฌถ์–ด์ฃผ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.


์‹คํ–‰ ํ™”๋ฉด




โœ๏ธ ํšŒ๊ณ 

์„ค๋ช…์—†์ด ๋„˜์–ด๊ฐ”๋˜ ๋ถ€๋ถ„๋“ค์„ ์™œ ์“ฐ๋Š”์ง€ ์ •๋ฆฌํ•˜๋ฉด์„œ ์ ์œผ๋‹ˆ ๋” ์ดํ•ด๊ฐ€ ์‰ฝ๊ฒŒ ๋˜๊ณ  ์™œ ๊ทธ๋ ‡๊ฒŒ ์ ์—ˆ๋Š”์ง€ ๋” ๊น”๋”ํ•˜๊ฒŒ ์ดํ•ด๊ฐ€ ๋˜๋Š” ๊ฒƒ ๊ฐ™๋‹ค.

profile
๐ŸŒฑ๊ฐœ๋ฐœ ๊ธฐ๋ก์žฅ

0๊ฐœ์˜ ๋Œ“๊ธ€