diff --git a/README.md b/README.md index 3eaeec280..da9835cf9 100644 --- a/README.md +++ b/README.md @@ -1 +1,40 @@ -# react-deploy \ No newline at end of file +# react-deploy +## 4단계 - 질문의 답변을 README에 작성 + +### 6주차 질문 +- 질문 1. SPA 페이지를 정적 배포를 하려고 할 때 Vercel을 사용하지 않고 한다면 어떻게 할 수 있을까요? + - Vercel로 정적 배포를 하는 경우엔 웹 서버의 기능을 추상화하여 제공해서 서버리스 함수와 같은 기능을 통해 사용자가 직접 웹 서버를 설정할 필요가 없습니다. + - 하지만 저는 네이버 클라우드 서버를 이용해서 정적 배포를 진행했습니다. 이 서비스는 가상 머신과 유사하게 작동하기 때문에 웹 서버 설치, 프론트엔드 코드 배포, 웹 서버 설정 등의 추가적인 작업이 필요했습니다. + - 해당 배포 과정에 대한 자세한 설명은 [여기](https://garbagetime.notion.site/0000b1da7eb34bba924d109b68ed972b)에서 확인하실 수 있습니다! + +- 질문 2. CSRF나 XSS 공격을 막는 방법은 무엇일까요? + - CSRF를 막기 위해선 서버에서 고유한 토큰을 생성해서 요청 시 서버에서 토큰의 유효성을 검증하는 로직을 추가하는 방법이 있을 것 같습니다. + - XSS 공격을 막기 위해선 사용자 입력을 받을 때 유효성을 검사하고 특수 문자를 escaping 처리하거나, textContent를 사용하여 텍스트 삽입 시 XSS를 방지할 수 있다고 생각합니다. + +- 질문 3. 브라우저 렌더링 원리에대해 설명해주세요. + - 브라우저 렌더링은 다음과 같은 4가지 과정을 거쳐서 진행됩니다. + +1. DOM Tree와 CSSOM Tree의 생성 + + + + : 토큰화 → 브라우저의 렉싱 과정(토큰이 해당 속성과 규칙을 정의하는 노드 객체로 변환됨) → DOM 트리 생성(각 노드가 서로 연관성을 가질 수 있도록) + + + + - DOM이 화면에 어떻게 표시될지 알려주는 역할을 함 + - CSS도 위→아래로 스타일 규칙이 정해짐 : tree 구조 가짐 +2. Render Tree 생성 + + : 화면에 표시되어야 할 모든 노드의 컨텐츠, 스타일 정보를 포함하는 트리 + + - document 객체부터 각 노드 순회하며 그에 맞는 CSSOM 찾아 규칙 적용 + - meta 태그, `display: none;` 속성 - 렌더와 관련 없어 포함X +3. Layout (= Reflow) + + : 뷰포트 내에서 요소들의 정확한 위치와 크기를 계산하는 과정 + + - css에서 상대적 단위(%, em 등) 사용 시, 뷰포트에 맞춰 픽셀 단위로 변환됨 + +4. 페인트 과정 +: 화면에 실제 픽셀로 그려지도록 변환하는 과정 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e0a8f5038..4aeeb8bef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@emotion/styled": "^11.11.0", "@tanstack/react-query": "^5.24.1", "axios": "^1.6.7", + "base-64": "^1.0.0", "framer-motion": "^11.0.6", "glob": "^11.0.0", "react": "^18.2.0", @@ -39,6 +40,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "@types/base-64": "^1.0.2", "@types/jest": "^27.5.2", "@types/node": "^16.18.82", "@types/react": "^18.2.57", @@ -9308,6 +9310,13 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/base-64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/base-64/-/base-64-1.0.2.tgz", + "integrity": "sha512-uPgKMmM9fmn7I+Zi6YBqctOye4SlJsHKcisjHIMWpb2YKZRc36GpKyNuQ03JcT+oNXg1m7Uv4wU94EVltn8/cw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -11693,6 +11702,12 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", diff --git a/package.json b/package.json index 8b38f4ba0..4774234a5 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@emotion/styled": "^11.11.0", "@tanstack/react-query": "^5.24.1", "axios": "^1.6.7", + "base-64": "^1.0.0", "framer-motion": "^11.0.6", "glob": "^11.0.0", "react": "^18.2.0", @@ -54,6 +55,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "@types/base-64": "^1.0.2", "@types/jest": "^27.5.2", "@types/node": "^16.18.82", "@types/react": "^18.2.57", @@ -87,4 +89,4 @@ "overrides": { "react-refresh": "0.11.0" } -} \ No newline at end of file +} diff --git a/public/token.html b/public/token.html new file mode 100644 index 000000000..c7bdcabc0 --- /dev/null +++ b/public/token.html @@ -0,0 +1,33 @@ + + + + + + OAuth Callback + + + +

