💡 오늘 할 것!
- 어드민 - 상품 목록
- 어드민 - 상품 추가
- 어드민 - 상품 수정
- 어드민 - 상품 삭제
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;
},
},
}
이제 고지가 멀지 않았다,, ㅎㅎ 파이팅!!!
'REACT > 쇼핑몰 프로젝트' 카테고리의 다른 글
[REACT] React + Typescript + Vite + SCSS로 만드는 쇼핑몰 개인프로젝트 (7/10) (0) | 2024.01.24 |
---|---|
[REACT] React + Typescript + Vite + SCSS로 만드는 쇼핑몰 개인프로젝트 (6/10) (0) | 2024.01.23 |
[REACT] React + Typescript + Vite + SCSS로 만드는 쇼핑몰 개인프로젝트 (5/10) (0) | 2024.01.22 |
[REACT] React + Typescript + Vite + SCSS로 만드는 쇼핑몰 개인프로젝트 (4/10) (0) | 2024.01.22 |
[REACT] React + Typescript + Vite + SCSS로 만드는 쇼핑몰 개인프로젝트 (3/10) (0) | 2024.01.18 |