Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
290 changes: 289 additions & 1 deletion src/pages/store/StoreEdit.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,291 @@
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';
import { getUser } from '@/api/userApi';

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 [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');

const [shopId, setShopId] = useState<string | null>(null);

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

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

try {
const user = await getUser(userId);
const id = user.item.shop?.item.id ?? null;
setShopId(id);

if (!id) return; // 등록 모드

const shopInfo = await getShop(id);
setEdit(shopInfo.item);
} catch (error) {
setModalType('warning');
setModalContent('가게 정보를 불러오는 데 실패했습니다.');
setIsModalOpen(true);
}
};

fetchInitialData();
}, [isLoggedIn]);

// 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 (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-[calc(100vh-102px)] bg-gray-5 md:min-h-[calc(100vh-70px)]">
<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/24 font-bold text-black md:text-h1/34">
가게 정보
</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
? ''
: edit.originalHourlyPay.toLocaleString()
}
onChange={handleNumberChange('originalHourlyPay')}
unit="원"
/>
</div>

<div className="md:w-483">
<ImageInput
label="가게 이미지"
imageUrl={edit.imageUrl}
onImageChange={handleImageChange}
mode={shopId ? '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">
{shopId ? '수정하기' : '등록하기'}
</Button>
</form>
{isModalOpen && (
<Modal onClose={handleModalClose} onButtonClick={handleModalClose}>
{modalContent}
</Modal>
)}
</div>
</div>
);
}