Skip to content

Commit f27d204

Browse files
authored
Merge pull request #92 from Leets-Official/89-feat/판매페이지-API-연동
[Feat] 판매페이지 API 연동
2 parents 19d4d8e + d9e36ff commit f27d204

15 files changed

Lines changed: 264 additions & 57 deletions

File tree

src/pages/sell-confirm/ui/SellConfirmPage.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useDeleteSellPostMutation } from '@shared/apis/sell';
12
import checkerImg from '@shared/assets/icons/common/checker.png';
23
import { ROUTES } from '@shared/constants';
34
import { useModal, useToast } from '@shared/hooks';
@@ -8,7 +9,7 @@ import { useLocation, useNavigate } from 'react-router';
89
import type { SellState } from '@shared/types/sell';
910

1011
const CONDITION_LABELS = {
11-
productCondition: { used: '개봉-중고', unopened: '미개봉-새상품' },
12+
productCondition: { used: '개봉-중고', new: '미개봉-새상품' },
1213
scratchCondition: { clean: '스크래치 없음', scratch: '스크래치 있음' },
1314
screenCondition: { clean: '화면 깨짐 없음', broken: '화면 깨짐' },
1415
batteryCondition: {
@@ -27,6 +28,7 @@ const SellConfirmPage = () => {
2728
const [imageError, setImageError] = useState(false);
2829
const deleteModal = useModal();
2930
const state = (location.state ?? MOCK_SELL_CONFIRM_DATA) as SellConfirmState;
31+
const deleteSellPostMutation = useDeleteSellPostMutation(state.postId ?? '');
3032

3133
const displayPrice = `${new Intl.NumberFormat('ko-KR').format(Number(state.price))}원`;
3234
const showImage = Boolean(state.imageUrl) && !imageError;
@@ -117,10 +119,20 @@ const SellConfirmPage = () => {
117119
title="삭제하시겠어요?"
118120
subtitle="삭제하면 복구할 수 없어요."
119121
onCancel={deleteModal.close}
120-
onConfirm={() => {
122+
onConfirm={async () => {
123+
if (!state.postId) {
124+
showToast('삭제할 판매글을 찾을 수 없습니다.', 'error');
125+
deleteModal.close();
126+
return;
127+
}
121128
deleteModal.close();
122-
showToast('삭제되었습니다', 'success');
123-
navigate(ROUTES.MAIN);
129+
try {
130+
await deleteSellPostMutation.mutateAsync();
131+
showToast('삭제되었습니다', 'success');
132+
navigate(ROUTES.MAIN);
133+
} catch {
134+
showToast('삭제에 실패했습니다. 다시 시도해 주세요.', 'error');
135+
}
124136
}}
125137
cancelText="취소"
126138
confirmText="삭제"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { CreateSellPostRequest } from '@shared/apis/sell';
2+
import type { SellFormData } from '@shared/utils/schemas';
3+
4+
export const buildSellPostRequest = (form: SellFormData, imageUrl: string): CreateSellPostRequest => {
5+
const batteryStatus: CreateSellPostRequest['batteryStatus'] =
6+
form.batteryCondition === '80plus' ? 'GREAT' : form.batteryCondition === '80minus' ? 'GOOD' : 'BAD';
7+
8+
return {
9+
title: form.title,
10+
manufacturer: form.manufacturer,
11+
model: form.modelName,
12+
color: form.colorName,
13+
capacity: form.storageSize,
14+
price: Number(form.price),
15+
description: form.description,
16+
imageUrls: [imageUrl],
17+
hasScratch: form.scratchCondition === 'scratch',
18+
batteryStatus,
19+
screenCracked: form.screenCondition === 'broken',
20+
used: form.productCondition === 'used',
21+
};
22+
};

src/pages/sell/model/initialValues.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { SellFormData } from '@shared/utils/schemas';
33
import type { DefaultValues } from 'react-hook-form';
44

55
export const getSellFormDefaults = (): DefaultValues<SellFormData> => ({
6+
imageFile: null,
67
title: '',
78
price: '',
89
manufacturer: '',
@@ -17,6 +18,7 @@ export const getSellFormDefaults = (): DefaultValues<SellFormData> => ({
1718
});
1819

1920
export const mapSellDraftToForm = (state: SellState): DefaultValues<SellFormData> => ({
21+
imageFile: null,
2022
title: state.title ?? '',
2123
price: state.price ?? '',
2224
manufacturer: state.manufacturer ?? '',

src/pages/sell/model/useSellForm.ts

Lines changed: 68 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
import { zodResolver } from '@hookform/resolvers/zod';
2+
import { uploadImage } from '@shared/apis/image';
3+
import { useCreateSellPostMutation, useUpdateSellPostMutation } from '@shared/apis/sell';
24
import { ROUTES } from '@shared/constants';
35
import { useClickOutside, useToast } from '@shared/hooks';
4-
import { validateImageFile } from '@shared/utils';
5-
import { MAX_IMAGE_BYTES, sellSchema, type SellFormData } from '@shared/utils/schemas';
6-
import { useEffect, useRef, useState } from 'react';
6+
import { sellSchema, type SellFormData } from '@shared/utils/schemas';
7+
import { useEffect, useMemo, useRef, useState } from 'react';
78
import { useForm } from 'react-hook-form';
89
import { useLocation, useNavigate } from 'react-router';
10+
import { buildSellPostRequest } from './buildSellPostRequest';
911
import { getSellFormDefaults, mapSellDraftToForm } from './initialValues';
12+
import { useSellImage } from './useSellImage';
1013
import type { SellState } from '@shared/types/sell';
1114

1215
export const useSellForm = () => {
1316
const navigate = useNavigate();
1417
const location = useLocation();
1518
const { showToast } = useToast();
19+
const createSellPostMutation = useCreateSellPostMutation();
20+
const locationState = useMemo(() => (location.state ?? {}) as SellState, [location.state]);
21+
const editPostId = locationState.postId ?? null;
22+
const existingImageUrl = locationState.imageUrl ?? null;
23+
const updateSellPostMutation = useUpdateSellPostMutation(editPostId ?? '');
1624

1725
const {
1826
control,
@@ -29,7 +37,6 @@ export const useSellForm = () => {
2937
});
3038

3139
const hasInitialized = useRef(false);
32-
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
3340
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
3441
const dropdownRef = useRef<HTMLDivElement | null>(null);
3542

@@ -40,31 +47,12 @@ export const useSellForm = () => {
4047
const screenCondition = watch('screenCondition');
4148
const batteryCondition = watch('batteryCondition');
4249

43-
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
44-
const file = e.target.files?.[0];
45-
if (!file) {
46-
return;
47-
}
48-
49-
const validation = validateImageFile(file, MAX_IMAGE_BYTES);
50-
if (!validation.ok) {
51-
showToast(validation.message);
52-
setPreviewUrl(null);
53-
resetField('imageFile');
54-
setError('imageFile', {
55-
type: 'validate',
56-
message: validation.message,
57-
});
58-
return;
59-
}
60-
61-
const reader = new FileReader();
62-
reader.onload = () => {
63-
setPreviewUrl(reader.result as string);
64-
};
65-
reader.readAsDataURL(file);
66-
setValue('imageFile', file, { shouldValidate: true });
67-
};
50+
const { previewUrl, setPreviewUrl, handleImageChange } = useSellImage({
51+
showToast,
52+
setError,
53+
resetField,
54+
setValue,
55+
});
6856

6957
const closeDropdown = () => {
7058
setIsDropdownOpen(false);
@@ -94,29 +82,63 @@ export const useSellForm = () => {
9482
if (hasInitialized.current) {
9583
return;
9684
}
97-
const state = (location.state ?? {}) as SellState;
98-
if (Object.keys(state).length === 0) {
85+
if (Object.keys(locationState).length === 0) {
9986
return;
10087
}
101-
reset(mapSellDraftToForm(state));
102-
if (state.imageFile) {
103-
setValue('imageFile', state.imageFile, { shouldValidate: true });
88+
reset(mapSellDraftToForm(locationState));
89+
if (locationState.imageFile) {
90+
setValue('imageFile', locationState.imageFile, { shouldValidate: true });
10491
} else {
10592
resetField('imageFile');
10693
}
107-
setPreviewUrl(state.imageUrl ?? null);
94+
setPreviewUrl(locationState.imageUrl ?? null);
10895
hasInitialized.current = true;
109-
}, [location.state, reset, resetField, setValue]);
110-
111-
const onSubmit = handleSubmit((data) => {
112-
// API 추후 연결
113-
showToast('등록되었습니다', 'success');
114-
navigate(ROUTES.SELL_CONFIRM, {
115-
state: {
116-
...data,
117-
imageUrl: previewUrl,
118-
},
119-
});
96+
}, [locationState, reset, resetField, setValue, setPreviewUrl]);
97+
98+
const onSubmit = handleSubmit(async (data) => {
99+
try {
100+
const imageFile = data.imageFile;
101+
if (!imageFile && !existingImageUrl) {
102+
const message = '이미지를 업로드해 주세요.';
103+
showToast(message);
104+
setError('imageFile', { type: 'validate', message });
105+
return;
106+
}
107+
108+
let imageUrl = existingImageUrl ?? '';
109+
if (imageFile) {
110+
const uploaded = await uploadImage('PRODUCT', imageFile);
111+
imageUrl = uploaded.fileUrl;
112+
}
113+
114+
const request = buildSellPostRequest(data, imageUrl);
115+
116+
if (editPostId) {
117+
await updateSellPostMutation.mutateAsync(request);
118+
showToast('수정되었습니다', 'success');
119+
} else {
120+
const created = await createSellPostMutation.mutateAsync(request);
121+
showToast('등록되었습니다', 'success');
122+
navigate(ROUTES.SELL_CONFIRM, {
123+
state: {
124+
...data,
125+
postId: created.id,
126+
imageUrl,
127+
},
128+
});
129+
return;
130+
}
131+
132+
navigate(ROUTES.SELL_CONFIRM, {
133+
state: {
134+
...data,
135+
postId: editPostId,
136+
imageUrl,
137+
},
138+
});
139+
} catch {
140+
showToast('판매글 처리 실패. 다시 시도해 주세요.', 'error');
141+
}
120142
});
121143

122144
return {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { validateImageFile } from '@shared/utils';
2+
import { MAX_IMAGE_BYTES, type SellFormData } from '@shared/utils/schemas';
3+
import { useState } from 'react';
4+
import type { ToastTone } from '@shared/ui/Toast';
5+
import type { UseFormResetField, UseFormSetError, UseFormSetValue } from 'react-hook-form';
6+
7+
type UseSellImageParams = {
8+
showToast: (message: string, tone?: ToastTone) => void;
9+
setError: UseFormSetError<SellFormData>;
10+
resetField: UseFormResetField<SellFormData>;
11+
setValue: UseFormSetValue<SellFormData>;
12+
};
13+
14+
export const useSellImage = ({ showToast, setError, resetField, setValue }: UseSellImageParams) => {
15+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
16+
17+
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
18+
const file = e.target.files?.[0];
19+
if (!file) {
20+
return;
21+
}
22+
23+
const validation = validateImageFile(file, MAX_IMAGE_BYTES);
24+
if (!validation.ok) {
25+
showToast(validation.message);
26+
setPreviewUrl(null);
27+
resetField('imageFile');
28+
setError('imageFile', {
29+
type: 'validate',
30+
message: validation.message,
31+
});
32+
return;
33+
}
34+
35+
const reader = new FileReader();
36+
reader.onload = () => {
37+
setPreviewUrl(reader.result as string);
38+
};
39+
reader.readAsDataURL(file);
40+
setValue('imageFile', file, { shouldValidate: true });
41+
};
42+
43+
return { previewUrl, setPreviewUrl, handleImageChange };
44+
};

src/shared/apis/sell/api.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { axiosInstance } from '../axiosInstance';
2+
import { SELL_ENDPOINTS } from './endpoints';
3+
import type {
4+
CreateSellPostRequest,
5+
CreateSellPostResponse,
6+
CreateSellPostResponseBody,
7+
UpdateSellPostRequest,
8+
UpdateSellPostResponseBody,
9+
} from './types';
10+
11+
export const createSellPost = async (request: CreateSellPostRequest): Promise<CreateSellPostResponse> => {
12+
const response = await axiosInstance.post<CreateSellPostResponseBody>(SELL_ENDPOINTS.LIST, request);
13+
return response.data.data;
14+
};
15+
16+
export const updateSellPost = async (
17+
postId: number | string,
18+
request: UpdateSellPostRequest
19+
): Promise<{ id: number }> => {
20+
const response = await axiosInstance.put<UpdateSellPostResponseBody>(SELL_ENDPOINTS.ITEM(postId), request);
21+
return response.data.data;
22+
};
23+
24+
export const deleteSellPost = async (postId: number | string): Promise<void> => {
25+
await axiosInstance.delete(SELL_ENDPOINTS.ITEM(postId));
26+
};

src/shared/apis/sell/endpoints.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const SELL_ENDPOINTS = {
2+
LIST: '/sell-post',
3+
ITEM: (postId: number | string) => `/sell-post/${postId}`,
4+
} as const;

src/shared/apis/sell/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './api';
2+
export * from './endpoints';
3+
export * from './keys';
4+
export * from './queries';
5+
export * from './types';

src/shared/apis/sell/keys.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const sellKeys = {
2+
all: ['sell'] as const,
3+
lists: () => [...sellKeys.all, 'list'] as const,
4+
};

src/shared/apis/sell/queries.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useMutation, useQueryClient } from '@tanstack/react-query';
2+
import { createSellPost, deleteSellPost, updateSellPost } from './api';
3+
import { sellKeys } from './keys';
4+
import type { CreateSellPostRequest, UpdateSellPostRequest } from './types';
5+
export const useCreateSellPostMutation = () => {
6+
const queryClient = useQueryClient();
7+
return useMutation({
8+
mutationFn: (request: CreateSellPostRequest) => createSellPost(request),
9+
onSuccess: () => {
10+
queryClient.invalidateQueries({ queryKey: sellKeys.lists() });
11+
},
12+
});
13+
};
14+
15+
export const useUpdateSellPostMutation = (postId: number | string) => {
16+
const queryClient = useQueryClient();
17+
return useMutation({
18+
mutationFn: (request: UpdateSellPostRequest) => updateSellPost(postId, request),
19+
onSuccess: () => {
20+
queryClient.invalidateQueries({ queryKey: sellKeys.lists() });
21+
},
22+
});
23+
};
24+
25+
export const useDeleteSellPostMutation = (postId: number | string) => {
26+
const queryClient = useQueryClient();
27+
return useMutation({
28+
mutationFn: () => deleteSellPost(postId),
29+
onSuccess: () => {
30+
queryClient.invalidateQueries({ queryKey: sellKeys.lists() });
31+
},
32+
});
33+
};

0 commit comments

Comments
 (0)