Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

경북대 FE_이정민 4주차 과제 Step 2 #75

Open
wants to merge 24 commits into
base: userjmmm
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
518409a
docs: 0단계 - 구현할 기능 목록 정리
Jul 17, 2024
07fa961
chore: chakra-ui 설치
Jul 17, 2024
b981cd7
feat: 전체 App에 적용되도록 ChakraProvider 추가
Jul 17, 2024
63e85ea
feat: products detail 정보를 가져오는 hooks 코드 추가
Jul 17, 2024
0f710ae
feat: Chakra UI를 사용해서 상세 페이지 UI 구현
Jul 18, 2024
417e1b9
feat: theme 페이지에서 GoodsItem 클릭 시 상세페이지로 이동 구현
Jul 18, 2024
5c95117
feat: 없는 상품의 경우 메인 페이지로 연결하도록 구현
Jul 18, 2024
847cdf3
refactor: not Found 상태 관리를 useGetProductDetail hook으로 이동
Jul 18, 2024
a187a8d
feat: 나에게 선물하기 버튼 클릭 시, 로그인 되어 있지 않다면 로그인 페이지로 이동하도록 구현
Jul 18, 2024
888e5cf
feat: Chakra UI를 이용해서 상품 결제(Order) 페이지 구현
Jul 18, 2024
fc4c898
docs: 기능 구현 사항 추가 및 완료 상태 수정
Jul 18, 2024
dd4209d
feat: 상세 페이지에서 로그인 페이지로 이동하면, 로그인 후 다시 상세페이지로 돌아오도록 쿼리 파라미터 수정
Jul 18, 2024
319715a
refactor: 불필요한 콘솔 로그 제거
Jul 18, 2024
4f7925c
docs: 2단계 기능 구현 목록 작성
Jul 18, 2024
244cf60
feat: /api/v1/products/{productId}/options를 받아오도록 구현
Jul 19, 2024
840391e
feat: 카드 메시지가 100글자가 넘어가면 100자 이내로 입력하라고 안내하는 alert창 구현
Jul 19, 2024
b48f60e
feat: 현금 영수증 checkbox 클릭 시 현금영수증 번호가 입력되었는지 확인하는 alert 창 구현
Jul 19, 2024
d92aa26
feat: 현금 영수증 입력은 숫자만 입력하도록 안내하는 alert창 구현
Jul 19, 2024
5a211a5
feat: 상품 상세 페이지에서 상품의 개수를 option API의 giftOrderLimit을 초과한 경우 선택이 불가하도…
Jul 19, 2024
d14c2f4
docs: 3단계 기능 구현 목록 작성
Jul 19, 2024
267c43c
chore: react-hook-form 라이브러리 추가
Jul 19, 2024
d0831a5
feat: 기존에 만든 제품 상세페이지의 form / input을 react-hook-form으로 변경
Jul 19, 2024
ae19a21
refactor: reack-hook-form 기능을 활용해서 제품 상세페이지 validate 구현
Jul 19, 2024
b42dd18
refactor: 기존에 만든 주문 페이지를 react-hook-form을 이용하도록 코드 수정
Jul 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# 카카오 테크 캠퍼스 - 프론트엔드 카카오 선물하기 편

### 3단계 기능 구현 목록
- [x] 기존에 만든 form / input을 react-hook-form으로 변경
- [x] validate 또한 react-hook-form 기능을 적극적으로 활용 (이 과정에서 zod를 사용해도 좋음)
1,543 changes: 1,501 additions & 42 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@
]
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@tanstack/react-query": "^5.24.1",
"axios": "^1.6.7",
"framer-motion": "^11.3.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.52.1",
"react-intersection-observer": "^9.8.1",
"react-router-dom": "^6.22.1"
},
Expand Down
13 changes: 8 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query';

import { queryClient } from './api/instance';
Expand All @@ -6,11 +7,13 @@ import { Routes } from './routes';

const App = () => {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Routes />
</AuthProvider>
</QueryClientProvider>
<ChakraProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Routes />
</AuthProvider>
</QueryClientProvider>
</ChakraProvider>
);
};

