diff --git a/src/app/(with-header)/myactivity/[id]/components/EditActivityForm.tsx b/src/app/(with-header)/myactivity/[id]/components/EditActivityForm.tsx index 8d3fc096..9f67b35b 100644 --- a/src/app/(with-header)/myactivity/[id]/components/EditActivityForm.tsx +++ b/src/app/(with-header)/myactivity/[id]/components/EditActivityForm.tsx @@ -24,6 +24,7 @@ export default function EditActivityForm() { dates, isLoading, isError, + editLoading, setTitle, setCategory, setPrice, @@ -58,6 +59,7 @@ export default function EditActivityForm() { + setPrice(Number(value))} + onPriceChange={setPrice} onDescriptionChange={setDescription} onAddressChange={setAddress} /> diff --git a/src/app/(with-header)/myactivity/hooks/useCreateActivityForm.ts b/src/app/(with-header)/myactivity/hooks/useCreateActivityForm.ts new file mode 100644 index 00000000..6dc69264 --- /dev/null +++ b/src/app/(with-header)/myactivity/hooks/useCreateActivityForm.ts @@ -0,0 +1,163 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import axios from 'axios'; +import { uploadImage } from '../utils/uploadImage'; +import { privateInstance } from '@/apis/privateInstance'; + +export interface DateSlot { + date: string; + startTime: string; + endTime: string; +} + +export const useCreateActivityForm = () => { + const [dates, setDates] = useState([ + { date: '', startTime: '', endTime: '' }, + ]); + const [mainImage, setMainImage] = useState(null); + const [subImage, setSubImage] = useState<(File | string)[]>([]); + const [title, setTitle] = useState(''); + const [category, setCategory] = useState(''); + const [price, setPrice] = useState(''); + const [description, setDescription] = useState(''); + const [address, setAddress] = useState(''); + + const router = useRouter(); + + const mutation = useMutation({ + mutationFn: async () => { + if (!mainImage) { + throw new Error('메인 이미지를 업로드해주세요.'); + } + + const parsedPrice = parseInt(price, 10); + if (isNaN(parsedPrice) || parsedPrice <= 0) { + throw new Error('유효한 가격을 입력해주세요.'); + } + + const payload = { + title, + category, + description, + address, + price: parsedPrice, + schedules: dates, + bannerImageUrl: mainImage, + subImageUrls: subImage, + }; + + const res = await privateInstance.post('/addActivity', payload); + return res.data; + }, + onSuccess: (data) => { + toast.success('체험이 성공적으로 등록되었습니다!'); + router.push(`/activities/${data.id}`); + }, + onError: (err) => { + if (axios.isAxiosError(err)) { + const detail = + err.response?.data?.detail?.message || + err.response?.data?.message || + '체험 등록 중 오류가 발생했습니다.'; + toast.error(detail); + } else { + toast.error( + err instanceof Error ? err.message : '알 수 없는 오류 발생', + ); + } + }, + }); + + const handleAddDate = () => { + setDates((prev) => [...prev, { date: '', startTime: '', endTime: '' }]); + }; + + const handleRemoveDate = (index: number) => { + setDates((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleDateChange = ( + index: number, + field: keyof DateSlot, + value: string, + ) => { + setDates((prev) => + prev.map((slot, i) => (i === index ? { ...slot, [field]: value } : slot)), + ); + }; + + const handleMainImageSelect = async (file: File) => { + try { + const url = await uploadImage(file); + setMainImage(url); + } catch { + toast.error('메인 이미지 업로드에 실패했습니다.'); + } + }; + + const handleMainImageRemove = () => { + setMainImage(null); + }; + + const handleSubImagesAdd = async (newFiles: File[]) => { + const remaining = 4 - subImage.length; + const filesToUpload = newFiles.slice(0, remaining); + try { + const uploadedUrls = await Promise.all( + filesToUpload.map((file) => uploadImage(file)), + ); + setSubImage((prev) => [...prev, ...uploadedUrls]); + } catch { + toast.error('서브 이미지 업로드 중 문제가 발생했습니다.'); + } + }; + + const handleSubImageRemove = (index: number) => { + setSubImage((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if ( + !title || + !category || + !description || + !address || + !price || + dates.length === 0 + ) { + toast.error('모든 필드를 입력해주세요.'); + return; + } + mutation.mutate(); + }; + + return { + title, + category, + price, + description, + address, + dates, + mainImage, + subImage, + setTitle, + setCategory, + setPrice, + setDescription, + setAddress, + handleAddDate, + handleRemoveDate, + handleDateChange, + handleMainImageSelect, + handleMainImageRemove, + handleSubImagesAdd, + handleSubImageRemove, + handleSubmit, + isLoading: mutation.isPending, + }; +}; diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 4b35909f..02fe680d 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -21,6 +21,7 @@ export default function Button({ children, variant, selected, + isLoading = false, ...props }: ButtonProps) { const variantClass: Record = { @@ -35,15 +36,16 @@ export default function Button({ return ( ); } diff --git a/src/components/FloatingBox/BookingInterface.tsx b/src/components/FloatingBox/BookingInterface.tsx index 462a30cb..09c254a0 100644 --- a/src/components/FloatingBox/BookingInterface.tsx +++ b/src/components/FloatingBox/BookingInterface.tsx @@ -32,12 +32,10 @@ export default function BookingInterface({ const setIsOpen = useBookingStore((state) => state.setIsOpen); const { selectedDate, - setSelectedDate, selectedTime, - setSelectedTime, participants, selectedTimeId, - setSelectedTimeId, + setToInitial, } = useBookingStore(); const { id } = useParams(); @@ -51,10 +49,7 @@ export default function BookingInterface({ }); toast.success('예약되었습니다!'); - setSelectedDate(null); - setSelectedTimeId(null); - setSelectedTime(''); - setIsOpen(false); + setToInitial(); } catch (err) { const error = err as AxiosError; const responseData = error.response?.data as @@ -154,7 +149,7 @@ export default function BookingInterface({
- ₩ 10,000{' '} + ₩{price} / 총 {participants}인 diff --git a/src/components/FloatingBox/hooks/useBookingMutation.ts b/src/components/FloatingBox/hooks/useBookingMutation.ts new file mode 100644 index 00000000..fbc78c4a --- /dev/null +++ b/src/components/FloatingBox/hooks/useBookingMutation.ts @@ -0,0 +1,52 @@ +import { useMutation } from '@tanstack/react-query'; +import { privateInstance } from '@/apis/privateInstance'; +import useBookingStore from '@/stores/Booking/useBookingStore'; +import { toast } from 'sonner'; +import { AxiosError } from 'axios'; +import { useParams } from 'next/navigation'; + +export function useBookingMutation(onSuccessCallback?: () => void) { + const { id } = useParams(); + const { + selectedTimeId, + participants, + setSelectedDate, + setSelectedTime, + setSelectedTimeId, + } = useBookingStore(); + const setIsOpen = useBookingStore((state) => state.setIsOpen); + + const mutation = useMutation({ + mutationFn: async () => { + return privateInstance.post(`/activities/${id}/reservation`, { + selectedTimeId, + participants, + }); + }, + onSuccess: () => { + toast.success('예약되었습니다!'); + setSelectedDate(null); + setSelectedTime(''); + setSelectedTimeId(null); + setIsOpen(false); + onSuccessCallback?.(); + }, + onError: (err) => { + const error = err as AxiosError; + const responseData = error.response?.data as + | { error?: string; message?: string } + | undefined; + + console.error('예약 에러:', error); + + toast.error( + responseData?.error || + responseData?.message || + error.message || + '예약에 실패했습니다.', + ); + }, + }); + + return mutation; +} diff --git a/src/hooks/useBookingMutation.ts b/src/hooks/useBookingMutation.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/stores/Booking/useBookingStore.ts b/src/stores/Booking/useBookingStore.ts index a665b564..f1bca045 100644 --- a/src/stores/Booking/useBookingStore.ts +++ b/src/stores/Booking/useBookingStore.ts @@ -1,17 +1,33 @@ import { create } from 'zustand'; import { BookingState } from '@/types/bookingStoreTypes'; -const useBookingStore = create((set) => ({ +const initialState: Omit< + BookingState, + | 'setAvailableDates' + | 'setSelectedTimeId' + | 'setSelectedDate' + | 'setSelectedTime' + | 'incrementParticipants' + | 'decrementParticipants' + | 'setIsOpen' + | 'setToInitial' +> = { selectedDate: new Date(), selectedTime: null, participants: 1, isOpen: false, availableDates: [], selectedTimeId: null, +}; + +const useBookingStore = create((set) => ({ + ...initialState, + setAvailableDates: (data) => set({ availableDates: data }), setSelectedTimeId: (id) => set({ selectedTimeId: id }), setSelectedDate: (date) => set({ selectedDate: date }), setSelectedTime: (time) => set({ selectedTime: time }), + incrementParticipants: () => set((state) => ({ participants: state.participants + 1 })), decrementParticipants: () => @@ -19,6 +35,8 @@ const useBookingStore = create((set) => ({ participants: state.participants > 1 ? state.participants - 1 : 1, })), setIsOpen: (open) => set({ isOpen: open }), + + setToInitial: () => set({ ...initialState }), })); export default useBookingStore; diff --git a/src/types/bookingStoreTypes.ts b/src/types/bookingStoreTypes.ts index 412f4006..85e6beef 100644 --- a/src/types/bookingStoreTypes.ts +++ b/src/types/bookingStoreTypes.ts @@ -20,4 +20,5 @@ export interface BookingState { setIsOpen: (open: boolean) => void; setAvailableDates: (data: BookingState['availableDates']) => void; + setToInitial: () => void; } diff --git a/src/types/buttonTypes.ts b/src/types/buttonTypes.ts index 51062006..4cae43c0 100644 --- a/src/types/buttonTypes.ts +++ b/src/types/buttonTypes.ts @@ -20,4 +20,5 @@ type Variant = 'primary' | 'secondary' | 'category'; export interface ButtonProps extends ButtonHTMLAttributes { variant: Variant; selected?: boolean; + isLoading?: boolean; } diff --git a/src/ui/MobileBookingModal.tsx b/src/ui/MobileBookingModal.tsx index 980d8d72..85a61dc2 100644 --- a/src/ui/MobileBookingModal.tsx +++ b/src/ui/MobileBookingModal.tsx @@ -42,7 +42,7 @@ export default function MobileModal({ return ( - + 예약하기 diff --git a/src/ui/TabletBookingModal.tsx b/src/ui/TabletBookingModal.tsx index 8bf72892..c5311c50 100644 --- a/src/ui/TabletBookingModal.tsx +++ b/src/ui/TabletBookingModal.tsx @@ -18,7 +18,7 @@ export default function TabletModal({ if (!isOpen) return null; return ( -
+

날짜