REACT/쇼핑몰 프로젝트

[REACT] React + Typescript + Vite + SCSS로 만드는 쇼핑몰 개인프로젝트 (8/10)

예글 2024. 1. 27. 15:11

💡 오늘 할 것!

  1. 어드민 - 상품 목록
  2. 어드민 - 상품 추가
  3. 어드민 - 상품 수정
  4. 어드민 - 상품 삭제

1️⃣ 어드민 - 상품 목록 

1. db 수정

- root/tempdbbuilder.ts 생성

import { v4 as uuid } from "uuid";
import { DBField, writeDB } from "./src/dbController";

const db = Array.from({ length: 100 }).map((_, i) => ({
  id: uuid(),
  imageUrl: `https://source.unsplash.com/200x150/?nature/${i}`,
  price: 1000 + Math.floor(Math.random() * 20) * 500,
  title: `임시상품${i}`,
  description: `임시상세내용${i}`,
  createdAt: 1642424841540 + 1000 * 60 * 60 * 5 * i,
}));

writeDB(DBField.PRODUCTS, db);

 

터미널에서 server로 이동 후

ts-node tempdbbuilder.ts

 

실행시켜주면!

 

- server/src/db/products.json

[
  {
    "id": "5bcca7f9-dc1e-4fa8-ac89-3f9081fd7e2b",
    "imageUrl": "https://source.unsplash.com/200x150/?nature/0",
    "price": 7000,
    "title": "임시상품0",
    "description": "임시상세내용0",
    "createdAt": 1642424841540
  },
  {
    "id": "c0cc70fc-868b-477d-94d1-2c7b4f77943e",
    "imageUrl": "https://source.unsplash.com/200x150/?nature/1",
    "price": 7500,
    "title": "임시상품1",
    "description": "임시상세내용1",
    "createdAt": 1642442841540
  },
  {
    "id": "4349b8e8-c645-41a3-babc-61b4a409b604",
    "imageUrl": "https://source.unsplash.com/200x150/?nature/2",
    "price": 7000,
    "title": "임시상품2",
    "description": "임시상세내용2"
  },
  {
    "id": "ea2dfa92-3dc0-45e4-913c-89fe911ba8d6",
    "imageUrl": "https://source.unsplash.com/200x150/?nature/3",
    "price": 2000,
    "title": "임시상품3",
    "description": "임시상세내용3",
    "createdAt": 1642478841540
  },
  ...
  ]

 

짠! 순식간에 아이템 100개 생성! 짱 신기🫣

 

2️⃣ delete 컨트롤

- 상품이 삭제되었을 때 user에게는 상품이 안 보이지만 admin에게는 보여야 함!

 

1. server/src/resolver/product.ts

