Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
4 changes: 2 additions & 2 deletions src/components/common/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import Down from '@/assets/icons/dropdown.svg';

interface DropdownProps<T extends string> {
options: readonly T[];
selected: T;
setSelect: Dispatch<SetStateAction<T>>; // Dispatch<SetStateAction<T>>는 set함수 타입
selected: T | null;
setSelect: Dispatch<SetStateAction<T | null>>; // Dispatch<SetStateAction<T>>는 set함수 타입
placeholder?: string;
variant: 'form' | 'filter';
}
Expand Down
300 changes: 299 additions & 1 deletion src/pages/store/StoreEdit.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,301 @@
import { useNavigate } from 'react-router-dom';
import { useCallback, useContext, useEffect, useState } from 'react';
import Close from '@/assets/icons/close.svg';
import { ADDRESS_OPTIONS, CATEGORY_OPTIONS } from '@/constants/dropdownOptions';
import { getShop, postShop, putShop, type ShopRequest } from '@/api/shopApi';
import { getPresignedUrl, uploadImageToS3 } from '@/api/imageApi';
import Input from '@/components/common/Input';
import Dropdown from '@/components/common/Dropdown';
import ImageInput from '@/components/common/ImageInput';
import Button from '@/components/common/Button';
import Modal from '@/components/common/Modal';
import { AuthContext } from '@/context/AuthContext';

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

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

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

export default function StoreEdit() {
return <div>내 가게 정보 등록/편집</div>;
const navigate = useNavigate();
const { isLoggedIn } = useContext(AuthContext);

const shopId = localStorage.getItem('shopId');
Copy link
Contributor

Choose a reason for hiding this comment

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

❓ 저희 로컬스토리지에 shopId가 있나요??

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 isEditMode = !!shopId;

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

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

// 로그아웃 처리 및 등록 수정 모드
useEffect(() => {
const userId = localStorage.getItem('userId');

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

if (isEditMode && !shopId) {
Copy link
Contributor

Choose a reason for hiding this comment

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

isEditModeshopId에 의해 결정되는데 둘을 같이 체크하는 이유를 모르겠습니다! 이렇게 된다면 항상 거짓이 아닌가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

에러 메세지 끄려고 만들었는데 중복이였네요! 삭제하겠습니다!

setModalType('warning');
setModalContent('가게 정보를 찾을 수 없습니다.');
setIsModalOpen(true);
return;
}

if (!isEditMode) return;

const fetchShopInfo = async () => {
try {
const shopInfo = await getShop(shopId);
setEdit({
name: shopInfo.item.name ?? '',
category: shopInfo.item.category ?? null,
address1: shopInfo.item.address1 ?? null,
address2: shopInfo.item.address2 ?? '',
description: shopInfo.item.description ?? '',
originalHourlyPay: shopInfo.item.originalHourlyPay ?? 0,
imageUrl: shopInfo.item.imageUrl ?? '',
});
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
setEdit({
name: shopInfo.item.name ?? '',
category: shopInfo.item.category ?? null,
address1: shopInfo.item.address1 ?? null,
address2: shopInfo.item.address2 ?? '',
description: shopInfo.item.description ?? '',
originalHourlyPay: shopInfo.item.originalHourlyPay ?? 0,
imageUrl: shopInfo.item.imageUrl ?? '',
});
setEdit(shopInfo.item);

💬 이렇게 줄일 수 있을 것 같아요~

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

오 좋네요! 바꾸겠습니다

} catch (error) {
setModalType('warning');
setModalContent('가게 정보를 불러오는 데 실패했습니다.');
setIsModalOpen(true);
}
};
fetchShopInfo();
}, [isLoggedIn, shopId, isEditMode]);

// 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 handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

// 필수 입력 값
const requiredFields = [
{ key: 'name', label: '가게 이름' },
{ key: 'category', label: '분류' },
{ key: 'address1', label: '주소' },
{ key: 'address2', label: '상세 주소' },
{ key: 'originalHourlyPay', label: '기본 시급' },
{ key: 'imageUrl', label: '가게 이미지' },
];

// 입력 안할 시 모달로 알려줌
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 && shopId) {
await putShop(shopId, requestBody);
setModalType('success');
setModalContent('수정이 완료되었습니다.');
} else {
await postShop(requestBody);
setModalType('success');
setModalContent('등록이 완료되었습니다.');
}

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;
default:
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 gap-24 px-12 pt-40 pb-80 md:gap-32 md:px-32 md:pb-60 lg:mx-auto lg:max-w-964 lg:px-0">
<div className="flex items-center justify-between">
<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>
<form
onSubmit={handleSubmit}
className="flex flex-col gap-24 md:gap-32"
>
<div className="flex flex-col gap-20 md:gap-24">
<div className="grid grid-cols-1 gap-20 md:grid-cols-2 md:gap-y-24">
<Input
label="가게 이름*"
value={edit.name}
onChange={handleChange('name')}
/>
<div className="flex flex-col gap-8">
<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 className="flex flex-col gap-8">
<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>
<Input
label="상세 주소*"
value={edit.address2}
onChange={handleChange('address2')}
/>
<Input
label="기본 시급*"
value={
edit.originalHourlyPay === 0
? ''
: Number(edit.originalHourlyPay).toLocaleString()
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
: Number(edit.originalHourlyPay).toLocaleString()
: edit.originalHourlyPay.toLocaleString()

💬 originalHourlyPay가 원래 number형이라 이대로 써도 될 것 같습니다~

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

<div className="flex flex-col gap-8">
<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 resize-none rounded-[5px] border border-gray-30 bg-white px-20 py-16 text-body1/26 font-regular text-black"
/>
</div>
</div>
<Button type="submit" className="md:mx-auto md:w-312">
{isEditMode ? '수정하기' : '등록하기'}
</Button>
</form>
{isModalOpen && (
<Modal onClose={handleModalClose} onButtonClick={handleModalClose}>
{modalContent}
</Modal>
)}
</div>
</div>
);
}