Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
da0ed9c
✨ feat: 가게 정보 등록 및 수정(중간 커밋)
minimo-9 Jun 18, 2025
30150a9
✨ feat: 가게 설명 입력란 추가 및 핸들러 수정
minimo-9 Jun 18, 2025
d559612
Merge branch 'develop' into feat/70-store-edit
minimo-9 Jun 18, 2025
59443ec
🐛 fix: selected prop의 타입을 T | ''로 변경
minimo-9 Jun 18, 2025
6a988f5
✨ feat: api 연동 전 중간 커밋
minimo-9 Jun 18, 2025
aed820f
Merge branch 'develop' into feat/70-store-edit
minimo-9 Jun 18, 2025
a8560f4
✨ feat: 가게 등록 및 수정 페이지 구현
minimo-9 Jun 19, 2025
bfc885e
Merge branch 'develop' into feat/70-store-edit
minimo-9 Jun 19, 2025
8507490
✨ feat: 로그아웃 시 사용자 인증 모달 추가 및 메시지 개선
minimo-9 Jun 19, 2025
227eb0b
🐛 fix: 로그인 필요 메시지 개선 및 중복 코드 제거
minimo-9 Jun 19, 2025
efd29d8
🐛 fix: 모달 경고 메시지 개선
minimo-9 Jun 19, 2025
51e2779
🐛 fix: 필수 입력 필드 체크 로직 개선
minimo-9 Jun 19, 2025
898f9c7
🐛 fix: 에러 메시지 처리 개선 및 불필요한 인터페이스 제거
minimo-9 Jun 19, 2025
1894800
🐛 fix: axios를 사용하여 S3 업로드 요청 수정
minimo-9 Jun 19, 2025
656933e
🐛 fix: 피드백 수정 적용 완료
minimo-9 Jun 20, 2025
235320a
🐛 fix: 카테고리 및 주소 필드를 null로 초기화하여 입력값 검증 개선
minimo-9 Jun 20, 2025
0a54c45
🐛 fix: selected 및 setSelect 타입을 null로 변경하여 입력값 검증 개선
minimo-9 Jun 20, 2025
9dfb840
🐛 fix: 피드백 반영 완료
minimo-9 Jun 20, 2025
c700f54
Merge branch 'develop' into feat/70-store-edit
minimo-9 Jun 20, 2025
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
16 changes: 6 additions & 10 deletions src/api/imageApi.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import api from './api';
import { AxiosError } from 'axios';
import axios, { AxiosError } from 'axios';
import type { LinkInfo } from './shopApi';

interface ErrorMessage {
message: string;
}