Expand Down
27 changes: 27 additions & 0 deletions src/api/hooks/useGetProductDetail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useEffect,useState } from "react";

import { useFetchData } from "@/hooks/useFetchData";
import type { ProductDetailResponseData } from "@/types";

const getProductDetailPath = ({ productId }: { productId: number }) =>
`v1/products/${productId}/detail`;

export const useGetProductDetail = () => {
const [notFound, setNotFound] = useState(false);
const { data, loading, error } = useFetchData<ProductDetailResponseData>(
(productId) => getProductDetailPath({ productId })
);

useEffect(() => {
if (!loading && !data) {
setNotFound(true);
}
}, [loading, data]);

return {
productDetail: data?.detail || null,
loading,
error,
notFound
};
};
17 changes: 17 additions & 0 deletions src/api/hooks/useGetProductOption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useFetchData } from "@/hooks/useFetchData";
import type { ProductOptionData } from "@/types";

const getProductOptionPath = ({ productId }: { productId: number }) =>
`/v1/products/${productId}/options`;

export const useGetProductOptions = () => {
const { data, loading, error } = useFetchData<ProductOptionData>(
(productId) => getProductOptionPath({ productId })
);

return {
productOption: data || null,
loading,
error
};
};
7 changes: 7 additions & 0 deletions src/api/hooks/useProductId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { useLocation } from "react-router-dom";

export const useProductId = () => {
const location = useLocation();
const productId = location.pathname.split('/')[2];
return Number(productId);
};
2 changes: 1 addition & 1 deletion src/api/instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const initInstance = (config: AxiosRequestConfig): AxiosInstance => {
};

export const fetchInstance = initInstance({
baseURL: 'https://kakao-tech-campus-mock-server.vercel.app/api',
baseURL: 'https://react-gift-mock-api-userjmmm.vercel.app/api',
});

