diff --git a/src/app/(with-header)/activities/[id]/components/ActivityDetailForm.tsx b/src/app/(with-header)/activities/[id]/components/ActivityDetailForm.tsx index 7a5b4a8..3516123 100644 --- a/src/app/(with-header)/activities/[id]/components/ActivityDetailForm.tsx +++ b/src/app/(with-header)/activities/[id]/components/ActivityDetailForm.tsx @@ -71,6 +71,8 @@ export default function ActivityDetailForm() { enabled: !!id && !!year && !!month, }); + + if (isLoading || !activityData) { return
로딩 중...
; } diff --git a/src/app/(with-header)/activities/[id]/components/Title.tsx b/src/app/(with-header)/activities/[id]/components/Title.tsx index 5355cd0..717a671 100644 --- a/src/app/(with-header)/activities/[id]/components/Title.tsx +++ b/src/app/(with-header)/activities/[id]/components/Title.tsx @@ -6,6 +6,9 @@ import ActivityDropdown from '@/components/ActivityDropdown'; import Menu from '@/components/ActivityDropdown/menu'; import Item from '@/components/ActivityDropdown/Item'; import Trigger from '@/components/ActivityDropdown/trigger'; +import { useParams } from 'next/navigation'; +import { useRouter } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; export default function Title({ title, @@ -15,6 +18,15 @@ export default function Title({ address, isOwner, }: ActivityDetail) { + const { id } = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const handleEdit = () => { + queryClient.invalidateQueries({ queryKey: ['edit-activity', id] }); + router.push(`/myactivity/${id}`); + }; + return (
@@ -39,7 +51,7 @@ export default function Title({ - alert('수정')}>수정하기 + 수정하기 alert('삭제')}>삭제하기 diff --git a/src/app/(with-header)/myactivity/[id]/components/EditActivityForm.tsx b/src/app/(with-header)/myactivity/[id]/components/EditActivityForm.tsx new file mode 100644 index 0000000..fd61910 --- /dev/null +++ b/src/app/(with-header)/myactivity/[id]/components/EditActivityForm.tsx @@ -0,0 +1,87 @@ +'use client'; + +import Button from '@/components/Button'; +import { InfoSection } from '../../components/InfoSection'; +import { ScheduleSelectForm } from '../../components/ScheduleSelectForm'; +import { ImageSection } from '../../components/ImageSection'; +import { useEditActivityForm } from '../hooks/useEditActivityForm'; + +interface SubImageType { + id?: number; + url: string | File; +} + +export default function EditActivityForm() { + const { + title, + category, + price, + description, + address, + mainImage, + subImages, + dates, + isLoading, + error, + setTitle, + setCategory, + setPrice, + setDescription, + setAddress, + handleSubImageRemove, + handleSubImagesAdd, + handleAddDate, + handleRemoveDate, + handleDateChange, + handleMainImageSelect, + handleMainImageRemove, + handleSubmit, + } = useEditActivityForm(); + + if (isLoading) return
로딩 중...
; + if (error) return
오류가 발생했습니다: {error.message}
; + + return ( +
+
+
+
+

체험 수정

+ +
+ + setPrice(Number(price))} + onDescriptionChange={setDescription} + onAddressChange={setAddress} + /> + + + + img.url)} + onMainImageSelect={handleMainImageSelect} + onMainImageRemove={handleMainImageRemove} + onSubImageAdd={handleSubImagesAdd} + onSubImageRemove={handleSubImageRemove} + /> + +
+
+ ); +} diff --git a/src/app/(with-header)/myactivity/[id]/hooks/useEditActivityForm.ts b/src/app/(with-header)/myactivity/[id]/hooks/useEditActivityForm.ts new file mode 100644 index 0000000..d30c454 --- /dev/null +++ b/src/app/(with-header)/myactivity/[id]/hooks/useEditActivityForm.ts @@ -0,0 +1,198 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { privateInstance } from '@/apis/privateInstance'; +import { uploadImage } from '../../utils/uploadImage'; +import { ActivityDetailEdit, Schedule } from '@/types/activityDetailType'; +import { AxiosError } from 'axios'; + +interface SubImageType { + id?: number; + url: string | File; +} + +export const useEditActivityForm = () => { + const { id } = useParams() as { id: string }; + const router = useRouter(); + const queryClient = useQueryClient(); + + const [title, setTitle] = useState(''); + const [category, setCategory] = useState(''); + const [price, setPrice] = useState(0); + const [description, setDescription] = useState(''); + const [address, setAddress] = useState(''); + const [mainImage, setMainImage] = useState(null); + const [originalSubImages, setOriginalSubImages] = useState( + [], + ); + const [subImages, setSubImages] = useState([]); + const [originalSchedules, setOriginalSchedules] = useState([]); + const [dates, setDates] = useState([]); + + const { data, isLoading, error } = useQuery({ + queryKey: ['edit-activity', id], + queryFn: async () => { + const res = await privateInstance.get(`/activities/${id}`); + return res.data; + }, + enabled: !!id, + }); + + useEffect(() => { + if (data) { + setTitle(data.title); + setCategory(data.category); + setPrice(data.price); + setDescription(data.description); + setAddress(data.address); + setMainImage(data.bannerImageUrl); + + const mappedSubImages: SubImageType[] = + data.subImages?.map((img) => ({ + id: img.id, + url: img.imageUrl, + })) ?? []; + + setOriginalSubImages(mappedSubImages); + setSubImages(mappedSubImages); + + setOriginalSchedules(data.schedules ?? []); + setDates(data.schedules ?? []); + } + }, [data]); + + const handleSubImageRemove = (index: number) => { + setSubImages((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleSubImagesAdd = (newFiles: File[]) => { + const remainingSlots = 4 - subImages.length; + const filesToAdd = newFiles.slice(0, remainingSlots); + const newSubImages = filesToAdd.map((file) => ({ url: file })); + setSubImages((prev) => [...prev, ...newSubImages]); + }; + + const handleAddDate = () => { + setDates([...dates, { date: '', startTime: '', endTime: '' }]); + }; + + const handleRemoveDate = (index: number) => { + setDates(dates.filter((_, i) => i !== index)); + }; + + const handleDateChange = ( + index: number, + field: keyof Schedule, + value: string, + ) => { + setDates((prev) => + prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)), + ); + }; + + const handleMainImageSelect = (file: File) => { + setMainImage(file); + }; + + const handleMainImageRemove = () => { + setMainImage(null); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + let bannerImageUrl = ''; + if (typeof mainImage === 'string') { + bannerImageUrl = mainImage; + } else if (mainImage instanceof File) { + bannerImageUrl = await uploadImage(mainImage); + } + + const subImageIdsToRemove = originalSubImages + .filter((orig) => !subImages.some((img) => img.id === orig.id)) + .map((img) => img.id) + .filter((id): id is number => id !== undefined); + + const subImageUrlsToAdd: string[] = []; + + for (const img of subImages) { + if (!img.id) { + if (img.url instanceof File) { + const uploadedUrl = await uploadImage(img.url); + subImageUrlsToAdd.push(uploadedUrl); + } else if (typeof img.url === 'string') { + subImageUrlsToAdd.push(img.url); + } + } + } + + const newSchedules = dates.filter((d) => !d.id); + + const scheduleIdsToRemove = originalSchedules + .filter((orig) => !dates.some((d) => d.id === orig.id)) + .map((d) => d.id) + .filter((id): id is number => id !== undefined); + + const payload = { + title, + category, + description, + address, + price, + bannerImageUrl, + subImageIdsToRemove, + subImageUrlsToAdd, + schedulesToAdd: newSchedules, + scheduleIdsToRemove, + }; + + await privateInstance.patch(`/editActivity/${id}`, payload); + + alert('수정이 완료되었습니다.'); //토스트로 대체 + queryClient.invalidateQueries({ queryKey: ['activity', id] }); + router.push(`/activities/${id}`); + } catch (err) { + const error = err as AxiosError; + const responseData = error.response?.data as + | { error?: string; message?: string } + | undefined; + console.error('전체 에러:', error); + alert( + //토스트로대체 + responseData?.error || + responseData?.message || + error.message || + '수정에 실패했습니다.', + ); + } + }; + + return { + title, + category, + price, + description, + address, + mainImage, + subImages, + dates, + isLoading, + error, + setTitle, + setCategory, + setPrice, + setDescription, + setAddress, + handleSubImageRemove, + handleSubImagesAdd, + handleAddDate, + handleRemoveDate, + handleDateChange, + handleMainImageSelect, + handleMainImageRemove, + handleSubmit, + }; +}; diff --git a/src/app/(with-header)/myactivity/[id]/page.tsx b/src/app/(with-header)/myactivity/[id]/page.tsx new file mode 100644 index 0000000..3c2d7f0 --- /dev/null +++ b/src/app/(with-header)/myactivity/[id]/page.tsx @@ -0,0 +1,9 @@ +import EditActivityForm from './components/EditActivityForm'; + +export default function Page() { + return ( +
+ +
+ ); +} diff --git a/src/app/(with-header)/myactivity/components/ScheduleSelectForm.tsx b/src/app/(with-header)/myactivity/components/ScheduleSelectForm.tsx index af2254e..4976aef 100644 --- a/src/app/(with-header)/myactivity/components/ScheduleSelectForm.tsx +++ b/src/app/(with-header)/myactivity/components/ScheduleSelectForm.tsx @@ -1,20 +1,15 @@ 'use client'; import { ScheduleSelect } from './ScheduleSelect'; - -interface ScheduleType { - date: string; - startTime: string; - endTime: string; -} +import { Schedule } from '@/types/activityDetailType'; interface ScheduleSelectFormProps { - dates: ScheduleType[]; + dates: Schedule[]; onAddDate: () => void; onRemoveDate: (index: number) => void; onDateChange: ( index: number, - field: keyof ScheduleType, + field: keyof Omit, value: string, ) => void; } @@ -39,9 +34,8 @@ export function ScheduleSelectForm({
{dates.map((dateSlot, idx) => ( -
+
1} onAddDate={onAddDate} diff --git a/src/app/api/editActivity/[id]/route.ts b/src/app/api/editActivity/[id]/route.ts new file mode 100644 index 0000000..4eb2526 --- /dev/null +++ b/src/app/api/editActivity/[id]/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import axios, { AxiosError } from 'axios'; + +const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_API_SERVER_URL; + +interface ErrorResponse { + message?: string; + error?: string; +} + +export async function PATCH(req: NextRequest) { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + const url = new URL(req.url); // <- 여기서 전체 URL 접근 + const segments = url.pathname.split('/'); + const id = segments[segments.indexOf('editActivity') + 1]; // editActivity 다음 segment가 id임 + + if (!id) { + return NextResponse.json( + { error: '유효하지 않은 요청입니다.' }, + { status: 400 }, + ); + } + + if (!accessToken) { + return NextResponse.json({ message: '액세스 토큰 없음' }, { status: 401 }); + } + + try { + const body = await req.json(); + + const response = await axios.patch( + `${BACKEND_BASE_URL}/my-activities/${id}`, + body, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }, + ); + + return NextResponse.json(response.data, { status: 200 }); + } catch (error: unknown) { + console.error('체험 등록 에러:', error); + + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + const status = axiosError.response?.status || 500; + const detail = axiosError.response?.data; + const errorMessage = detail?.message || detail?.error || '체험 등록 실패'; + + return NextResponse.json({ message: errorMessage }, { status }); + } + + return NextResponse.json({ message: '서버 내부 오류' }, { status: 500 }); + } +} diff --git a/src/types/activityDetailType.ts b/src/types/activityDetailType.ts index cf1a6b9..e19870e 100644 --- a/src/types/activityDetailType.ts +++ b/src/types/activityDetailType.ts @@ -45,7 +45,7 @@ export interface ActivitySubImage { export interface ActivityDetail { id: number; - isOwner : boolean; + isOwner: boolean; userId: number; title: string; description: string; @@ -64,3 +64,24 @@ export interface ActivityDetail { export interface BookinDateProps { schedules: ActivitySchedule[]; } + +export interface Schedule { + id?: number; + date: string; + startTime: string; + endTime: string; +} + +export interface ActivityDetailEdit { + title: string; + category: string; + description: string; + address: string; + price: number; + bannerImageUrl: string; + subImages: { + id: number; + imageUrl: string; + }[]; + schedules: Schedule[]; +}