interface ImageResponse {
item: {
url: string; // ✅ 쿼리 스트링 포함된 presigned S3 PUT URL
Expand All @@ -21,9 +17,9 @@ export const getPresignedUrl = async (filename: string): Promise<string> => {
});
return response.data.item.url;
} catch (error) {
const axiosError = error as AxiosError<ErrorMessage>; // 에러 타입 명시
const axiosError = error as AxiosError; // 에러 타입 명시
if (axiosError.response) {
throw new Error(axiosError.response.data.message);
throw new Error('URL 생성에 실패했습니다.');
} else {
throw new Error('서버에 연결할 수 없습니다. 인터넷 연결을 확인해주세요.');
}
Expand All @@ -36,15 +32,15 @@ export const uploadImageToS3 = async (
file: File,
): Promise<void> => {
try {
await api.put(uploadUrl, file, {
await axios.put(uploadUrl, file, {
headers: {
'Content-Type': file.type,
},
});
} catch (error) {
const axiosError = error as AxiosError<ErrorMessage>; // 에러 타입 명시
const axiosError = error as AxiosError; // 에러 타입 명시
if (axiosError.response) {
throw new Error(axiosError.response.data.message);
throw new Error('이미지 업로드에 실패했습니다.');
} else {
throw new Error('서버에 연결할 수 없습니다. 인터넷 연결을 확인해주세요.');
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Down from '@/assets/icons/dropdown.svg';

interface DropdownProps<T extends string> {
options: readonly T[];
selected: T;
selected: T | '';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

에고 그 주영님께서 null로 사용하자는 피드백 주셔서 제가 null로 수정했는데 ㅠㅠ 혹시 드롭다운 요소 초기값을 null로 변경 가능하실까요?
좀 복잡하면 그냥 제가 ''로 되돌리겠습니다! 크게 상관은 없는것 같아서!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엇.. 그렇군요 ㅠ

setSelect: Dispatch<SetStateAction<T>>; // Dispatch<SetStateAction<T>>는 set함수 타입
placeholder?: string;
variant: 'form' | 'filter';
Expand Down
306 changes: 305 additions & 1 deletion src/pages/store/StoreEdit.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,307 @@
import { useLocation, useNavigate } from 'react-router-dom';
import Input from '@/components/common/Input';
import Dropdown from '@/components/common/Dropdown';
import { ADDRESS_OPTIONS, CATEGORY_OPTIONS } from '@/constants/dropdownOptions';
import Close from '@/assets/icons/close.svg';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
postShop,
putShop,
type ShopItem,
type ShopRequest,
} from '@/api/shopApi';
import ImageInput from '@/components/common/ImageInput';
import Button from '@/components/common/Button';
import Modal from '@/components/common/Modal';
import { getPresignedUrl, uploadImageToS3 } from '@/api/imageApi';
import { AuthContext } from '@/context/AuthContext';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 import 문을 비슷한 요소끼리 정렬해 놓으면 좀 더 이해하기 편할 것 같습니다~

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 정리 해두겠습니다!


type Category = (typeof CATEGORY_OPTIONS)[number];
type Address1 = (typeof ADDRESS_OPTIONS)[number];

interface StoreEditForm extends Omit<ShopRequest, 'category' | 'address1'> {
category: Category | '';
address1: Address1 | '';
}

type InitialData = ShopItem | undefined;

type ModalType = 'success' | 'warning' | 'auth';

export default function StoreEdit() {
return <div>내 가게 정보 등록/편집</div>;
const navigate = useNavigate();
const location = useLocation();
const initialData = location.state as InitialData;
Copy link
Contributor

@Moon-ju-young Moon-ju-young Jun 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 아마 location.state를 통해 구현하신 걸 보면 navigate에서 데이터를 넘겨주는 것이지 않나 싶은데 이렇게 될 경우엔 주소 직접 치고 들어올 때는 데이터가 있는데 없다고 인식하는 경우가 있지 않을까 싶네요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 그래서 api에서 가져오는 방식으로 수정했습니다!

const { isLoggedIn } = useContext(AuthContext);

const isEditMode =
initialData !== undefined && typeof initialData?.id === 'string';

const [edit, setEdit] = useState<StoreEditForm>(
initialData
? {
name: initialData.name,
category: initialData.category,
address1: initialData.address1,
address2: initialData.address2,
description: initialData.description,
originalHourlyPay: initialData.originalHourlyPay,
imageUrl: initialData.imageUrl,
}
: {
name: '',
category: '',
address1: '',
address2: '',
description: '',
originalHourlyPay: 0,
imageUrl: '',
},
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const [edit, setEdit] = useState<StoreEditForm>(
initialData
? {
name: initialData.name,
category: initialData.category,
address1: initialData.address1,
address2: initialData.address2,
description: initialData.description,
originalHourlyPay: initialData.originalHourlyPay,
imageUrl: initialData.imageUrl,
}
: {
name: '',
category: '',
address1: '',
address2: '',
description: '',
originalHourlyPay: 0,
imageUrl: '',
},
);
const [edit, setEdit] = useState<StoreEditForm>({
name: initialData?.name ?? '',
category: initialData?.category ?? '',
address1: initialData?.address1 ?? '',
address2: initialData?.address2 ?? '',
description: initialData?.description ?? '',
originalHourlyPay: initialData?.originalHourlyPay ?? 0,
imageUrl: initialData?.imageUrl ?? '',
});

이게 좀더 보기 쉬울것 같습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 그렇네요 감사합니다! 변경할게요

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const [edit, setEdit] = useState<StoreEditForm>(
initialData
? {
name: initialData.name,
category: initialData.category,
address1: initialData.address1,
address2: initialData.address2,
description: initialData.description,
originalHourlyPay: initialData.originalHourlyPay,
imageUrl: initialData.imageUrl,
}
: {
name: '',
category: '',
address1: '',
address2: '',
description: '',
originalHourlyPay: 0,
imageUrl: '',
},
);
const [edit, setEdit] = useState<StoreEditForm>(
initialData ?? {
name: '',
category: '',
address1: '',
address2: '',
description: '',
originalHourlyPay: 0,
imageUrl: '',
},
);

💬 이런 방식도 가능할 것 같습니다~

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 좋습니다! 변경할게요!


const [isModalOpen, setIsModalOpen] = useState(false);
const [modalContent, setModalContent] = useState('');
const [modalType, setModalType] = useState<ModalType>('success');

// x 버튼 눌렀을 때
const handleClose = () => {
navigate('/owner/store');
};

// 공통 문자열 핸들러
const handleChange = useCallback(
(key: keyof StoreEditForm) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setEdit((prev) => ({ ...prev, [key]: e.target.value }));
},
[],
);

// 숫자 전용 핸들러
const handleNumberChange = useCallback(
(key: keyof StoreEditForm) => (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value.replace(/[^0-9]/g, '');
setEdit((prev) => ({ ...prev, [key]: Number(raw) }));
},
[],
);

// 이미지 핸들러
const handleImageChange = async (file: File) => {
try {
const presignedUrl = await getPresignedUrl(file.name);
const fileUrl = presignedUrl.split('?')[0]; // S3 저장용 URL(쿼리 제거)

await uploadImageToS3(presignedUrl, file);

setEdit((prev) => ({ ...prev, imageUrl: fileUrl }));
} catch (error) {
alert((error as Error).message);
}
};

const formatNumber = useMemo(
() => (value: string) => {
return Number(value).toLocaleString();
},
[],
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 여기서 useCallback이 아니라 useMemo를 쓰신 이유가 있으실까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 제가 따로 따로 작업하다보니 다른 부분이랑 통일을 못했네요! 변경해두겠습니다!


// 로그아웃 시 모달 창 뜸
useEffect(() => {
const userId = localStorage.getItem('userId');

if (!userId) {
setModalType('auth');
setModalContent('로그인이 필요합니다.');
setIsModalOpen(true);
return;
}
}, [isLoggedIn]);

// 등록 버튼 처리
const handleSubmit = async () => {
// 필수 입력 값
const requiredFields = [
{ key: 'name', label: '가게 이름' },
{ key: 'category', label: '분류' },
{ key: 'address1', label: '주소' },
{ key: 'address2', label: '상세 주소' },
{ key: 'originalHourlyPay', label: '기본 시급' },
{ key: 'imageUrl', label: '가게 이미지' },
{ key: 'description', label: '가게 설명' },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ 가게 설명도 필수인가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

필수 부분은 아닌걸로 아는데 있는게 좋을 것 같아 넣었습니다! 요건 빼도록 하겠습니다!

];

// 입력 안할 시 모달로 알려줌
for (const { key, label } of requiredFields) {
const value = edit[key as keyof StoreEditForm];

const isEmpty = typeof value === 'string' ? value.trim() === '' : !value;

if (isEmpty) {
setModalType('warning');
setModalContent(`${label} 내용을 추가해 주세요.`);
setIsModalOpen(true);
return;
}
}

try {
const requestBody: ShopRequest = {
...edit,
category: edit.category as ShopRequest['category'],
address1: edit.address1 as ShopRequest['address1'],
};

// 등록, 수정을 구분
if (isEditMode && initialData?.id) {
await putShop(initialData.id, requestBody);
} else {
await postShop(requestBody);
}

setModalType('success');
setModalContent(
isEditMode ? '수정이 완료되었습니다.' : '등록이 완료되었습니다.',
);
setIsModalOpen(true);
} catch (error) {
setModalType('warning');
setModalContent((error as Error).message);
setIsModalOpen(true);
}
};

// 모달 버튼 기능
const handleModalClose = () => {
setIsModalOpen(false);

switch (modalType) {
case 'success':
navigate('/owner/store');
break;
case 'auth':
navigate('/login');
break;
case 'warning':
default:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case 'warning':
default:
default:

💬 간단하게 이렇게 작성해도 될 듯 합니다~

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

변경했습니다!

break;
}
};

return (
<div className="min-h-screen bg-gray-5">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❗ 이 부분은 진우님께도 피드백 드린 부분인데 Nav 컴포넌트가 페이지 바깥에 있어서 h-screen 같은 스타일을 사용하면 실제 화면 크기보다 높이가 더 높아져 불필요한 스크롤이 생겨버립니다! 수정 부탁드려요~

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 수정했습니다!

<div className="flex flex-col px-12 pt-40 pb-80 md:px-32 md:pb-60 lg:mx-auto lg:max-w-964 lg:px-0">
<div className="mb-24 flex items-center justify-between md:mb-32">
<h2 className="text-h3/25 font-bold text-black md:text-h1">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<h2 className="text-h3/25 font-bold text-black md:text-h1">
<h2 className="text-h3/25 font-bold text-black md:text-h1/24">

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h1/24 이부분은 28인것 같습니다! 100%로 나온 부분이라

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오우 오타가 났네요 24가 아니라 34나 35가 맞을 것 같습니다!

가게 정보
</h2>
<button onClick={handleClose}>
<img src={Close} alt="닫기" className="md:size-32" />
</button>
</div>

<div className="mb-20 flex flex-col gap-20 md:mb-24 md:flex-row md:gap-20">
<div className="md:max-w-472 md:basis-1/2">
<Input
label="가게 이름*"
value={edit.name}
onChange={handleChange('name')}
/>
</div>
<div className="flex flex-col gap-8 md:max-w-472 md:basis-1/2">
<label className="text-body1/26 font-regular text-black">
분류*
</label>
<Dropdown<Category>
options={CATEGORY_OPTIONS}
selected={edit.category}
setSelect={(value) =>
setEdit((prev) => ({ ...prev, category: value as Category }))
}
variant="form"
/>
</div>
</div>

<div className="mb-20 flex flex-col gap-20 md:mb-24 md:gap-24">
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

보통 input을 포함하고 해당 값들을 전달하는 버튼이 필요한 부분은 form으로 감싸주는게 좋습니다!
또한 form의 onSubmit 속성을 활용하면 enter를 눌러서 등록버튼을 클릭할 수도 있습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 알겠습니다! 변경해보겠습니다!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ input 에 대해서 굳이 div 태그를 두 부분으로 분리하신 이유가 있으실까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

드롭다운 부분에 따로 라벨 props가 없어서 div 감싸는 걸 먼저 작업 후에 태블릿,데스크탑 사이즈를 작업하다보니 두 개로 나눈 것 같습니다! 요건 수정하겠습니다

<div className="flex flex-col gap-20 md:flex-row md:gap-20">
<div className="flex flex-col gap-8 md:max-w-472 md:basis-1/2">
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이부분 저도 고민했던 부분인데 grid로 하니까 편하더라구요! 이건 그냥 참고만 하세요! 방법은 여러가지니 ㅎㅎ

<div className="grid grid-cols-1 md:grid-cols-2">

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 그리드도 좋은 방법이네요 ㅎㅎ

<label className="text-body1/26 font-regular text-black">
주소*
</label>
<Dropdown<Address1>
options={ADDRESS_OPTIONS}
selected={edit.address1}
setSelect={(value) =>
setEdit((prev) => ({ ...prev, address1: value as Address1 }))
}
variant="form"
/>
</div>
<div className="md:max-w-472 md:basis-1/2">
<Input
label="상세 주소*"
value={edit.address2}
onChange={handleChange('address2')}
/>
</div>
</div>
<div className="flex flex-col md:flex-row md:items-center md:gap-20">
<div className="md:max-w-472 md:basis-1/2">
<Input
label="기본 시급*"
value={
edit.originalHourlyPay === 0
? ''
: formatNumber(String(edit.originalHourlyPay))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 formatNumber 가 한 번 밖에 쓰이지 않는 것 같아 굳이 함수로 분리하지 않아도 될 것 같네요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 알겠습니다!

}
onChange={handleNumberChange('originalHourlyPay')}
unit="원"
/>
</div>
<div className="hidden md:block md:max-w-472 md:basis-1/2"></div>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ 이 요소는 왜 있는 걸까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flex로 작업을 하다보니 태블릿에서 기본 시급 input 사이즈가 다른 사이즈들 보다 먼저 길어져 있어 맞추기 위해 넣었습니다
image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인했습니다~ grid를 활용한다면 빈 div 태그 없이 처리 할 수도 있을 것 같네요

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 그리드로 바꿔도 좋을 것 같습니다!

</div>
</div>

<div className="md:w-483">
<ImageInput
label="가게 이미지"
imageUrl={edit.imageUrl}
onImageChange={handleImageChange}
mode={isEditMode ? 'edit' : 'create'}
/>
</div>

<div className="mt-20 mb-24 flex flex-col gap-8 md:mt-24 md:mb-32">
<label
htmlFor="description"
className="text-body1/26 font-regular text-black"
>
가게 설명
</label>
<textarea
id="description"
value={edit.description}
onChange={handleChange('description')}
placeholder="입력"
className="h-153 w-full rounded-[5px] border border-gray-30 bg-white px-20 py-16 text-body1/26 font-regular text-black"
/>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<textarea
id="description"
value={edit.description}
onChange={handleChange('description')}
placeholder="입력"
className="h-153 w-full rounded-[5px] border border-gray-30 bg-white px-20 py-16 text-body1/26 font-regular text-black"
/>
<textarea
id="description"
value={edit.description}
onChange={handleChange('description')}
placeholder="입력"
className="h-153 w-full resize-none rounded-[5px] border border-gray-30 bg-white px-20 py-16 text-body1/26 font-regular text-black"
/>

image
이걸 숨기려면 class에 resize-none을 추가해주시면 됩니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 까먹고 있었네요 감사합니다!

</div>
<Button
type="submit"
onClick={handleSubmit}
className="md:mx-auto md:w-312"
>
{isEditMode ? '수정하기' : '등록하기'}
</Button>

{isModalOpen && (
<Modal onClose={handleModalClose} onButtonClick={handleModalClose}>
{modalContent}
</Modal>
)}
</div>
</div>
);
}