export const queryClient = new QueryClient({
Expand Down
17 changes: 10 additions & 7 deletions src/components/features/Theme/ThemeGoodsSection/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import styled from '@emotion/styled';
import { Link } from 'react-router-dom';

import { useGetThemesProducts } from '@/api/hooks/useGetThemesProducts';
import { DefaultGoodsItems } from '@/components/common/GoodsItem/Default';
Expand Down Expand Up @@ -41,13 +42,15 @@ export const ThemeGoodsSection = ({ themeKey }: Props) => {
gap={16}
>
{flattenGoodsList.map(({ id, imageURL, name, price, brandInfo }) => (
<DefaultGoodsItems
key={id}
imageSrc={imageURL}
title={name}
amount={price.sellingPrice}
subtitle={brandInfo.name}
/>
<Link key={id} to={`/products/${id}`} >
<DefaultGoodsItems
key={id}
imageSrc={imageURL}
title={name}
amount={price.sellingPrice}
subtitle={brandInfo.name}
/>
</Link>
))}
</Grid>
{hasNextPage && (
Expand Down
37 changes: 37 additions & 0 deletions src/hooks/useFetchData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useEffect, useRef,useState } from "react";

import { useProductId } from "../api/hooks/useProductId";
import { fetchInstance } from "../api/instance";

export const fetchData = async <T>(path: string): Promise<T> => {
const response = await fetchInstance.get<T>(path);
return response.data;
};

export const useFetchData = <T>(getPath: (productId: number) => string) => {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const productId = useProductId();
const hasFetched = useRef(false);

useEffect(() => {
const fetchDataAsync = async () => {
try {
const fetchedData = await fetchData<T>(getPath(productId));
setData(fetchedData);
} catch (err) {
setError("Failed to fetch data.");
} finally {
setLoading(false);
}
};

if (!hasFetched.current) {
fetchDataAsync();
hasFetched.current = true;
}
}, [productId, getPath]);

return { data, loading, error };
};
155 changes: 155 additions & 0 deletions src/pages/Order/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { Box, Button, Checkbox, Flex, Image, Input, Select,Text, Textarea, VStack } from "@chakra-ui/react";
import { Controller, useForm } from "react-hook-form";
import { Navigate,useLocation } from "react-router-dom";

type FormValues = {
message: string;
cashReceipt: boolean;
cashReceiptType: string;
cashReceiptNumber: string;
};

export const OrderPage = () => {
const location = useLocation();
const { productDetail, productQuantity } = location.state || {};

const { control, handleSubmit, setValue, watch, formState: { errors } } = useForm<FormValues>({
defaultValues: {
message: "",
cashReceipt: false,
cashReceiptType: "PERSONAL",
cashReceiptNumber: ""
}
});

const watchCashReceipt = watch("cashReceipt");
const totalPrice = productDetail.price.sellingPrice * productQuantity;

const onSubmit = (data: FormValues) => {
if (data.message.trim() === "") {
alert("선물 메시지를 입력해주세요.");
return;
}

if (data.cashReceipt) {
if (data.cashReceiptNumber.trim() === "") {
alert("현금영수증 번호를 입력해주세요.");
return;
}

if (isNaN(Number(data.cashReceiptNumber))) {
alert("현금영수증 번호는 숫자만 입력해주세요.");
return;
}
}

alert("주문이 완료되었습니다.");
};

if (!productDetail || !productQuantity) {
return <Navigate to="/" />;
}

return (
<Box p={8}>
<form onSubmit={handleSubmit(onSubmit)}>
<Text fontSize="2xl" fontWeight="bold" mb={4}>나에게 주는 선물</Text>
<Flex direction="column" gap={4}>
<Box flex="1" p={4} borderWidth="1px" borderRadius="md">
<Text fontSize="lg" fontWeight="bold">선물 메시지</Text>
<Controller
name="message"
control={control}
rules={{
required: "선물 메시지를 입력해주세요.",
maxLength: {
value: 100,
message: "선물 메시지는 100자 이내로 입력해주세요."
}
}}
render={({ field }) => (
<Textarea
{...field}
placeholder="선물과 함께 보낼 메시지를 적어보세요"
size="lg"
/>
)}
/>
{errors.message && <Text color="red.500">{errors.message.message}</Text>}
</Box>

<Box flex="1" p={4} borderWidth="1px" borderRadius="md">
<Text fontSize="lg" fontWeight="bold">선물 내역</Text>
<Flex direction="row" align="center">
<Image src={productDetail.imageURL} alt={productDetail.name} boxSize="100px" />
<Box ml={4}>
<Text>{productDetail.name}</Text>
<Text>{productDetail.price.sellingPrice}원 x {productQuantity}개</Text>
</Box>
</Flex>
</Box>

<Box flex="1" p={4} borderWidth="1px" borderRadius="md">
<Text fontSize="lg" fontWeight="bold" mb={4}>결제 정보</Text>
<VStack align="flex-start" spacing={4}>
<Controller
name="cashReceipt"
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onChange={(e) => {
setValue("cashReceipt", e.target.checked);
if (!e.target.checked) {
setValue("cashReceiptNumber", "");
}
}}
>
현금영수증 신청
</Checkbox>
)}
/>
{watchCashReceipt && (
<>
<Text>현금영수증 타입</Text>
<Controller
name="cashReceiptType"
control={control}
render={({ field }) => (
<Select {...field}>
<option value="PERSONAL">개인소득공제</option>
<option value="BUSINESS">사업자지출증빙</option>
</Select>
)}
/>
<Text>현금영수증 번호</Text>
<Controller
name="cashReceiptNumber"
control={control}
rules={{
required: watchCashReceipt && "현금영수증 번호를 입력해주세요.",
validate: value => !watchCashReceipt || !isNaN(Number(value)) || "현금영수증 번호는 숫자만 입력해주세요."
}}
render={({ field }) => (
<Input
{...field}
type="text"
placeholder="(-없이) 숫자만 입력해주세요"
/>
)}
/>
{errors.cashReceiptNumber && <Text color="red.500">{errors.cashReceiptNumber.message}</Text>}
</>
)}
<Text fontSize="lg" fontWeight="bold">최종 결제금액</Text>
<Text fontSize="2xl" fontWeight="bold">{totalPrice}원</Text>
<Button type="submit" colorScheme="yellow" size="lg">
{totalPrice}원 결제하기
</Button>
</VStack>
</Box>
</Flex>
</form>
</Box>
);
};
Loading