OAuth Callback

+

토큰을 기다리고 있습니다...

+ + + \ No newline at end of file diff --git a/src/api/hooks/categories.mock.ts b/src/api/hooks/categories.mock.ts index 7b1ac46f2..c1d5545eb 100644 --- a/src/api/hooks/categories.mock.ts +++ b/src/api/hooks/categories.mock.ts @@ -8,21 +8,48 @@ export const categoriesMockHandler = [ }), ]; -const CATEGORIES_RESPONSE_DATA = [ - { - id: 2920, - name: '생일', - description: '감동을 높여줄 생일 선물 리스트', - color: '#5949a3', - imageUrl: - 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', +const CATEGORIES_RESPONSE_DATA = { + totalElements: 2, + totalPages: 1, + first: true, + last: true, + size: 10, + content: [ + { + id: 2920, + name: '생일', + description: '감동을 높여줄 생일 선물 리스트', + color: '#5949a3', + imageUrl: + 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', + }, + { + id: 2930, + name: '교환권', + description: '놓치면 후회할 교환권 특가', + color: '#9290C3', + imageUrl: + 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Fst.kakaocdn.net%2Fproduct%2Fgift%2Fproduct%2F20240131153049_5a22b137a8d346e9beb020a7a7f4254a.jpg', + }, + ], + number: 0, + sort: { + empty: true, + sorted: false, + unsorted: true, }, - { - id: 2930, - name: '교환권', - description: '놓치면 후회할 교환권 특가', - color: '#9290C3', - imageUrl: - 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Fst.kakaocdn.net%2Fproduct%2Fgift%2Fproduct%2F20240131153049_5a22b137a8d346e9beb020a7a7f4254a.jpg', + numberOfElements: 2, + pageable: { + pageNumber: 0, + pageSize: 10, + sort: { + empty: true, + sorted: false, + unsorted: true, + }, + offset: 0, + paged: true, + unpaged: false, }, -]; + empty: false, +}; \ No newline at end of file diff --git a/src/api/hooks/useGetCategories.ts b/src/api/hooks/useGetCategories.ts index d93e4fc95..8bb5c4fed 100644 --- a/src/api/hooks/useGetCategories.ts +++ b/src/api/hooks/useGetCategories.ts @@ -2,20 +2,58 @@ import { useQuery } from '@tanstack/react-query'; import type { CategoryData } from '@/types'; -import { BASE_URL, fetchInstance } from '../instance'; +import { getBaseUrl, fetchInstance } from '../instance'; -export type CategoryResponseData = CategoryData[]; +export type CategoryResponseData = { + totalElements: number; + totalPages: number; + first: boolean; + last: boolean; + size: number; + content: CategoryData[]; + number: number; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + numberOfElements: number; + pageable: { + pageNumber: number; + pageSize: number; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + offset: number; + paged: boolean; + unpaged: boolean; + }; + empty: boolean; +}; + +export const getCategoriesPath = () => `${getBaseUrl()}/api/categories`; + +export const getCategories = async (): Promise => { + let allCategories: CategoryData[] = []; + let currentPage = 0; + let hasNextPage = true; -export const getCategoriesPath = () => `${BASE_URL}/api/categories`; -const categoriesQueryKey = [getCategoriesPath()]; + while (hasNextPage) { + const response = await fetchInstance.get( + `${getCategoriesPath()}?page=${currentPage}&size=100` + ); + allCategories = [...allCategories, ...response.data.content]; + hasNextPage = !response.data.last; + currentPage++; + } -export const getCategories = async () => { - const response = await fetchInstance.get(getCategoriesPath()); - return response.data; + return allCategories; }; export const useGetCategories = () => useQuery({ - queryKey: categoriesQueryKey, + queryKey: ['categories'], queryFn: getCategories, }); diff --git a/src/api/hooks/useGetMemberPoints.ts b/src/api/hooks/useGetMemberPoints.ts new file mode 100644 index 000000000..2e96c8916 --- /dev/null +++ b/src/api/hooks/useGetMemberPoints.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query'; +import { getBaseUrl } from '@/api/instance'; +import { useAuth } from '@/provider/Auth'; + +interface PointsResponse { + point: number; +} + +const fetchMemberPoints = async (token: string): Promise => { + const response = await fetch(`${getBaseUrl()}/api/points`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch member points'); + } + + const data: PointsResponse = await response.json(); + return data.point; +}; + +export const useGetMemberPoints = () => { + const authInfo = useAuth(); + + return useQuery({ + queryKey: ['memberPoints'], + queryFn: () => fetchMemberPoints(authInfo?.token ?? ''), + enabled: !!authInfo?.token, + }); +}; \ No newline at end of file diff --git a/src/api/hooks/useGetProductDetail.ts b/src/api/hooks/useGetProductDetail.ts index 539de0196..72023785c 100644 --- a/src/api/hooks/useGetProductDetail.ts +++ b/src/api/hooks/useGetProductDetail.ts @@ -2,7 +2,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import type { ProductData } from '@/types'; -import { BASE_URL, fetchInstance } from '../instance'; +import { getBaseUrl, fetchInstance } from '../instance'; export type ProductDetailRequestParams = { productId: string; @@ -12,7 +12,7 @@ type Props = ProductDetailRequestParams; export type GoodsDetailResponseData = ProductData; -export const getProductDetailPath = (productId: string) => `${BASE_URL}/api/products/${productId}`; +export const getProductDetailPath = (productId: string) => `${getBaseUrl()}/api/products/${productId}`; export const getProductDetail = async (params: ProductDetailRequestParams) => { const response = await fetchInstance.get( diff --git a/src/api/hooks/useGetProductOptions.ts b/src/api/hooks/useGetProductOptions.ts index a3bdc538f..1be861ee0 100644 --- a/src/api/hooks/useGetProductOptions.ts +++ b/src/api/hooks/useGetProductOptions.ts @@ -2,7 +2,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import type { ProductOptionsData } from '@/types'; -import { BASE_URL, fetchInstance } from '../instance'; +import {getBaseUrl, fetchInstance } from '../instance'; import type { ProductDetailRequestParams } from './useGetProductDetail'; type Props = ProductDetailRequestParams; @@ -10,7 +10,7 @@ type Props = ProductDetailRequestParams; export type ProductOptionsResponseData = ProductOptionsData[]; export const getProductOptionsPath = (productId: string) => - `${BASE_URL}/api/products/${productId}/options`; + `${getBaseUrl()}/api/products/${productId}/options`; export const getProductOptions = async (params: ProductDetailRequestParams) => { const response = await fetchInstance.get( diff --git a/src/api/hooks/useGetProducts.ts b/src/api/hooks/useGetProducts.ts index 432f90d93..7fef29938 100644 --- a/src/api/hooks/useGetProducts.ts +++ b/src/api/hooks/useGetProducts.ts @@ -6,7 +6,7 @@ import { import type { ProductData } from '@/types'; -import { BASE_URL } from '../instance'; +import { getBaseUrl } from '../instance'; import { fetchInstance } from './../instance/index'; type RequestParams = { @@ -35,12 +35,14 @@ type ProductsResponseRawData = { export const getProductsPath = ({ categoryId, pageToken, maxResults }: RequestParams) => { const params = new URLSearchParams(); - params.append('categoryId', categoryId); + if (pageToken) + params.append('page', pageToken); + if (maxResults) + params.append('size', maxResults.toString()); params.append('sort', 'name,asc'); - if (pageToken) params.append('page', pageToken); - if (maxResults) params.append('size', maxResults.toString()); + params.append('categoryId', categoryId); - return `${BASE_URL}/api/products?${params.toString()}`; + return `${getBaseUrl()}/api/products?${params.toString()}`; }; export const getProducts = async (params: RequestParams): Promise => { @@ -58,6 +60,7 @@ export const getProducts = async (params: RequestParams): Promise & { initPageToken?: string }; + export const useGetProducts = ({ categoryId, maxResults = 20, @@ -71,4 +74,4 @@ export const useGetProducts = ({ initialPageParam: initPageToken, getNextPageParam: (lastPage) => lastPage.nextPageToken, }); -}; +}; \ No newline at end of file diff --git a/src/api/instance/index.ts b/src/api/instance/index.ts index b83ca1407..db0118b9f 100644 --- a/src/api/instance/index.ts +++ b/src/api/instance/index.ts @@ -2,6 +2,8 @@ import { QueryClient } from '@tanstack/react-query'; import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import axios from 'axios'; +let BASE_URL = localStorage.getItem('baseURL') || 'https://api.example.com'; + const initInstance = (config: AxiosRequestConfig): AxiosInstance => { const instance = axios.create({ timeout: 5000, @@ -16,10 +18,8 @@ const initInstance = (config: AxiosRequestConfig): AxiosInstance => { return instance; }; -export const BASE_URL = 'https://api.example.com'; -// TODO: 추후 서버 API 주소 변경 필요 -export const fetchInstance = initInstance({ - baseURL: 'https://api.example.com', +export let fetchInstance = initInstance({ + baseURL: BASE_URL, }); export const queryClient = new QueryClient({ @@ -32,3 +32,17 @@ export const queryClient = new QueryClient({ }, }, }); + +export const updateBaseUrl = (newBaseUrl: string) => { + BASE_URL = newBaseUrl; + localStorage.setItem('baseURL', newBaseUrl); + fetchInstance = initInstance({ + baseURL: BASE_URL, + }); + // 선택한 이름마다 잘 받아오는지 확인용, 추후 삭제 예정 + console.log('Updated fetchInstance:', fetchInstance.defaults.baseURL); +}; + +export const getBaseUrl = () => { + return localStorage.getItem('baseURL') || 'https://api.example.com'; +}; \ No newline at end of file diff --git a/src/components/common/layouts/SplitLayout/index.tsx b/src/components/common/layouts/SplitLayout/index.tsx index 95ea26dfc..62f042fa2 100644 --- a/src/components/common/layouts/SplitLayout/index.tsx +++ b/src/components/common/layouts/SplitLayout/index.tsx @@ -1,54 +1,44 @@ import styled from '@emotion/styled'; +import { ReactNode } from 'react'; -import { HEADER_HEIGHT } from '@/components/features/Layout/Header'; -import { breakpoints } from '@/styles/variants'; - -import { Container } from '../Container'; - -type Props = { - children: React.ReactNode; - sidebar: React.ReactNode; +type SplitLayoutProps = { + sidebar: ReactNode; + children: ReactNode; }; -export const SplitLayout = ({ children, sidebar }: Props) => { +export const SplitLayout = ({ sidebar, children }: SplitLayoutProps) => { return ( - - - -
{children}
- {sidebar} -
-
-
+ + {children} + {sidebar} + ); }; -const Wrapper = styled.div` - width: 100%; -`; - -const Inner = styled.div` - width: 100%; +const Container = styled.div` display: flex; - justify-content: flex-start; - align-items: flex-start; - position: relative; -`; -const Main = styled.main` - width: 100%; - max-width: 900px; + @media (max-width: 768px) { + flex-direction: column; + } `; -const Sidebar = styled.aside` - display: none; - position: sticky; - top: ${HEADER_HEIGHT}; - width: 100%; - max-width: 360px; - height: calc(100vh - ${HEADER_HEIGHT}); +const MainContent = styled.div` + flex: 2; + padding: 20px; - @media screen and (min-width: ${breakpoints.sm}) { - display: block; + @media (max-width: 768px) { + order: 1; + padding: 10px; } `; + +const Sidebar = styled.div` + flex: 1; + padding: 20px; + + @media (max-width: 768px) { + order: 2; + padding: 10px; + } +`; \ No newline at end of file diff --git a/src/components/features/Category/CategoryProductsSection/index.tsx b/src/components/features/Category/CategoryProductsSection/index.tsx index d2dae2c3b..4b1df1dbd 100644 --- a/src/components/features/Category/CategoryProductsSection/index.tsx +++ b/src/components/features/Category/CategoryProductsSection/index.tsx @@ -20,10 +20,14 @@ export const CategoryProductsSection = ({ categoryId }: Props) => { categoryId, }); - if (isLoading) return ; - if (isError) return 에러가 발생했습니다.; - if (!data) return <>; - if (data.pages[0].products.length <= 0) return 상품이 없어요.; + if (isLoading) + return ; + if (isError) + return 에러가 발생했습니다.; + if (!data) + return <>; + if (data.pages[0].products.length <= 0) + return 상품이 없어요.; const flattenGoodsList = data.pages.map((page) => page?.products ?? []).flat(); diff --git a/src/components/features/Goods/Detail/OptionItem/CountOptionItem.tsx b/src/components/features/Goods/Detail/OptionItem/CountOptionItem.tsx index 2147d41fc..97de01420 100644 --- a/src/components/features/Goods/Detail/OptionItem/CountOptionItem.tsx +++ b/src/components/features/Goods/Detail/OptionItem/CountOptionItem.tsx @@ -12,7 +12,7 @@ type Props = { export const CountOptionItem = ({ name, - minValues = 1, + minValues = 0, maxValues = 100, value, onChange, diff --git a/src/components/features/Goods/Detail/OptionSection.tsx b/src/components/features/Goods/Detail/OptionSection.tsx index 31f7e9839..aa7f47786 100644 --- a/src/components/features/Goods/Detail/OptionSection.tsx +++ b/src/components/features/Goods/Detail/OptionSection.tsx @@ -1,5 +1,5 @@ import styled from '@emotion/styled'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { @@ -13,6 +13,7 @@ import { getDynamicPath, RouterPath } from '@/routes/path'; import { orderHistorySessionStorage } from '@/utils/storage'; import { CountOptionItem } from './OptionItem/CountOptionItem'; +import { getBaseUrl } from '@/api/instance'; type Props = ProductDetailRequestParams; @@ -24,10 +25,32 @@ export const OptionSection = ({ productId }: Props) => { const { data: detail } = useGetProductDetail({ productId }); const { data: options } = useGetProductOptions({ productId }); - const [counts, setCounts] = useState({}); + const [counts, setCounts] = useState(() => { + const initialCounts: CountState = {}; + if (options) { + options.forEach(option => { + initialCounts[option.id] = '0'; + }); + } + return initialCounts; + }); + + useEffect(() => { + if (options) { + setCounts(prevCounts => { + const newCounts = { ...prevCounts }; + options.forEach(option => { + if (!newCounts[option.id]) { + newCounts[option.id] = '0'; + } + }); + return newCounts; + }); + } + }, [options]); + const totalPrice = useMemo(() => { - if (!detail) - return 0; + if (!detail) return 0; const count = Object.values(counts).reduce((acc, countStr) => acc + Number(countStr), 0); return detail.price * count; }, [detail, counts]); @@ -46,21 +69,21 @@ export const OptionSection = ({ productId }: Props) => { } try { - const response = await fetch('/api/wishes', { + const requestBody = { + productId: parseInt(productId, 10), + quantity: null, + }; + + const response = await fetch(`${getBaseUrl()}/api/wishes`, { method: 'POST', headers: { Authorization: `Bearer ${authInfo.token}`, 'Content-Type': 'application/json', }, - body: JSON.stringify({ - productId: parseInt(productId, 10), - name: detail?.name, - price: detail?.price, - imageUrl: detail?.imageUrl, - }), + body: JSON.stringify(requestBody), }); - if (response.status === 201) { + if (response.ok) { alert('관심 등록 완료'); } else if (response.status === 400) { alert('잘못된 요청입니다.'); @@ -89,24 +112,39 @@ export const OptionSection = ({ productId }: Props) => { const totalCount = Object.values(counts).reduce((acc, countStr) => acc + Number(countStr), 0); - orderHistorySessionStorage.set({ - id: parseInt(productId, 10), - count: totalCount, - }); + if (totalCount === 0) { + alert('옵션을 선택해주세요.'); + return; + } + + const selectedOptions = options + .filter(option => Number(counts[option.id]) > 0) + .map(option => ({ + productId: parseInt(productId, 10), + optionId: option.id, + quantity: Number(counts[option.id]), + message: '', + })); + + console.log('선택된 옵션 및 수량:', selectedOptions); + + orderHistorySessionStorage.set(selectedOptions); navigate(RouterPath.order); }; return ( - {options && options.map(option => ( - setCounts(prev => ({ ...prev, [option.id]: value }))} - /> - ))} + + {options && options.map(option => ( + setCounts(prev => ({ ...prev, [option.id]: value }))} + /> + ))} +