diff --git a/README.md b/README.md index 3eaeec280..d4f43b08f 100644 --- a/README.md +++ b/README.md @@ -1 +1,33 @@ -# react-deploy \ No newline at end of file +# react-deploy + +### 질문 1. SPA 페이지를 정적 배포를 하려고 할 때 Vercel을 사용하지 않고 한다면 어떻게 할 수 있을까요? + +- `npm run build`를 통해 빌드된 파일을 정적 호스팅 서비스에 업로드하면 됩니다. +- AWS S3, Github Pages 등을 사용할 수 있습니다. +- 빌드된 파일을 서버에 올리는 방법은 다음과 같습니다. + - AWS S3: S3 버킷을 생성하고 빌드된 파일을 업로드합니다. + - Github Pages: Github 저장소에 빌드된 파일을 업로드합니다. + +### 질문 2. CSRF나 XSS 공격을 막는 방법은 무엇일까요? + +- CSRF(Cross-Site Request Forgery) 공격을 막는 방법 + + - CSRF 토큰을 사용합니다. + - SameSite 쿠키 속성을 사용합니다. + - Referer 검증을 사용합니다. + - 사용자의 동작을 요구하는 방식을 사용합니다. + +- XSS(Cross-Site Scripting) 공격을 막는 방법 + - 사용자 입력값을 필터링합니다. + - 사용자 입력값을 필터링하여 스크립트를 실행할 수 없도록 합니다. + - 사용자 입력값을 HTML 엔티티로 변환하여 스크립트를 실행할 수 없도록 합니다. + - 사용자 입력값을 JavaScript Escape하여 스크립트를 실행할 수 없도록 합니다. + +### 질문 3. 브라우저 렌더링 원리에대해 설명해주세요. + +- 브라우저 렌더링 원리 + - HTML 문서를 파싱하여 DOM 트리를 생성합니다. + - CSS 스타일을 파싱하여 CSSOM 트리를 생성합니다. + - DOM 트리와 CSSOM 트리를 결합하여 렌더 트리를 생성합니다. + - 렌더 트리를 기반으로 레이아웃을 계산합니다. + - 레이아웃을 기반으로 픽셀을 그리고 화면에 표시합니다. diff --git a/src/api/axiosInstance.ts b/src/api/axiosInstance.ts index 0aa4b0e2c..31f675e90 100644 --- a/src/api/axiosInstance.ts +++ b/src/api/axiosInstance.ts @@ -23,7 +23,7 @@ const sessionStorageApiWithAuth = (token: string) => { baseURL: apiSessionStorage.get(), headers: { 'Content-Type': 'application/json', - Authorization: token, + Authorization: 'Bearer ' + token, }, }); }; diff --git a/src/api/hooks/useGetOrderPrice.ts b/src/api/hooks/useGetOrderPrice.ts new file mode 100644 index 000000000..9020000d6 --- /dev/null +++ b/src/api/hooks/useGetOrderPrice.ts @@ -0,0 +1,29 @@ +import type { UseQueryResult } from '@tanstack/react-query'; + +import { useAxiosQuery } from '@/api'; +import type { GetOrderPriceResponseBody } from '@/api/type'; +type RequestParams = { + optionId: string; + quantity: number; + productId: string; +}; + +export function getOrderPricePath({ optionId, quantity, productId }: RequestParams): string { + return `/api/orders/price?optionId=${optionId}&quantity=${quantity}&productId=${productId}`; +} + +function useGetOrderPrice({ + optionId, + quantity, + productId, +}: RequestParams): UseQueryResult { + return useAxiosQuery( + { + method: 'GET', + url: getOrderPricePath({ optionId, quantity, productId }), + }, + ['orderPrice', optionId, quantity.toString(), productId], + ); +} + +export default useGetOrderPrice; diff --git a/src/api/hooks/useGetOrders.ts b/src/api/hooks/useGetOrders.ts new file mode 100644 index 000000000..96996e69b --- /dev/null +++ b/src/api/hooks/useGetOrders.ts @@ -0,0 +1,34 @@ +import type { UseAxiosQueryWithPageResult } from '@/api'; +import { useAxiosQueryWithPage } from '@/api'; +import { sessionStorageApiWithAuth } from '@/api/axiosInstance'; +import type { GetOrdersResponseBody } from '@/api/type'; +import { authSessionStorage } from '@/utils/storage'; + +type RequestParams = { + size?: number; + page?: number; + sort?: string; +}; + +export function getOrdersPath({ size, sort }: RequestParams): string { + return `/api/orders?size=${size}&sort=${sort}`; +} + +function useGetOrders({ + size = 20, + sort = 'id,desc', +}: RequestParams): UseAxiosQueryWithPageResult { + const token = authSessionStorage.get()?.token ?? ''; + + return useAxiosQueryWithPage( + { + method: 'GET', + url: getOrdersPath({ size, sort }), + }, + ['orders'], + (lastPage) => (!lastPage.last ? (lastPage.number + 1).toString() : undefined), + sessionStorageApiWithAuth(token), + ); +} + +export default useGetOrders; diff --git a/src/api/hooks/useGetPoint.ts b/src/api/hooks/useGetPoint.ts new file mode 100644 index 000000000..56ca8bae2 --- /dev/null +++ b/src/api/hooks/useGetPoint.ts @@ -0,0 +1,26 @@ +import type { UseQueryResult } from '@tanstack/react-query'; + +import { useAxiosQuery } from '@/api'; +import { sessionStorageApiWithAuth } from '@/api/axiosInstance'; +import type { GetPointResponseBody } from '@/api/type'; +import { authSessionStorage } from '@/utils/storage'; + +export function getPointPath(): string { + return '/api/members/point'; +} + +function useGetPoint(): UseQueryResult { + const token = authSessionStorage.get()?.token ?? ''; + + return useAxiosQuery( + { + method: 'GET', + url: getPointPath(), + }, + ['point'], + {}, + sessionStorageApiWithAuth(token), + ); +} + +export default useGetPoint; diff --git a/src/api/hooks/useGetProducts.ts b/src/api/hooks/useGetProducts.ts index 0e356d79c..c08707d47 100644 --- a/src/api/hooks/useGetProducts.ts +++ b/src/api/hooks/useGetProducts.ts @@ -4,22 +4,23 @@ import type { GetCategoriesProductsResponseBody } from '@/api/type'; type RequestParams = { categoryId: string; - maxResults?: number; - initPageToken?: string; + size?: number; + page?: number; + sort?: string; }; -export function getProductsPath({ categoryId, maxResults }: RequestParams): string { - return `/api/products?categoryId=${categoryId}` + (maxResults ? `&maxResults=${maxResults}` : ''); +export function getProductsPath({ categoryId, size, sort = 'id,desc' }: RequestParams): string { + return `/api/products?categoryId=${categoryId}` + (size ? `&size=${size}` : '') + `&sort=${sort}`; } function useGetProducts({ categoryId, - maxResults = 20, + size = 20, }: RequestParams): UseAxiosQueryWithPageResult { return useAxiosQueryWithPage( { method: 'GET', - url: getProductsPath({ categoryId, maxResults }), + url: getProductsPath({ categoryId, size }), }, ['products', categoryId], (lastPage) => (!lastPage.last ? (lastPage.number + 1).toString() : undefined), diff --git a/src/api/hooks/useGetWishes.ts b/src/api/hooks/useGetWishes.ts index 1e609271d..4b94b3d33 100644 --- a/src/api/hooks/useGetWishes.ts +++ b/src/api/hooks/useGetWishes.ts @@ -15,7 +15,7 @@ export function getWishesPath({ size, sort }: RequestParams): string { function useGetWishes({ size = 10, - sort = 'createdDate,desc', + sort = 'id,desc', }: RequestParams): UseAxiosQueryWithPageResult { const token = authSessionStorage.get()?.token ?? ''; @@ -25,7 +25,10 @@ function useGetWishes({ url: getWishesPath({ size, sort }), }, ['wishes'], - (lastPage) => (!lastPage.last ? (lastPage.number + 1).toString() : undefined), + (lastPage) => + lastPage.last !== undefined && !lastPage.last + ? (lastPage?.number || 0 + 1).toString() + : undefined, sessionStorageApiWithAuth(token), ); } diff --git a/src/api/hooks/usePostLogin.ts b/src/api/hooks/usePostLogin.ts index e22d1f056..11a9962a4 100644 --- a/src/api/hooks/usePostLogin.ts +++ b/src/api/hooks/usePostLogin.ts @@ -3,7 +3,7 @@ import { useAxiosMutation } from '@/api'; import type { PostLoginRequestBody, PostLoginResponseBody } from '@/api/type'; export function getLoginPath(): string { - return '/api/login'; + return '/api/members/login'; } function usePostLogin(): UseAxiosMutationResult { diff --git a/src/api/hooks/usePostOrder.ts b/src/api/hooks/usePostOrder.ts new file mode 100644 index 000000000..2901b9a84 --- /dev/null +++ b/src/api/hooks/usePostOrder.ts @@ -0,0 +1,23 @@ +import type { UseAxiosMutationResult } from '@/api'; +import { useAxiosMutation } from '@/api'; +import { sessionStorageApiWithAuth } from '@/api/axiosInstance'; +import type { PostOrderRequestBody } from '@/api/type'; +import { authSessionStorage } from '@/utils/storage'; + +export function postOrderPath(): string { + return '/api/orders'; +} + +function usePostOrder(): UseAxiosMutationResult { + const token = authSessionStorage.get()?.token ?? ''; + + return useAxiosMutation( + { + method: 'POST', + url: postOrderPath(), + }, + sessionStorageApiWithAuth(token), + ); +} + +export default usePostOrder; diff --git a/src/api/hooks/usePostRegister.ts b/src/api/hooks/usePostRegister.ts index a3b7ac2de..468e7f91f 100644 --- a/src/api/hooks/usePostRegister.ts +++ b/src/api/hooks/usePostRegister.ts @@ -2,7 +2,7 @@ import { useAxiosMutation, type UseAxiosMutationResult } from '@/api'; import type { PostRegisterRequestBody, PostRegisterResponseBody } from '@/api/type'; export function getRegisterPath(): string { - return '/api/register'; + return '/api/members/register'; } function usePostRegister(): UseAxiosMutationResult< diff --git a/src/api/index.ts b/src/api/index.ts index d3d58bb4c..97460c204 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -49,7 +49,7 @@ export function useAxiosQueryWithPage( queryFn: async ({ pageParam }: QueryFunctionContext) => axiosInstance({ ...axiosOptions, - params: { ...axiosOptions.params, initPageToken: pageParam }, + params: { ...axiosOptions.params, page: pageParam }, }).then((res) => res.data), initialPageParam: '0', getNextPageParam: getNextPageParam as GetNextPageParamFunction, diff --git a/src/api/type.ts b/src/api/type.ts index 790a2e919..b638b148e 100644 --- a/src/api/type.ts +++ b/src/api/type.ts @@ -12,36 +12,13 @@ export type ProductData = { price: number; }; -export type ProductDetailData = ProductData & { - isAccessableProductPage: boolean; - review: { - averageRating: number; - totalReviewCount: number; - }; - productDescription: { - images: string[]; - }; - productDetailInfo: { - announcements: { - displayOrder: number; - name: string; - value: string; - }[]; - terms: { - displayOrder: number; - title: string; - description: string; - }[]; - }; -}; +export type ProductDetailData = ProductData; export type CategoryData = { id: number; - key: string; name: string; imageUrl: string; - title: string; - description?: string; + description: string; color: string; }; @@ -95,6 +72,19 @@ export type WishesData = { product: ProductData; }; +export type OrderLog = { + id: number; + productId: number; + name: string; + imageUrl: string; + optionId: number; + count: number; + price: number; + orderDateTime: string; + message: string; + success: boolean; +}; + // RequestBody Types export type ProductOrderRequestBody = { productId: number; @@ -132,6 +122,16 @@ export type DeleteWishesRequestBody = { wishId: number; }; +export type PostOrderRequestBody = { + optionId: number; + message: string; + quantity: number; + productId: number; + point: number; + phone: string; + receipt: boolean; +}; + // ResponseBody Types export type GetRankingProductsResponseBody = { products: ProductData[]; @@ -140,7 +140,7 @@ export type GetRankingProductsResponseBody = { export type GetCategoriesResponseBody = CategoryData[]; export type GetCategoriesProductsResponseBody = { - content: ProductData[]; + products: ProductData[]; number: number; totalElements: number; size: number; @@ -161,20 +161,29 @@ export type PostRegisterResponseBody = { token: string; }; -export type PostWishesResponseBody = { - id: number; - productId: number; -}; +export type PostWishesResponseBody = void; export type GetWishesResponseBody = { content: WishesData[]; pageable: PageableData; - totalPage: number; totalElements: number; last: boolean; number: number; size: number; - numberOfElements: number; - first: boolean; - empty: boolean; +}; + +export type GetOrderPriceResponseBody = { + price: number; +}; + +export type GetPointResponseBody = { + point: number; +}; + +export type GetOrdersResponseBody = { + contents: OrderLog[]; + number: number; + totalElements: number; + size: number; + last: boolean; }; diff --git a/src/components/features/Category/CategoryGoodsSection/index.tsx b/src/components/features/Category/CategoryGoodsSection/index.tsx index 370ca43ff..a2868d99d 100644 --- a/src/components/features/Category/CategoryGoodsSection/index.tsx +++ b/src/components/features/Category/CategoryGoodsSection/index.tsx @@ -23,7 +23,7 @@ export const CategoryGoodsSection = ({ categoryId }: Props) => { categoryId, }); - const flattenGoodsList = data?.pages.map((page) => page?.content ?? []).flat(); + const flattenGoodsList = data?.pages.map((page) => page?.products ?? []).flat(); useEffect(() => { if (inView && hasNextPage) { diff --git a/src/components/features/Category/CategoryHeroSection/index.tsx b/src/components/features/Category/CategoryHeroSection/index.tsx index 8e9b442a1..c29ab8788 100644 --- a/src/components/features/Category/CategoryHeroSection/index.tsx +++ b/src/components/features/Category/CategoryHeroSection/index.tsx @@ -16,13 +16,12 @@ export const CategoryHeroSection = ({ categoryId, categoryList }: Props) => { return null; } - const { color, name, title, description } = currentTheme; + const { color, name, description } = currentTheme; return ( - {title} {description && {description}} @@ -51,24 +50,6 @@ const Label = styled.p` } `; -const Title = styled.h1` - font-weight: 700; - color: #fff; - font-size: 18px; - line-height: 26px; - word-break: break-all; - overflow: hidden; - text-overflow: ellipsis; - -webkit-line-clamp: 2; - - @media screen and (min-width: ${breakpoints.sm}) { - font-size: 30px; - line-height: 40px; - padding-top: 12px; - word-break: break-word; - } -`; - const Description = styled.p` padding-top: 5px; font-size: 14px; diff --git a/src/components/features/MyAccount/Tabs/OrderListTab.tsx b/src/components/features/MyAccount/Tabs/OrderListTab.tsx index 2a770aec7..0640c7a65 100644 --- a/src/components/features/MyAccount/Tabs/OrderListTab.tsx +++ b/src/components/features/MyAccount/Tabs/OrderListTab.tsx @@ -1,35 +1,29 @@ import { Container } from '@chakra-ui/react'; +import { useEffect } from 'react'; import { useInView } from 'react-intersection-observer'; +import useGetOrders from '@/api/hooks/useGetOrders'; import ListMapper from '@/components/common/ListMapper'; import Loading from '@/components/common/Loading'; import type { OrderLog } from '@/components/features/MyAccount/OrderLogCard'; import OrderLogCard from '@/components/features/MyAccount/OrderLogCard'; const OrderListTab = () => { - const { ref } = useInView(); + const { ref, inView } = useInView(); + + const { data, isLoading, isError, hasNextPage, fetchNextPage } = useGetOrders({}); + const flattenOrderList = data?.pages.map((page) => page?.contents || []).flat(); + + useEffect(() => { + if (inView && hasNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, fetchNextPage]); return ( - - - items={[ - { - imageUrl: 'https://via.placeholder.com/150', - name: '상품명', - count: 12, - price: 10000, - }, - ]} - ItemComponent={OrderLogCard} - wrapperProps={{ - columns: { - initial: 2, - md: 4, - }, - gap: 16, - }} - /> + + items={flattenOrderList} ItemComponent={OrderLogCard} />
diff --git a/src/components/features/MyAccount/Tabs/WishesTab.tsx b/src/components/features/MyAccount/Tabs/WishesTab.tsx index 83764bcea..21a0fb4f3 100644 --- a/src/components/features/MyAccount/Tabs/WishesTab.tsx +++ b/src/components/features/MyAccount/Tabs/WishesTab.tsx @@ -13,7 +13,7 @@ const WishesTab = () => { const { ref, inView } = useInView(); const { data, isLoading, isError, hasNextPage, fetchNextPage } = useGetWishes({}); - const flattenWishsList = data?.pages.map((page) => page?.content ?? []).flat(); + const flattenWishsList = data?.pages.map((page) => page?.content || page || []).flat(); useEffect(() => { if (inView && hasNextPage) { diff --git a/src/pages/MyAccount/index.tsx b/src/pages/MyAccount/index.tsx index d925b1638..ecc07cc29 100644 --- a/src/pages/MyAccount/index.tsx +++ b/src/pages/MyAccount/index.tsx @@ -12,6 +12,7 @@ import { import styled from '@emotion/styled'; import { TbParkingCircle } from 'react-icons/tb'; +import useGetPoint from '@/api/hooks/useGetPoint'; import OrderListTab from '@/components/features/MyAccount/Tabs/OrderListTab'; import WishesTab from '@/components/features/MyAccount/Tabs/WishesTab'; import { useAuth } from '@/provider/Auth'; @@ -21,6 +22,8 @@ import { authSessionStorage } from '@/utils/storage'; export const MyAccountPage = () => { const authInfo = useAuth(); + const { data: point } = useGetPoint(); + const handleLogout = () => { authSessionStorage.set(undefined); @@ -38,7 +41,7 @@ export const MyAccountPage = () => { - 1000 + {point?.point} diff --git a/src/pages/Order/index.tsx b/src/pages/Order/index.tsx index c7bb96414..9090ea4db 100644 --- a/src/pages/Order/index.tsx +++ b/src/pages/Order/index.tsx @@ -15,6 +15,9 @@ import type { FieldErrors } from 'react-hook-form'; import { useForm } from 'react-hook-form'; import { useLocation } from 'react-router-dom'; +import useGetPoint from '@/api/hooks/useGetPoint'; +import usePostOrder from '@/api/hooks/usePostOrder'; +import type { PostOrderRequestBody } from '@/api/type'; import type { RegisterOption } from '@/utils/form'; import { useCreateRegister } from '@/utils/form'; @@ -23,6 +26,7 @@ type OrderInfo = { needReceipt: boolean; receiptType: '개인소득공제' | '사업자증빙용'; receiptNumber: string; + point: number; }; const defaultOrderInfo: OrderInfo = { @@ -30,14 +34,16 @@ const defaultOrderInfo: OrderInfo = { needReceipt: false, receiptType: '개인소득공제', receiptNumber: '', + point: 0, }; export const OrderPage = () => { const location = useLocation(); - const { register, handleSubmit, getValues } = useForm({ + const { register, handleSubmit, getValues, setValue } = useForm({ defaultValues: defaultOrderInfo, }); const [needReceiptState, setNeedReceiptState] = useState(defaultOrderInfo.needReceipt); + const { data: userPoint } = useGetPoint(); const orderInfoOptions: RegisterOption[] = [ { @@ -75,6 +81,23 @@ export const OrderPage = () => { }, }, }, + { + name: 'point' as const, + option: { + required: { + value: true, + message: '포인트를 입력해주세요.', + }, + max: { + value: userPoint?.point || 0, + message: '보유한 포인트보다 많이 사용할 수 없습니다.', + }, + pattern: { + value: /^[0-9]+$/, + message: '숫자만 입력 가능합니다.', + }, + }, + }, ]; const getRegister = useCreateRegister({ @@ -95,17 +118,22 @@ export const OrderPage = () => { } }; - const handleOrder = () => { - const needReceipt = getValues('needReceipt'); + const { mutateAsync: order } = usePostOrder(); - const completedOrder: OrderInfo = { + const handleOrder = () => { + const completedOrder: PostOrderRequestBody = { + optionId: location.state.optionId, message: getValues('message'), - needReceipt: needReceipt, - receiptType: getValues('receiptType'), - receiptNumber: getValues('receiptNumber'), + quantity: location.state.count, + productId: location.state.id, + point: getValues('point'), + phone: getValues('receiptNumber'), + receipt: getValues('needReceipt'), }; - console.log(completedOrder); - alert('주문이 완료되었습니다.'); + + order(completedOrder).then(() => { + alert('주문이 완료되었습니다.'); + }); }; const handleError = (errors: FieldErrors) => { @@ -113,6 +141,10 @@ export const OrderPage = () => { alert(firstError.message); }; + const handleAllPointUse = () => { + setValue('point', userPoint?.point || 0); + }; + return ( @@ -166,11 +198,11 @@ export const OrderPage = () => { - 포인트 사용 + 포인트 사용 ({userPoint?.point || 0}포인트 보유) - - diff --git a/src/pages/Products/index.tsx b/src/pages/Products/index.tsx index f46a6fe62..cb27dc8d5 100644 --- a/src/pages/Products/index.tsx +++ b/src/pages/Products/index.tsx @@ -98,7 +98,7 @@ export const ProductsPage = () => { } } else if (productsDetail) { navigate(RouterPath.order, { - state: { ...productsDetail, count: getValues('count') }, + state: { ...productsDetail, count: getValues('count'), optionId: productOptions?.[0].id }, }); } }; @@ -178,12 +178,6 @@ export const ProductsPage = () => { - - - 총 결제 금액 - - {`${productsDetail?.price}원`} -