Query: {
    products: (parent, { cursor = "", showDeleted = false }, { db }) => {
      const filteredDB = showDeleted
        ? db.products
        : db.products.filter((product) => !!product.createdAt);
      const fromIndex =
        filteredDB.findIndex((product) => product.id === cursor) + 1;
      return filteredDB.slice(fromIndex, fromIndex + 15) || [];
    },

 

- showDeleted = false를 기본값으로 가져오기

  • showDeleted가 true : 모든 상품을 보여준다.( = db.products)
  • showDeleted가 false : 삭제된 상품 빼고 보여준다.
    • filter를 통해 false값을 제외 하고 true값 필터링 == createdAt이 있는 값들을 반환

2. server/src/schema/product.ts

 extend type Query {
   products(cursor: ID, showDeleted: Boolean): [Product!]
   product(id: ID!): Product!
 }

 

- 스키마에도 showDeleted 추가

 

3. client/src/graphql/products.ts

const GET_PRODUCTS = gql`
  query GET_PRODUCTS($cursor: ID, $showDeleted: Boolean) {
    products(cursor: $cursor, showDeleted: $showDeleted) {
      id
      imageUrl
      price
      title
      description
      createdAt
    }
  }
`;

 

- graphql에도 showDeleted 추가

 

4. client/src/pages/products/index.tsx

const { data, isSuccess, isFetchingNextPage, fetchNextPage, hasNextPage } =
    useInfiniteQuery<Products>(
      // 배열로 감싸고 false 받아오기
      [QueryKeys.PRODUCTS, false],
      ({ pageParam = "" }) =>
        graphqlFetcher<Products>(GET_PRODUCTS, { cursor: pageParam }),
      {
        getNextPageParam: (lastPage, allPages) => {
          return lastPage.products.at(-1)?.id;
        },
      }
    );

 

- user 상품 목록 페이지 수정

 

5. client/src/pages/admin/index.tsx

import { useInfiniteQuery, useQuery } from "react-query";
import { QueryKeys, graphqlFetcher } from "../../queryClient";

import GET_PRODUCTS, { Products } from "../../graphql/products";
import ProductList from "../../assets/components/product/list";
import { useCallback, useEffect, useRef, useState } from "react";
import useInfiniteScroll from "../../assets/hooks/useInfiniteScroll";

const AdminPage = () => {
  // 무한 스크롤을 위해
  const fetchMoreRef = useRef<HTMLDivElement>(null);
  const intersecting = useInfiniteScroll(fetchMoreRef);

  // 데이터 가져오기, type 정의
  const { data, isSuccess, isFetchingNextPage, fetchNextPage, hasNextPage } =
    useInfiniteQuery<Products>(
      // 배열로 감싸고 true 받아오기
      [QueryKeys.PRODUCTS, true],
      ({ pageParam = "" }) =>
        graphqlFetcher<Products>(GET_PRODUCTS, {
          cursor: pageParam,
          showDeleted: true,
        }),
      {
        getNextPageParam: (lastPage, allPages) => {
          return lastPage.products.at(-1)?.id;
        },
      }
    );

  useEffect(() => {
    if (!intersecting || !isSuccess || !hasNextPage || isFetchingNextPage)
      return;

    fetchNextPage();
  }, [intersecting]);

  return (
    <div>
      <h2>관리자 - 상품목록</h2>

      <ProductList list={data?.pages || []} />

      <div ref={fetchMoreRef} />
    </div>
  );
};

export default AdminPage;

 

6. db에서 상품2의 createdAt을 지운 후 확인

 

- user에서는 상품2가 안 보이지만 admin에서는 보이는 것 확인!

 

3️⃣ admin - 상품 추가

1. addForm 생성

 

- client/src/assets/components/admin/addForm.tsx

import { SyntheticEvent } from "react";
import { useMutation } from "react-query";
import { ADD_PRODUCT, Product } from "../../../graphql/products";
import { graphqlFetcher } from "../../../queryClient";
import arrToObj from "../../../util/arrToObj";

type OmittedProduct = Omit<Product, "id" | "createdAt">;

const AddForm = () => {
  const { mutate: addProduct } = useMutation(
    ({ title, imageUrl, price, description }: OmittedProduct) =>
      graphqlFetcher(ADD_PRODUCT, { title, imageUrl, price, description })
  );

  // 제출 버튼
  const handleSubmit = (e: SyntheticEvent) => {
    e.preventDefault(); // 다른 페이지로 이동하는 것 방지
    const formData = arrToObj([...new FormData(e.target as HTMLFormElement)]);
    formData.price = Number(formData.price); // price만 number로 형변환
    addProduct(formData as OmittedProduct);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        상품명:
        <input name="title" type="text" required />
      </label>
      <label>
        이미지URL:
        <input name="imageUrl" type="text" required />
      </label>
      <label>
        상품가격:
        <input name="price" type="text" required />
      </label>
      <label>
        상세:
        <textarea name="description" />
      </label>

      <button type="submit">등록</button>
    </form>
  );
};

export default AddForm;

 

✏️  type PartialProduct = Partial<Product> 

-> 부분집합으로 모든 필드가 optional 해짐

 

✏️ type OmitProduct = Omit<Product, 'id' | 'createdAt'>

-> 지정한 타입을 뺀 나머지 타입

 

- client/src/pages/admin/index.tsx 에 <AddForm /> 추가

 

2. client/src/graphql/products.ts

export const ADD_PRODUCT = gql`
  mutation ADD_PRODUCT(
    $imageUrl: String!
    $price: Int!
    $title: String!
    $description: String!
  ) {
    addProduct(
      imageUrl: $imageUrl
      price: $price
      title: $title
      description: $description
    ) {
      id
      imageUrl
      price
      title
      description
      createdAt
    }
  }
`;

export const UPDATE_PRODUCT = gql`
  mutation UPDATE_PRODUCT(
    $id: ID!
    $imageUrl: String!
    $price: Int!
    $title: String!
    $description: String!
  ) {
    updateProduct(
      id: $id
      imageUrl: $imageUrl
      price: $price
      title: $title
      description: $description
    ) {
      id
      imageUrl
      price
      title
      description
      createdAt
    }
  }
`;

export const DELETE_PRODUCT = gql`
  mutation DELETE_PRODUCT($id: ID!) {
    deleteProduct(id: $id)
  }

 

3. 배열을 객체로 바꿔주는 함수 만들기

- client/src/util/arrToObj.ts

const arrToObj = (arr: [string, any][]) =>
  arr.reduce<{ [key: string]: any }>((res, [key, val]) => {
    res[key] = val;
    return res;
  }, {});

export default arrToObj;

 

4. 제출버튼 누르고 db 확인

  {
    "id": "94101536-7785-40db-8300-39763828cc90",
    "imageUrl": "https://source.unsplash.com/200x150/?nature/0",
    "price": 8500,
    "title": "추가상품",
    "description": "상세상세",
    "createdAt": 1706271411897
  }

 

 

- 잘 들어오는 것 확인!

 

5. 내림차순으로 정렬

- server/src/resolvers/product.ts

const productResolver: Resolver = {
  Query: {
    products: (parent, { cursor = "", showDeleted = false }, { db }) => {
      const [hasCreatedAt, noCreatedAt] = [
        db.products
          .filter((product) => !!product.createdAt)
          .sort((a, b) => b.createdAt! - a.createdAt!),
        db.products.filter((product) => !product.createdAt),
      ];

      const filteredDB = showDeleted
        ? [...hasCreatedAt, ...noCreatedAt]
        : hasCreatedAt;
...
}

 

6. addForm에 mutation 추가

 const queryClient = getClient();

  const { mutate: addProduct } = useMutation(
    ({ title, imageUrl, price, description }: OmittedProduct) =>
      graphqlFetcher(ADD_PRODUCT, { title, imageUrl, price, description }),
    {
      onSuccess: ({ addProduct }) => {
        // (1) 데이터를 stale처리해서 재요청하게끔 함. =>
        // (장) 코드가 간단하다. 쉽다.
        // (단) 서버요청 또 한다.
        queryClient.invalidateQueries(QueryKeys.PRODUCTS, {
          exact: false,
          refetchInactive: true,
        });
        /* 
          // (2) 응답결과만으로 캐시 업데이트. => 장단점 반대.
          const adminData = queryClient.getQueriesData<{
            pageParams: (number | undefined)[]
            pages: Products[]
          }>([QueryKeys.PRODUCTS, 'admin'])
  
          const [adminKey, { pageParams: adminParams, pages: adminPages }] = adminData[0]
          const newAdminPages = [...adminPages]
          newAdminPages[0].products = [addProduct, ...newAdminPages[0].products]
          queryClient.setQueriesData(adminKey, { pageParms: adminParams, pages: newAdminPages })
          */
      },
    }
  );
  • refetchInactive : 기본값은 false. true로 설정하면 refetch 대상 또는 랜더링되지 않은 쿼리가 모두 유효하지 않다고 판단하여 새롭게 랜더링한다.
  • exact: 기본값은 true로, false로 설정하여 변경에 대하여 더 탐색이 필요하다고 설정.

 

마지막에 추가된 상품이 제일 위로 오는 것 확인!

 

4️⃣ 상품 수정

1. adminIndex 컴포넌트 생성

- client/src/pages/admin/index.tsx

import AdminIndex from "../../assets/components/admin";

const AdminPage = () => {
  return (
    <div>
      <h2>관리자 - 상품목록</h2>
      <AdminIndex />
    </div>
  );
};

export default AdminPage;

 

- client/src/assets/components/admin/index.tsx

const AdminIndex = () => {
  // 상품 수정
  const [editingIndex, setEditigIndex] = useState<number | null>(null);

  // 무한 스크롤을 위해
  const fetchMoreRef = useRef<HTMLDivElement>(null);
  const intersecting = useInfiniteScroll(fetchMoreRef);

  // 데이터 가져오기, type 정의
  const { data, isSuccess, isFetchingNextPage, fetchNextPage, hasNextPage } =
    useInfiniteQuery<Products>(
      [QueryKeys.PRODUCTS, "admin"],
      ({ pageParam = "" }) =>
        graphqlFetcher<Products>(GET_PRODUCTS, {
          cursor: pageParam,
          showDeleted: true,
        }),
      {
        getNextPageParam: (lastPage, allPages) => {
          return lastPage.products.at(-1)?.id;
        },
      }
    );

  useEffect(() => {
    if (!intersecting || !isSuccess || !hasNextPage || isFetchingNextPage)
      return;

    fetchNextPage();
  }, [intersecting]);

  // 상품 수정 시작
  const startEdit = (index: number) => () => setEditigIndex(index);
  
  // 상품 수정 끝
  const doneEdit = () => () => setEditigIndex(null);

  return (
    <div>
      <AddForm />

      <AdminList
        list={data?.pages || []}
        editingIndex={editingIndex}
        startEdit={startEdit}
        doneEdit={doneEdit}
      />

      <div ref={fetchMoreRef} />
    </div>
  );
};

export default AdminIndex;

 

2. client/src/assets/components/admin/list.tsx

const AdminList = ({
  list,
  editingIndex,
  startEdit,
  doneEdit,
}: {
  list: { products: Product[] }[];
  editingIndex: number | null;
  startEdit: (index: number) => () => void;
  doneEdit: (index: number) => () => void;
}) => {
  return (
    <ul className="products">
      {list.map((page) =>
        page.products.map((product, i) => (
          <AdminItem
            {...product}
            key={product.id}
            isEditing={editingIndex === i}
            startEdit={startEdit(i)}
            doneEdit={doneEdit(i)}
          />
        ))
      )}
    </ul>
  );
};

export default AdminList;
  • startEdit={startEdit(i)} : 수정할 상품의 인덱스값 전달
  • isEditing={editingIndex === i}: 전달된 인덱스값과 동일한 상품을 찾기

3. client/src/assets/components/admin/item.tsx

const AdminItem = ({
  imageUrl,
  price,
  title,
  id,
  description,
  createdAt,
  isEditing,
  startEdit,
  doneEdit,
}: Product & {
  isEditing: boolean;
  startEdit: () => void;
  doneEdit: () => void;
}) => {
  const queryClient = getClient();

  const { mutate: updateProduct } = useMutation(
    ({ title, imageUrl, price, description }: MutableProduct) =>
      graphqlFetcher(UPDATE_PRODUCT, {
        id,
        title,
        imageUrl,
        price,
        description,
      }),
    {
      onSuccess: () => {
        queryClient.invalidateQueries(QueryKeys.PRODUCTS, {
          exact: false,
          refetchInactive: true,
        });

        doneEdit();
      },
    }
  );

  // 제출 버튼
  const handleSubmit = (e: SyntheticEvent) => {
    e.preventDefault(); // 다른 페이지로 이동하는 것 방지
    const formData = arrToObj([...new FormData(e.target as HTMLFormElement)]);
    formData.price = Number(formData.price); // price만 number로 형변환
    updateProduct(formData as MutableProduct);
  };

  if (isEditing)
    return (
      <li className="product-item">
        <form onSubmit={handleSubmit}>
          <label>
            상품명:
            <input name="title" type="text" required defaultValue={title} />
          </label>
          <label>
            이미지URL:
            <input
              name="imageUrl"
              type="text"
              required
              defaultValue={imageUrl}
            />
          </label>
          <label>
            상품가격:
            <input name="price" type="text" required defaultValue={price} />
          </label>
          <label>
            상세:
            <textarea name="description" defaultValue={description} />
          </label>

          <button type="submit">저장</button>
        </form>
      </li>
    );
  return (
    <li className="products-item">
      <Link to={`/products/${id}`}>
        <p className="products-item__title">{title}</p>
        <img className="products-item__image" src={imageUrl} />
        <span className="products-item__price">${price}</span>
      </Link>

      {!createdAt && <span>삭제된 상품</span>}

      <button className="product-item__add-cart" onClick={startEdit}>
        어드민!!!
      </button>
    </li>
  );
};

export default AdminItem;

 

- 상품 수정 잘 되는 것 확인!

 

5️⃣ 상품 삭제

1. client/src/assets/components/admin/item.tsx

// 상품 삭제하기
  const { mutate: deleteProduct } = useMutation(
    ({ id }: { id: String }) =>
      graphqlFetcher(DELETE_PRODUCT, {
        id,
      }),
    {
      onSuccess: () => {
        queryClient.invalidateQueries(QueryKeys.PRODUCTS, {
          exact: false,
          refetchInactive: true,
        });
      },
    }
  );
  
   // 아이템 삭제
  const deleteItem = () => {
    deleteProduct({ id });
  };
  
  <button className="product-item__delete-item" onClick={deleteItem}>
      삭제
   </button>

 

2. 장바구니에 넣어둔 아이템이 삭제되었을 경우

- client/src/assets/components/cart/item.tsx

const CartItem = (
  { id, product: { imageUrl, price, title, createdAt }, amount }: Cart,
  ref: ForwardedRef<HTMLInputElement>
) => {

...

return (
    <li className="cart-item">
      <input
        className="cart-item__checkbox"
        type="checkbox"
        name={`select-item`}
        ref={ref}
        data-id={id}
        // createdAt이 없을 때 disabled 걸어주기
        disabled={!createdAt}
      />

      <ItemData imageUrl={imageUrl} price={price} title={title} />

	  // createdAt이 없으면 삭제된 상품입니다 띄우기
      {!createdAt ? (
        <div>삭제된 상품입니다</div>
      ) : (
        <input
          type="number"
          className="cart-item__amount"
          value={amount}
          onChange={handleUpdateAmount}
          min={1}
        />
      )}

      <button
        className="cart-item__button"
        type="button"
        onClick={handleDeleteItem}
      >
        삭제
      </button>
    </li>
  );
 };
};

export default forwardRef(CartItem);

 

- client/src/assets/components/cart/list.tsx

const CartList = ({ items }: { items: Cart[] }) => {

  ...
  
  // 관리자에서 삭제하면 체크박스 체크 안 되게 하기 위함
  const enabledItem = items.filter((item) => item.product.createdAt);
  
  ... 
  
  const setItemsCheckedFromAll = (targetInput: HTMLInputElement) => {
    const allChecked = targetInput.checked;
    checkboxRefs
      .filter((inputElem) => {
        return !inputElem.current!.disabled;
      })
      .forEach((inputElem) => {
        inputElem.current!.checked = allChecked;
      });
  };
  
  ...

 

- client/src/assets/compoenets/willPay/index.tsx

const WillPay = ({
  submitTitle,
  handleSubmit,
}: {
  submitTitle: string;
  handleSubmit: (e: SyntheticEvent) => void;
}) => {

 ...
 
 const totalPrice = checkedItems.reduce(
    (res, { product: { price, createdAt }, amount }) => {
      if (createdAt) res += price * amount;
      return res;
    },
    0
  );
  
  return (
    <div className="cart-willpay">
      <ul>
        {checkedItems.map(
          ({ product: { imageUrl, price, title, createdAt }, amount, id }) => (
            <li key={id}>
              <ItemData
                imageUrl={imageUrl}
                price={price}
                title={title}
                key={id}
              />
              <p>수량 : {amount}</p>
              <p>금액 : {price * amount}</p>
              
              // createAt이 없으면 품절된 상품입니다 띄우기
              {!createdAt && "품절된 상품입니다."}
            </li>
          )
        )}
      </ul>

      <p>총예상금액 : {totalPrice}</p>

      <button onClick={handleSubmit}>{submitTitle}</button>
    </div>
  );
};

export default WillPay;

 

💡 결제 할 때도 삭제된 상품이 있으면 에러 던져주기! (서버에서)

- server/src/resolvers/cart.ts

const cartResolver: Resolver = {
  Mutation: {
    executePay: (parent, { ids }, { db }) => {
      const newCartData = db.cart.filter(
        (cartItem) => !ids.includes(cartItem.id)
      );

      if (
        newCartData.some((item) => {
          const product = db.products.find(
            (product: any) => product.id === item.id
          );
          return !product?.createdAt;
        })
      )
        throw new Error("삭제된 상품이 포함되어 결제를 진행할 수 없습니다.");

      db.cart = newCartData;
      setJSON(db.cart);
      return ids;
    },
  },
 }

 

 

이제 고지가 멀지 않았다,, ㅎㅎ 파이팅!!!