diff --git a/public/images/icons/ActiveBellIcon.svg b/public/images/icons/ActiveBellIcon.svg new file mode 100644 index 0000000..26cae73 --- /dev/null +++ b/public/images/icons/ActiveBellIcon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/icons/CalendarIcon.svg b/public/images/icons/CalendarIcon.svg index 9595725..cc11b89 100644 --- a/public/images/icons/CalendarIcon.svg +++ b/public/images/icons/CalendarIcon.svg @@ -1,4 +1,4 @@ - - + + diff --git a/src/app/(global)/(mypage)/myCreateExperiences/page.tsx b/src/app/(global)/(mypage)/myCreateExperiences/page.tsx index d595220..6c24902 100644 --- a/src/app/(global)/(mypage)/myCreateExperiences/page.tsx +++ b/src/app/(global)/(mypage)/myCreateExperiences/page.tsx @@ -17,6 +17,7 @@ import { useTimeSlotStore } from '@/src/store/TimeSlotStore'; import { useActivityStore } from '@/src/store/useActivityStore'; import { openDaumPostcode } from '@/src/utils/daumPostcode'; import { format } from 'date-fns'; +import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { useForm, SubmitHandler, Controller } from 'react-hook-form'; @@ -38,7 +39,7 @@ interface ExperiencesFormData { export default function MyCreateExperiencesPage() { const dropdownItems = [ - { id: 1, title: '문화 ∙ 예술' }, + { id: 1, title: '문화 · 예술' }, { id: 2, title: '식음료' }, { id: 3, title: '스포츠' }, { id: 4, title: '투어' }, @@ -54,7 +55,6 @@ export default function MyCreateExperiencesPage() { } = useForm({ mode: 'onBlur' }); const [isAlertOpen, setIsAlertOpen] = useState(false); const [isConfirmOpen, setIsConfirmOpen] = useState(false); - const router = useRouter(); const handleAddressClick = async () => { @@ -66,18 +66,27 @@ export default function MyCreateExperiencesPage() { } }; + const handleBackClick = () => { + setIsConfirmOpen(true); + }; + const handleCloseModal = () => { router.back(); }; const handleConfirm = () => { setIsConfirmOpen(false); + router.back(); }; const handleCancel = () => { setIsConfirmOpen(false); }; + const formatNumber = (value?: number) => { + if (value === undefined || value === null) return ''; + return value.toLocaleString(); + }; const { timeSlots } = useTimeSlotStore(); const selectedDate = useReservationStore( (state) => state.dateSelector.selectedDate @@ -85,13 +94,12 @@ export default function MyCreateExperiencesPage() { const { bannerImages, subImages } = useActivityStore(); const onSubmit: SubmitHandler = async (data) => { - console.log('소개 이미지 상태:', subImages); try { // 1️⃣ schedules 변환 const schedules: Schedule[] = timeSlots .filter((slot) => slot.startTime && slot.endTime) .map((slot) => ({ - date: selectedDate ? format(selectedDate, 'yyyy-MM-dd') : '', + date: selectedDate ? format(selectedDate, 'yyyy-MM-dd') : 'yy/mm/dd', startTime: slot.startTime!, endTime: slot.endTime!, })); @@ -105,11 +113,14 @@ export default function MyCreateExperiencesPage() { ? await uploadActivityImages(subImages) : []; - console.log('업로드 후 소개 이미지 URL:', subImageUrls); + const categoryTitle = + dropdownItems.find((item) => item.id === Number(data.category)) + ?.title ?? ''; // 4️⃣ payload 생성 const payload = { ...data, + category: categoryTitle, schedules, bannerImageUrl: bannerUrl, subImageUrls, @@ -128,9 +139,19 @@ export default function MyCreateExperiencesPage() {
-

내 체험 등록

+
+

내 체험 등록

+ +
- ( + { + const raw = e.target.value.replace(/,/g, ''); + const parsed = raw === '' ? undefined : Number(raw); + field.onChange(parsed); + }} + errorMessage={errors.price?.message} + /> + )} /> (0); const { setIsTabOpen } = useContext(TabContext); const { data, isPending } = useQuery(queries.myExperiencesOptions()); + const mutation = useMutation(queries.myExperiencesMutationOptions()); return (
@@ -31,14 +36,21 @@ export default function MyExperiencesPage() {

- + + + {!isPending ? ( <> {data && data.totalCount !== 0 ? ( <> {data.activities.map((activity) => ( - + ))} ) : ( @@ -59,6 +71,15 @@ export default function MyExperiencesPage() { ) : ( )} + { + mutation.mutate(selectedExperience); + setIsModalVisible(false); + }} + onCancel={() => setIsModalVisible(false)} + isOpen={isModalVisible} + message='정말 체험을 삭제하시겠습니까?' + /> ); } diff --git a/src/app/(global)/(mypage)/myInfo/page.tsx b/src/app/(global)/(mypage)/myInfo/page.tsx index 488f462..e831d7f 100644 --- a/src/app/(global)/(mypage)/myInfo/page.tsx +++ b/src/app/(global)/(mypage)/myInfo/page.tsx @@ -55,7 +55,7 @@ export default function MypageLayout() {
(); const router = useRouter(); const [isAlertOpen, setIsAlertOpen] = useState(false); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); const [existingSubImages, setExistingSubImages] = useState< { id: number; url: string }[] >([]); + const [existingSchedules, setExistingSchedules] = useState([]); + const { timeSlots, setTimeSlots } = useTimeSlotStore(); + const { bannerImages, subImages, setBannerImages, setSubImages } = + useActivityStore(); + const { setSelectedDate } = useReservationStore( + (state) => state.dateSelector + ); + const [bannerUrls, setBannerUrls] = useState([]); + const [subUrls, setSubUrls] = useState([]); + const [categoryDefaultValue, setCategoryDefaultValue] = useState<{ + id: number; + title: string; + } | null>(null); // ✅ 수정 전용 useForm const { @@ -80,38 +107,51 @@ export default function MyUpdateExperiencesPage() { } }; - const handleCloseModal = () => { + const handleBackClick = () => { + setIsConfirmOpen(true); + }; + + const handleConfirm = () => { + setIsConfirmOpen(false); router.back(); }; - const { timeSlots, setTimeSlots } = useTimeSlotStore(); - const { bannerImages, subImages, setBannerImages, setSubImages } = - useActivityStore(); - const { setSelectedDate } = useReservationStore( - (state) => state.dateSelector - ); + const handleCancel = () => { + setIsConfirmOpen(false); + }; - // 🆕 서버에서 받아온 기존 이미지(URL) 상태 - const [bannerUrls, setBannerUrls] = useState([]); - const [subUrls, setSubUrls] = useState([]); + const handleCloseModal = () => { + router.back(); + }; + + const formatNumber = (value?: number) => { + if (value === undefined || value === null) return ''; + return value.toLocaleString(); + }; // 1️⃣ 기존 데이터 불러오기 useEffect(() => { const fetchData = async () => { try { - // console.log('id:', id); - const detail = await getExperienceDetail(id); // form 값 세팅 reset({ title: detail.title, - category: detail.category, + category: + dropdownItem + .find((el) => el.title === detail.category) + ?.id.toString() || '', description: detail.description, price: detail.price, address: detail.address, }); + // 카테고리 상태 세팅 + setCategoryDefaultValue( + dropdownItem.find((el) => el.title === detail.category) ?? null + ); + // 이미지 상태 세팅 setBannerUrls([detail.bannerImageUrl]); setSubUrls( @@ -121,10 +161,12 @@ export default function MyUpdateExperiencesPage() { ); setExistingSubImages(detail.subImages ?? []); + setExistingSchedules(detail.schedules ?? []); // timeSlots & 날짜 세팅 setTimeSlots( detail.schedules.map((s: Schedule) => ({ + id: s.id, startTime: s.startTime, endTime: s.endTime, })) @@ -141,6 +183,17 @@ export default function MyUpdateExperiencesPage() { const onSubmit = async (data: ExperiencesFormData) => { try { + const selectedCategory = dropdownItem.find( + (el) => el.id === parseInt(data.category) + ); + + if (!selectedCategory) { + alert('유효한 카테고리를 선택해주세요.'); + return; + } + + data.category = selectedCategory.title; + // 1️⃣ 스케줄 처리 const schedulesToAdd = timeSlots .filter((slot) => slot.startTime && slot.endTime) @@ -150,17 +203,16 @@ export default function MyUpdateExperiencesPage() { endTime: slot.endTime!, })); + const scheduleIdsToRemove = existingSchedules + .filter((s) => !timeSlots.some((slot) => slot.id === s.id)) // 현재 timeSlots에 없는 스케줄 + .map((s) => s.id); + // 2️⃣ 배너 이미지 처리 const bannerUrl = bannerImages[0] ? await uploadActivityImage(bannerImages[0]) : bannerUrls[0]; // 기존 URL 유지 // 3️⃣ 서브 이미지 처리 - // 삭제할 기존 이미지 ID 추출 - const subImageIdsToRemove = existingSubImages - .filter((img) => !subUrls.includes(img.url)) // 삭제된 것만 - .map((img) => img.id); - // 새로 업로드한 파일만 업로드 const newFiles = subImages.filter( (file) => file instanceof File @@ -170,8 +222,11 @@ export default function MyUpdateExperiencesPage() { let subImageUrlsToAdd: string[] = []; if (newFiles.length > 0) { subImageUrlsToAdd = await uploadActivityImages(newFiles); - console.log('📌 새로 업로드한 이미지 URL:', subImageUrlsToAdd); } + // 삭제할 기존 이미지 ID 추출 + const subImageIdsToRemove = existingSubImages + .filter((img) => !subUrls.includes(img.url)) // 삭제된 것만 + .map((img) => img.id); // 4️⃣ payload 구성 (기존 이미지는 API에서 자동 유지됨) const payload: UpdateExperiencePayload = { @@ -179,7 +234,7 @@ export default function MyUpdateExperiencesPage() { bannerImageUrl: bannerUrl, subImageIdsToRemove, subImageUrlsToAdd, - scheduleIdsToRemove: [], + scheduleIdsToRemove, schedulesToAdd, }; @@ -196,10 +251,19 @@ export default function MyUpdateExperiencesPage() {
-

체험 수정하기

- +
+

체험 수정하기

+ +
- ( )} /> - - - ( + { + const raw = e.target.value.replace(/,/g, ''); + const parsed = raw === '' ? undefined : Number(raw); + field.onChange(parsed); + }} + errorMessage={errors.price?.message} + /> + )} /> - - - - +
); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 109d5ea..850c07d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,8 @@ import '@/src/styles/globals.css'; import { pretendard } from '@/src/styles/fonts'; import QueryProvider from '@/src/components/primitives/QueryProvider'; import AuthProvicder from '@/src/components/primitives/AuthProvider'; +import { Suspense } from 'react'; +import LoadingSpinner from '@/src/components/primitives/LoadingSpinner'; export const metadata: Metadata = { title: 'Global Nomad', @@ -18,7 +20,9 @@ export default function RootLayout({ - {children} + + }>{children} +
diff --git a/src/components/pages/detail/ActivityFooter.tsx b/src/components/pages/detail/ActivityFooter.tsx index 306c136..5c2bd51 100644 --- a/src/components/pages/detail/ActivityFooter.tsx +++ b/src/components/pages/detail/ActivityFooter.tsx @@ -2,6 +2,8 @@ import { IActivity } from '@/src/types/scheduleType'; import Button from '../../primitives/Button'; import { format } from 'date-fns'; import { useReservationStore } from '@/src/store/ReservationStore'; +import { createReservation } from '@/src/services/pages/detail/postReservation'; +import { useToastStore } from '@/src/store/useToastStore'; export default function ActivityFooter({ activity, @@ -16,12 +18,28 @@ export default function ActivityFooter({ const timeInfo = activity.schedules.find( (schedule) => schedule.id === timeSelector.timeId ); + const createToast = useToastStore((state) => state.createToast); const handleReserveClick = () => { - console.log({ + createReservation(activity.id, { scheduleId: timeSelector.timeId!, headCount: personSelector.person, - }); + }) + .then(() => { + createToast({ + message: '예약이 완료되었습니다!', + type: 'success', + }); + }) + .catch((err) => { + createToast({ + message: + err.response.status === 409 + ? '이미 예약된 스케줄입니다!' + : '예약이 실패했습니다!', + type: 'failed', + }); + }); }; return ( diff --git a/src/components/pages/detail/ReservationUI.tsx b/src/components/pages/detail/ReservationUI.tsx index a5207e6..dbe5dc7 100644 --- a/src/components/pages/detail/ReservationUI.tsx +++ b/src/components/pages/detail/ReservationUI.tsx @@ -7,15 +7,32 @@ import Calendar from './DateSelector/Calendar'; import { useReservationStore } from '@/src/store/ReservationStore'; import TimeSelectorButtons from './DateSelector/TimeSelector'; import { createReservation } from '@/src/services/pages/detail/postReservation'; +import { useToastStore } from '@/src/store/useToastStore'; export default function ReservationUI({ activity }: { activity: IActivity }) { const { personSelector, timeSelector } = useReservationStore(); + const createToast = useToastStore((state) => state.createToast); const handleReserveClick = async () => { - const data = await createReservation(activity.id, { + createReservation(activity.id, { scheduleId: timeSelector.timeId!, headCount: personSelector.person, - }); + }) + .then(() => { + createToast({ + message: '예약이 완료되었습니다!', + type: 'success', + }); + }) + .catch((err) => { + createToast({ + message: + err.response.status === 409 + ? '이미 예약된 스케줄입니다!' + : '예약이 실패했습니다!', + type: 'failed', + }); + }); }; return ( diff --git a/src/components/pages/main/AllExperiencesSection.tsx b/src/components/pages/main/AllExperiencesSection.tsx index bf55ade..9c186f6 100644 --- a/src/components/pages/main/AllExperiencesSection.tsx +++ b/src/components/pages/main/AllExperiencesSection.tsx @@ -39,10 +39,10 @@ export default function AllExperiencesSection() { const totalCount = allExperiences?.totalCount; const lastPage = totalCount && Math.ceil(totalCount / size); - if (lastPage && size > lastPage) { + if (lastPage && page > lastPage) { setPage(lastPage); } - }, [allExperiences, size]); + }, [allExperiences, size, page]); return (
diff --git a/src/components/pages/main/SearchResultSection.tsx b/src/components/pages/main/SearchResultSection.tsx index 375e2b4..f746a5a 100644 --- a/src/components/pages/main/SearchResultSection.tsx +++ b/src/components/pages/main/SearchResultSection.tsx @@ -21,10 +21,10 @@ export default function SearchResultSection() { const totalCount = resultList?.totalCount; const lastPage = totalCount && Math.ceil(totalCount / size); - if (lastPage && size > lastPage) { + if (lastPage && page > lastPage) { setPage(lastPage); } - }, [size, resultList]); + }, [resultList, size, page]); useEffect(() => { // 검색값이 변경되면, 페이지네이션 초기화 diff --git a/src/components/pages/myCreateExperiences/AvailableTimeSlots.tsx b/src/components/pages/myCreateExperiences/AvailableTimeSlots.tsx index 37e27a7..8971210 100644 --- a/src/components/pages/myCreateExperiences/AvailableTimeSlots.tsx +++ b/src/components/pages/myCreateExperiences/AvailableTimeSlots.tsx @@ -1,21 +1,35 @@ -import TimepickerDropdown from './TimePickerDropdown'; +'use client'; + +import TimepickerDropdown from './Dropdown'; +import { useBreakPoint } from '@/src/hooks/useBreakPoint'; import CalendarInput from '../../primitives/input/CalendarInput'; import { useTimeSlotStore } from '@/src/store/TimeSlotStore'; import Image from 'next/image'; export default function AvailableTimeSlots() { const { timeSlots, setTimeSlots } = useTimeSlotStore(); + const { isMd } = useBreakPoint(); + const availableTimes: string[] = Array.from({ length: 24 }, (_, i) => { const hour = i.toString().padStart(2, '0'); return `${hour}:00`; }); const handleAddSlot = () => { - const lastSlot = timeSlots[timeSlots.length - 1]; - if (!lastSlot.startTime || !lastSlot.endTime) return; - setTimeSlots([...timeSlots, { startTime: '', endTime: '' }]); - }; + // 실제 슬롯 배열 (첫 번째는 UI 가이드용이므로 제외) + const actualSlots = timeSlots; + // 마지막 실제 슬롯이 완전히 입력되었거나 슬롯이 없는 경우에만 추가 + if ( + actualSlots.length === 0 || + (actualSlots[actualSlots.length - 1].startTime && + actualSlots[actualSlots.length - 1].endTime) + ) { + setTimeSlots([...actualSlots, { startTime: '', endTime: '' }]); + } else { + alert('예약 날짜를 모두 입력해야 새로운 슬롯을 추가할 수 있습니다.'); + } + }; const handleRemoveSlot = (index: number) => { const updatedSlots = timeSlots.filter((_, i) => i !== index); setTimeSlots(updatedSlots); @@ -28,6 +42,22 @@ export default function AvailableTimeSlots() { ) => { const updatedSlots = [...timeSlots]; updatedSlots[index][field] = value; + + const start = updatedSlots[index].startTime; + const end = updatedSlots[index].endTime; + + const toMinutes = (t: string) => { + const [h, m] = t.split(':').map(Number); + return h * 60 + m; + }; + + // 시간 순서 체크 + if (start && end && toMinutes(start) >= toMinutes(end)) { + alert('체험 시작 시간은 종료 시간보다 빨라야 합니다.'); + return; + } + + // 중복 체크 const duplicate = updatedSlots.some( (slot, i) => i !== index && @@ -48,55 +78,97 @@ export default function AvailableTimeSlots() {

예약 가능한 시간대

날짜

- -
-
- handleChange(0, 'startTime', time)} - placeholder='00:00' - /> -
- MinusIcon -
- handleChange(0, 'endTime', time)} - placeholder='00:00' - /> +
+ {/* 첫 번째 가이드 슬롯 */} +
+
+ +
-
+
+
+
+ +
+
PlusIcon +
+
+ +
+
+
+ PlusIcon +
+
+ {timeSlots.map((slot, index) => ( +
+ {/* CalendarInput */} +
+ +
- {timeSlots.slice(1).map((slot, index) => ( -
- -
+ {/* Timepicker + 버튼 그룹 (남은 공간 차지) */} +
- handleChange(index + 1, 'startTime', time) - } + onSelect={(time) => handleChange(index, 'startTime', time)} placeholder='00:00' />
@@ -110,16 +182,17 @@ export default function AvailableTimeSlots() { handleChange(index + 1, 'endTime', time)} + onSelect={(time) => handleChange(index, 'endTime', time)} placeholder='00:00' />
-
+
handleRemoveSlot(index)} + className='flex cursor-pointer items-center justify-center bg-gray-50 rounded-[30px] w-7 h-7' + > handleRemoveSlot(index + 1)} src='/images/icons/MinusIcon.svg' alt='MinusIcon' - className='cursor-pointer' width={16} height={16} /> diff --git a/src/components/pages/myCreateExperiences/TimePickerDropdown.tsx b/src/components/pages/myCreateExperiences/Dropdown.tsx similarity index 76% rename from src/components/pages/myCreateExperiences/TimePickerDropdown.tsx rename to src/components/pages/myCreateExperiences/Dropdown.tsx index 482b28b..0d86023 100644 --- a/src/components/pages/myCreateExperiences/TimePickerDropdown.tsx +++ b/src/components/pages/myCreateExperiences/Dropdown.tsx @@ -8,6 +8,7 @@ interface TimepickerDropdownProps { selectedTime?: string; onSelect: (time: string) => void; placeholder?: string; + disabled?: boolean; } export default function TimepickerDropdown({ @@ -15,15 +16,21 @@ export default function TimepickerDropdown({ selectedTime, onSelect, placeholder = '시간 선택', + disabled = false, }: TimepickerDropdownProps) { const [open, setOpen] = useState(false); - + const handleButtonClick = () => { + if (disabled) return; // ✅ 비활성화 시 클릭 무시 + setOpen((prev) => !prev); + }; return (
{isModalVisible && ( @@ -27,10 +47,14 @@ export default function LoggingInGnb() { />
e.stopPropagation()} > - +
)} diff --git a/src/components/primitives/global/Header/UserMenuDropdown.tsx b/src/components/primitives/global/Header/UserMenuDropdown.tsx index b2b3eae..4f585ee 100644 --- a/src/components/primitives/global/Header/UserMenuDropdown.tsx +++ b/src/components/primitives/global/Header/UserMenuDropdown.tsx @@ -16,7 +16,7 @@ export default function UserMenuDropdown({ }; return ( -
+
+
+
+

+ 알림 {notifications?.totalCount ?? 0}개 +

+
+ +
+
+ +
+ {items.length ? ( + <> + {items.map((n) => { + const notificationType = getNotificationType(n.content); + const { relative } = dateToCalendarDate(new Date(n.createdAt)); + const formatted = parseContent(n.content); + + return ( +
+
+
+
+

+ 예약{' '} + {notificationType === 'RESERVATION_APPROVED' + ? '승인' + : '거절'} +

+ + {relative} + +
+ +
+
+

+

+
+
+ ); + })} + {hasMore && ( +
+ 불러오는 중... +
+ )} + + ) : ( +
+ 아직 알람이 없습니다 +
+ )}
-
므아지경
); } diff --git a/src/components/primitives/toast/ToastContainer.tsx b/src/components/primitives/toast/ToastContainer.tsx index 73b3b56..335d1d8 100644 --- a/src/components/primitives/toast/ToastContainer.tsx +++ b/src/components/primitives/toast/ToastContainer.tsx @@ -1,3 +1,5 @@ +'use client'; + import Toast from '@/src/components/primitives/toast/Toast'; import { useToastStore } from '@/src/store/useToastStore'; import { createPortal } from 'react-dom'; @@ -5,6 +7,8 @@ import { createPortal } from 'react-dom'; export default function ToastContainer() { const toasts = useToastStore((state) => state.toasts); + if (toasts.length === 0) return; + return createPortal(
{toasts.map((toast, idx) => ( diff --git a/src/services/pages/detail/postReservation.ts b/src/services/pages/detail/postReservation.ts index 9702628..1fc4736 100644 --- a/src/services/pages/detail/postReservation.ts +++ b/src/services/pages/detail/postReservation.ts @@ -1,19 +1,20 @@ import { IReservation } from '@/src/types/activityType'; import { apiClient } from '../../primitives/apiClient'; +import { CancelMyReservationResponse } from '@/src/types/myReservationType'; export async function createReservation( activityId: number, body: IReservation ) { try { - const res = await apiClient.post( + const res = await apiClient.post( `/activities/${activityId}/reservations`, body ); - if (res.status !== 201) throw new Error(res.data.message); return res.data; } catch (err) { if (err instanceof Error) console.error('예약에 실패했습니다!', err.message); + throw err; } } diff --git a/src/services/pages/myExperiences/api.ts b/src/services/pages/myExperiences/api.ts index cd7c8d9..3981e6a 100644 --- a/src/services/pages/myExperiences/api.ts +++ b/src/services/pages/myExperiences/api.ts @@ -44,3 +44,15 @@ export async function getMyReservationStatus( throw err; } } + +// 내 체험 지우기 +export async function deleteMyExperiences(experienceId: number) { + try { + await apiClient.delete(`/my-activities/${experienceId}`); + + return { ok: true }; + } catch (err) { + if (err instanceof Error) + console.error('체험을 지우는데 실패했습니다!', err.message); + } +} diff --git a/src/services/pages/notifications/api.ts b/src/services/pages/notifications/api.ts new file mode 100644 index 0000000..f2898a1 --- /dev/null +++ b/src/services/pages/notifications/api.ts @@ -0,0 +1,21 @@ +import { INotifications } from '@/src/types/notificationType'; +import { apiClient } from '../../primitives/apiClient'; + +interface Params { + cursorId?: number; + size?: number; +} + +export async function getNotifications(params?: Params) { + const { data } = await apiClient.get('/my-notifications', { + params, + }); + return data; +} + +export async function deleteNotificationById(notificationId: number) { + const { data } = await apiClient.delete( + `/my-notifications/${notificationId}` + ); + return data; +} diff --git a/src/services/primitives/queries.ts b/src/services/primitives/queries.ts index 5f89ad2..50d9e3f 100644 --- a/src/services/primitives/queries.ts +++ b/src/services/primitives/queries.ts @@ -2,8 +2,13 @@ import { ActivitiesParams, getActivities } from '@/src/services/pages/main/api'; import { getMyReservationList } from '@/src/services/pages/myReservation/api'; import getUserInfo from '@/src/services/primitives/getUserInfo'; import { ReservationStatus } from '@/src/types/myReservationType'; -import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; import { + infiniteQueryOptions, + mutationOptions, + queryOptions, +} from '@tanstack/react-query'; +import { + deleteMyExperiences, getMyExperiences, getMyReservationStatus, } from '../pages/myExperiences/api'; @@ -12,6 +17,9 @@ import { getReservedSchedule, getTimeSchedule, } from '../pages/myReservationStatus/myActivities'; +import { getQueryClient } from '@/src/utils/getQueryClient'; + +const queryClient = getQueryClient(); export const queries = { user: () => ['user'], @@ -39,6 +47,16 @@ export const queries = { queryKey: [...queries.myExperiences()], queryFn: () => getMyExperiences(), }), + myExperiencesMutationOptions: () => + mutationOptions({ + mutationKey: [...queries.myExperiences()], + mutationFn: (experienceId: number) => + deleteMyExperiences(experienceId ?? 0), + onSuccess: async () => + await queryClient.invalidateQueries({ + queryKey: [...queries.myExperiences()], + }), + }), allActivities: (params: ActivitiesParams) => ['allActivities', params], allActivitiesOptions: (params: ActivitiesParams) => queryOptions({ diff --git a/src/store/TimeSlotStore.ts b/src/store/TimeSlotStore.ts index d264016..a1a8f02 100644 --- a/src/store/TimeSlotStore.ts +++ b/src/store/TimeSlotStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; interface TimeSlot { + id?: number; startTime?: string; endTime?: string; } diff --git a/src/store/useTokenStore.ts b/src/store/useTokenStore.ts index 6f56494..019957d 100644 --- a/src/store/useTokenStore.ts +++ b/src/store/useTokenStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; interface TokenState { - accessToken: null | string | undefined; + accessToken: null | string; setAccessToken: (token: string | null) => void; deleteAccessToken: () => void; } @@ -11,7 +11,7 @@ export const useTokenStore = create()( persist( (set) => { return { - accessToken: undefined, + accessToken: null, setAccessToken: (token) => set({ accessToken: token }), deleteAccessToken: () => set({ accessToken: null }), }; @@ -19,11 +19,6 @@ export const useTokenStore = create()( { name: 'accessToken', storage: createJSONStorage(() => localStorage), - onRehydrateStorage: () => (state) => { - if (state?.accessToken === undefined) { - state?.setAccessToken(null); - } - }, } ) ); diff --git a/src/styles/globals.css b/src/styles/globals.css index 04b8f18..6f92163 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -98,4 +98,25 @@ opacity: 0; } } + + @layer base { + .scrollbar-hide::-webkit-scrollbar { + display: none; + } + .scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; + } + + .thin-scrollbar::-webkit-scrollbar { + width: 6px; + } + .thin-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); + border-radius: 9999px; + } + .thin-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + } } diff --git a/src/types/notificationType.ts b/src/types/notificationType.ts index 8ef0de8..1b10b1a 100644 --- a/src/types/notificationType.ts +++ b/src/types/notificationType.ts @@ -1,12 +1,15 @@ export interface INotification { - cursorId: number; - notification: { - id: number; - teamId: string; - userId: number; - createdAt: string; - updatedAt: string; - deletedAt: string; - }[]; + id: number; + teamId: string; + userId: number; + content: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface INotifications { + cursorId: number | null; + notifications: INotification[]; totalCount: number; } diff --git a/src/utils/dateParser.ts b/src/utils/dateParser.ts index adce60e..6979c8c 100644 --- a/src/utils/dateParser.ts +++ b/src/utils/dateParser.ts @@ -1,4 +1,5 @@ -import { format, getDay } from 'date-fns'; +import { format, formatDistanceToNow, getDay } from 'date-fns'; +import { ko } from 'date-fns/locale'; export function dateToCalendarDate(date: Date) { const calendarDate = { @@ -7,6 +8,7 @@ export function dateToCalendarDate(date: Date) { month: format(date, 'MM'), day: format(date, 'dd'), yoil: getDay(date), + relative: formatDistanceToNow(date, { addSuffix: true, locale: ko }), }; return calendarDate; diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts new file mode 100644 index 0000000..b2939f1 --- /dev/null +++ b/src/utils/notifications.ts @@ -0,0 +1,32 @@ +export const isRecent = (createdAt: string, updatedAt?: string) => { + const baseTime = updatedAt || createdAt; + return ( + new Date().getTime() - new Date(baseTime).getTime() < 24 * 60 * 60 * 1000 + ); +}; + +export type NotificationType = + | 'RESERVATION_APPROVED' + | 'RESERVATION_REJECTED' + | 'OTHER'; + +export const getNotificationType = (content: string): NotificationType => { + if (content.includes('승인')) return 'RESERVATION_APPROVED'; + if (content.includes('거절')) return 'RESERVATION_REJECTED'; + return 'OTHER'; +}; + +export const parseContent = (content: string) => { + let formatted = content.replace(/(\))\s*(예약)/, '$1
$2'); + if (content.includes('승인')) + formatted = formatted.replace( + '승인', + `승인` + ); + if (content.includes('거절')) + formatted = formatted.replace( + '거절', + `거절` + ); + return formatted; +};