diff --git a/apps/what-today/src/apis/activityDetail.ts b/apps/what-today/src/apis/activityDetail.ts index 99d6476e..bdb9465c 100644 --- a/apps/what-today/src/apis/activityDetail.ts +++ b/apps/what-today/src/apis/activityDetail.ts @@ -1,4 +1,9 @@ -import { type ActivityWithSubImagesAndSchedules, activityWithSubImagesAndSchedulesSchema } from '@/schemas/activities'; +import { + type ActivityWithSubImagesAndSchedules, + activityWithSubImagesAndSchedulesSchema, + type Schedules, + schedulesSchema, +} from '@/schemas/activities'; import { type ActivityReviewsResponse, activityReviewsResponseSchema } from '@/schemas/activityReview'; import { type CreateReservationBodyDto, @@ -55,3 +60,22 @@ export const createReservation = async ( const response = await axiosInstance.post(`/activities/${activityId}/reservations`, body); return reservationResponseSchema.parse(response.data); }; + +// ✅ 체험 예약 가능일 리스트 조회 시 사용되는 파라미터 타입 +export interface ReservationAvailableScheduleParams { + year: string; + month: string; +} + +/** + * @description 체험 예약 가능일 리스트를 불러옵니다. + * @param activityId 체험 ID + * @returns ActivityWithSubImagesAndSchedules 타입의 체험 상세 데이터 + */ +export const fetchReservationAvailableSchedule = async ( + activityId: number, + params: ReservationAvailableScheduleParams, +): Promise => { + const response = await axiosInstance.get(`/activities/${activityId}/available-schedule`, { params }); + return schedulesSchema.parse(response.data); +}; diff --git a/apps/what-today/src/components/Header.tsx b/apps/what-today/src/components/Header.tsx index 117edd9d..5634296c 100644 --- a/apps/what-today/src/components/Header.tsx +++ b/apps/what-today/src/components/Header.tsx @@ -31,7 +31,7 @@ export default function Header() {
- + {user?.profileImageUrl ? ( 프로필 이미지 void; +// /** +// * 클릭했을 때 sidebar 열림 여부 변경 +// */ +// onClick: () => void; +// /** +// * MypageSidebar의 열림 여부 +// */ +// isOpen: boolean; +// } + +/** + * MypageSidebar 컴포넌트 + * + * 사용자의 프로필 이미지와 마이페이지 관련 메뉴를 표시합니다. + * 현재 URL 경로에 따라 해당 메뉴 항목을 하이라이트 처리합니다. + * 로그아웃 버튼을 포함하며, 클릭 시 지정된 콜백을 실행합니다. + * + * @component + * @example + * setSidebarOpen((prev) => !prev)} + * onLogoutClick={() => alert('hi')} + * /> + */ +export default function MypageMainSidebar() { + const location = useLocation(); + + /** + * 사이드바에 표시할 고정 메뉴 항목 목록 + * 각 항목은 라벨, 아이콘 컴포넌트, 이동 경로로 구성됩니다. + */ + const items = [ + { icon: UserIcon, label: '내 정보', to: '/mypage/edit-profile' }, + { icon: ListIcon, label: '예약 내역', to: '/mypage/reservations-list' }, + { icon: SettingIcon, label: '내 체험 관리', to: '/mypage/manage-activities' }, + { icon: CalendarIcon, label: '예약 현황', to: '/mypage/reservations-status' }, + ]; + + return ( + + ); +} diff --git a/apps/what-today/src/components/MypageSidebar.tsx b/apps/what-today/src/components/MypageSidebar.tsx index 3dcc0ea1..191220c9 100644 --- a/apps/what-today/src/components/MypageSidebar.tsx +++ b/apps/what-today/src/components/MypageSidebar.tsx @@ -1,12 +1,4 @@ -import { - Button, - CalendarIcon, - ExitIcon, - ListIcon, - ProfileLogo, - SettingIcon, - UserIcon, -} from '@what-today/design-system'; +import { Button, CalendarIcon, ExitIcon, ListIcon, SettingIcon, UserIcon } from '@what-today/design-system'; import { Link, useLocation } from 'react-router-dom'; import { twMerge } from 'tailwind-merge'; @@ -15,7 +7,7 @@ interface MypageSidebarProps { * 사용자 프로필 이미지 URL * 전달되지 않을 경우 기본 아이콘(ProfileLogo)이 표시됩니다. */ - profileImgUrl?: string; + // profileImgUrl?: string; /** * 로그아웃 버튼 클릭 시 실행되는 콜백 함수(아마 모달을 띄우지 않을까 싶습니다.) */ @@ -46,7 +38,7 @@ interface MypageSidebarProps { * onLogoutClick={() => alert('hi')} * /> */ -export default function MypageSidebar({ profileImgUrl, onLogoutClick, onClick, isOpen }: MypageSidebarProps) { +export default function MypageSidebar({ onLogoutClick, onClick, isOpen }: MypageSidebarProps) { const location = useLocation(); /** @@ -66,7 +58,7 @@ export default function MypageSidebar({ profileImgUrl, onLogoutClick, onClick, i // 공통 스타일 'fixed z-50 max-w-200 min-w-200 rounded-2xl border border-gray-50 bg-white transition duration-300 md:static md:h-fit xl:w-280', // 모바일에서 Drawer 위치 - isOpen ? 'h-474 translate-x-0' : 'h-50 -translate-x-full bg-gray-200', + isOpen ? 'translate-x-0' : 'h-50 -translate-x-full bg-gray-200', 'md:translate-x-0', 'md:bg-white', )} @@ -79,7 +71,7 @@ export default function MypageSidebar({ profileImgUrl, onLogoutClick, onClick, i 'md:flex', )} > - {profileImgUrl ? ( + {/* {profileImgUrl ? ( 프로필 이미지 ) : ( - )} + )} */}
    {items.map(({ label, icon: Icon, to }) => { const isSelected = location.pathname === to; diff --git a/apps/what-today/src/layouts/Mypage.tsx b/apps/what-today/src/layouts/Mypage.tsx index 0b4be6ab..8112c68e 100644 --- a/apps/what-today/src/layouts/Mypage.tsx +++ b/apps/what-today/src/layouts/Mypage.tsx @@ -5,14 +5,12 @@ import { twMerge } from 'tailwind-merge'; import MypageSidebar from '@/components/MypageSidebar'; import useAuth from '@/hooks/useAuth'; -import { useWhatTodayStore } from '@/stores'; export default function MyPageLayout() { const location = useLocation(); const navigate = useNavigate(); const [isSidebarOpen, setSidebarOpen] = useState(false); const { logoutUser } = useAuth(); - const { user } = useWhatTodayStore(); const { toast } = useToast(); const handleLogout = () => { @@ -37,7 +35,7 @@ export default function MyPageLayout() { )} setSidebarOpen((prev) => !prev)} onLogoutClick={handleLogout} /> diff --git a/apps/what-today/src/pages/mypage/main/index.tsx b/apps/what-today/src/pages/mypage/main/index.tsx new file mode 100644 index 00000000..009108d5 --- /dev/null +++ b/apps/what-today/src/pages/mypage/main/index.tsx @@ -0,0 +1,157 @@ +import { useQueries, useQuery } from '@tanstack/react-query'; +import { + MypageProfileHeader, + MypageSummaryCard, + OngoingExperienceCard, + UpcomingSchedule, + useToast, +} from '@what-today/design-system'; +import dayjs from 'dayjs'; +import { useNavigate } from 'react-router-dom'; + +import { fetchReservationAvailableSchedule } from '@/apis/activityDetail'; +import { getMonthlySchedule } from '@/apis/myActivities'; +import { fetchMyReservations } from '@/apis/myReservations'; +import { useInfiniteMyActivitiesQuery } from '@/hooks/myActivity/useMyActivitiesQuery'; +import useAuth from '@/hooks/useAuth'; +import type { monthlyScheduleResponse } from '@/schemas/myActivities'; +import type { MyReservationsResponse } from '@/schemas/myReservations'; +import { useWhatTodayStore } from '@/stores'; + +export default function MyPage() { + const navigate = useNavigate(); + const { logoutUser } = useAuth(); + const { user } = useWhatTodayStore(); + const { toast } = useToast(); + + const MAX_PAGE_SIZE = 1000; + const year = dayjs().format('YYYY'); + const month = dayjs().format('MM'); + + // 등록한 체험 갯수 + const { data: activityData } = useInfiniteMyActivitiesQuery(MAX_PAGE_SIZE); + const totalActivity = activityData?.pages[0]?.totalCount; + + // 이번달 예약 승인 대기 갯수 + const activityIds = + activityData?.pages.flatMap((page: { activities: { id: number }[] }) => + page.activities.map((activity) => activity.id), + ) ?? []; + + const monthlyReservationsResults = useQueries({ + queries: activityIds.map((id) => ({ + queryKey: ['monthlySchedule', id, year, month], + queryFn: () => getMonthlySchedule(id, { year, month }), + enabled: !!id, + })), + }); + const monthlyReservations = monthlyReservationsResults + .map((result) => result.data) + .filter(Boolean) as monthlyScheduleResponse[]; + const totalPending = monthlyReservations.flat().reduce((sum, item) => sum + item.reservations.pending || 0, 0); + + // 완료한 체험 갯수 + const { data: completedData } = useQuery({ + queryKey: ['reservations', 'completed'], + queryFn: () => + fetchMyReservations({ + cursorId: null, // 첫 페이지부터 가져옴 + size: MAX_PAGE_SIZE, // 충분히 큰 숫자로 설정 (전체 데이터 한 번에) + status: 'completed', // 완료된 체험만 받아오기 + }), + staleTime: 1000 * 30, + }); + + // 완료한 체험 중 리뷰 미작성 갯수 + const reviewRequired = completedData?.reservations.filter((res) => res.reviewSubmitted === false).length ?? 0; + + // 다가오는 체험 데이터 + const { data: confirmedData } = useQuery({ + queryKey: ['reservations', 'confirmed'], + queryFn: () => + fetchMyReservations({ + cursorId: null, // 첫 페이지부터 가져옴 + size: MAX_PAGE_SIZE, // 충분히 큰 숫자로 설정 (전체 데이터 한 번에) + status: 'confirmed', // 확정된 체험만 받아오기 + }), + staleTime: 1000 * 30, + }); + + // 이번 달 모집 중인 체험 + const reservationAvailableResults = useQueries({ + queries: activityIds.map((id) => ({ + queryKey: ['availableSchedule', id, year, month], + queryFn: () => { + return fetchReservationAvailableSchedule(id, { year, month }); + }, + enabled: !!id, + })), + }); + const availableActivityIds = reservationAvailableResults + .map((result, index) => ({ data: result.data, activityId: activityIds[index] })) + .filter(({ data }) => Array.isArray(data) && data.length > 0) + .map(({ activityId }) => activityId); + // 1. useInfiniteMyActivitiesQuery에서 받은 모든 pages를 펼침 + const allActivities = activityData?.pages.flatMap((page) => page.activities) ?? []; + // 2. 예약 가능 activityId와 일치하는 항목만 필터링 + const availableActivities = allActivities.filter((activity) => availableActivityIds.includes(activity.id)); + + const handleLogout = () => { + logoutUser(); + toast({ + title: '로그아웃 성공', + description: '다음에 또 만나요! 👋🏻', + type: 'success', + }); + navigate('/login'); + }; + return ( +
    + {/* */} +
    + +
    + + + + + + + + +
    +
    +

    다가오는 일정

    + navigate('/')} + onClickReservation={(id) => navigate(`/activities/${id}`)} + /> +
    +
    +

    {`${dayjs().format('M')}월 모집 중인 체험`}

    + navigate('/experiences/create')} + onClickActivity={(id) => navigate(`/activities/${id}`)} + /> +
    +
    +
    + ); +} diff --git a/apps/what-today/src/routes/index.tsx b/apps/what-today/src/routes/index.tsx index 957a5979..4086e06c 100644 --- a/apps/what-today/src/routes/index.tsx +++ b/apps/what-today/src/routes/index.tsx @@ -10,6 +10,7 @@ import KakaoCallbackSignup from '@/pages/kakao-callback-signup'; import LoginPage from '@/pages/login'; import MainPage from '@/pages/main'; import EditProfilePage from '@/pages/mypage/edit-profile'; +import MyPage from '@/pages/mypage/main'; import ManageActivitiesPage from '@/pages/mypage/manage-activities'; import ReservationsListPage from '@/pages/mypage/reservations-list'; import ReservationsStatusPage from '@/pages/mypage/reservations-status'; @@ -49,7 +50,7 @@ export const router = createBrowserRouter([ loader: authGuardLoader, element: , children: [ - { index: true, element: }, + { index: true, element: }, { path: 'edit-profile', element: }, { path: 'reservations-list', element: }, { path: 'manage-activities', element: }, diff --git a/apps/what-today/src/schemas/activities.ts b/apps/what-today/src/schemas/activities.ts index 862fa37f..3672475a 100644 --- a/apps/what-today/src/schemas/activities.ts +++ b/apps/what-today/src/schemas/activities.ts @@ -37,6 +37,28 @@ export const scheduleSchema = z.object({ endTime: z.string().regex(/^\d{2}:\d{2}$/, 'HH:MM 형식이어야 합니다'), }); +/** + * @description 항목 스키마 + */ +export const timeSchema = z.object({ + id: z.number().int().positive(), + startTime: z.string().regex(/^\d{2}:\d{2}$/, 'HH:MM 형식이어야 합니다'), + endTime: z.string().regex(/^\d{2}:\d{2}$/, 'HH:MM 형식이어야 합니다'), +}); + +/** + * @description 스케줄 항목 스키마 + */ +const scheduleResponseSchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD 형식이어야 합니다'), + times: z.array(timeSchema), +}); + +/** + * @description 스케줄 리스트 스키마 + */ +export const schedulesSchema = z.array(scheduleResponseSchema); + /** * @description subImages와 schedules를 포함한 확장 스키마 */ @@ -48,4 +70,5 @@ export const activityWithSubImagesAndSchedulesSchema = activitySchema.extend({ export type Activity = z.infer; export type SubImage = z.infer; export type Schedule = z.infer; +export type Schedules = z.infer; export type ActivityWithSubImagesAndSchedules = z.infer; diff --git a/packages/design-system/src/components/MypageProfileHeader.tsx b/packages/design-system/src/components/MypageProfileHeader.tsx index b1213205..1ca8c2f6 100644 --- a/packages/design-system/src/components/MypageProfileHeader.tsx +++ b/packages/design-system/src/components/MypageProfileHeader.tsx @@ -1,13 +1,38 @@ -export default function MypageProfileHeader() { +import Button from './button'; +import { ExitIcon } from './icons'; +import { ProfileLogo } from './logos'; + +interface MypageProfileHeaderProps { + name?: string; + profileImageUrl?: string; + onLogoutClick: () => void; +} + +export default function MypageProfileHeader({ name, profileImageUrl, onLogoutClick }: MypageProfileHeaderProps) { return ( -
    +
    -
    + {profileImageUrl ? ( + 프로필 이미지 + ) : ( + + )}
    -

    멋쟁이 개발자 님

    -

    로그아웃

    +

    {name}

    +
    ); diff --git a/packages/design-system/src/components/OngoingExperienceCard.tsx b/packages/design-system/src/components/OngoingExperienceCard.tsx index d009a9a7..da9fb87a 100644 --- a/packages/design-system/src/components/OngoingExperienceCard.tsx +++ b/packages/design-system/src/components/OngoingExperienceCard.tsx @@ -1,14 +1,51 @@ -export default function OngoingExperienceCard() { +import { twMerge } from 'tailwind-merge'; + +import Button from './button'; +import { EmptyLogo } from './logos'; + +interface Activity { + id: number; + bannerImageUrl: string; + title: string; + price: number; +} + +interface OngoingExperienceCardProps { + className?: string; + activities: Activity[]; + onClickActivity: (id: number) => void; + onClick: () => void; +} + +export default function OngoingExperienceCard({ + className, + activities, + onClickActivity, + onClick, +}: OngoingExperienceCardProps) { + const flex = activities.length === 0 ? 'justify-center' : ''; return ( -
    - -
    -

    도라에몽ㅇ도라에몽 도라에몽ㅇ도라에몽ㅇ

    -

    w 3,000,000 / 인

    -
    +
    + {activities.length === 0 ? ( +
    + + +
    + ) : ( + activities.map((act) => { + return ( +
    onClickActivity(act.id)}> + +
    +

    {act.title}

    +

    ₩{act.price.toLocaleString()} / 인

    +
    +
    + ); + }) + )}
    ); } diff --git a/packages/design-system/src/components/UpcomingSchedule.tsx b/packages/design-system/src/components/UpcomingSchedule.tsx index 64437b56..27b82d94 100644 --- a/packages/design-system/src/components/UpcomingSchedule.tsx +++ b/packages/design-system/src/components/UpcomingSchedule.tsx @@ -1,69 +1,104 @@ +import { twMerge } from 'tailwind-merge'; + +import Button from './button'; +import { EmptyLogo } from './logos'; + function ScheduleDateLabel({ date }: { date: string }) { return

    {date}

    ; } function ScheduleItem({ title, - date, - location, + price, + headCount, + time, src, }: { title: string; - date: string; - location?: string; + price: number; + headCount: number; + time: string; src?: string; }) { return (

    {title}

    -

    {date}

    -

    {location}

    + +

    {time}

    +
    +

    ₩{price.toLocaleString()}

    +

    {headCount}명

    +
    - + 체험 베너 이미지
    ); } -export default function UpcomingSchedule() { - return ( -
    -
    -
    -
    -
    +interface Reservation { + id: number; + activity: { id: number; title: string; bannerImageUrl: string }; + date: string; + headCount: number; + totalPrice: number; + startTime: string; + endTime: string; +} -
    -
    - - - - -
    -
    - - -
    +interface UpcomingScheduleProps { + className?: string; + reservation: Reservation[]; + onClickReservation: (id: number) => void; + onClick?: () => void; +} +export default function UpcomingSchedule({ + className, + reservation, + onClickReservation, + onClick, +}: UpcomingScheduleProps) { + const flex = reservation.length === 0 ? 'justify-center' : ''; + return ( +
    + {/*
    +
    +
    +
    */} +
    + {reservation.length === 0 ? ( +
    + + +
    + ) : ( + (() => { + let prevDate: string | null = null; + return reservation.map((res, idx) => { + const showDateLabel = res.date !== prevDate; + const isLast = idx === reservation.length - 1; + prevDate = res.date; + return ( +
    onClickReservation(res.activity.id)} + > + {showDateLabel && } + +
    + ); + }); + })() + )}
    ); diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index 14eb3611..dfa7bbb0 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -16,8 +16,11 @@ export { default as MainBanner } from './MainBanner/MainBanner'; export * from './MainCard'; export { default as MainSearchInput } from './MainSearchInput/MainSearchInput'; export * from './modal'; +export { default as MypageProfileHeader } from './MypageProfileHeader'; +export * from './MypageSummaryCard'; export { default as NoResult } from './NoResult'; export { default as NotificationCard } from './NotificationCard'; +export { default as OngoingExperienceCard } from './OngoingExperienceCard'; export { default as OwnerBadge } from './OwnerBadge'; export { default as Pagination } from './Pagination'; export * from './popover'; @@ -29,4 +32,5 @@ export * from './select'; export { default as StarRating } from './StarRating'; export { default as TimePicker } from './TimePicker'; export { Toaster, useToast } from './Toast'; +export { default as UpcomingSchedule } from './UpcomingSchedule'; export { default as UserBadge } from './UserBadge'; diff --git a/packages/design-system/src/pages/MypageProfileHeaderDoc.tsx b/packages/design-system/src/pages/MypageProfileHeaderDoc.tsx index 101098e0..a33baaa2 100644 --- a/packages/design-system/src/pages/MypageProfileHeaderDoc.tsx +++ b/packages/design-system/src/pages/MypageProfileHeaderDoc.tsx @@ -25,7 +25,7 @@ export default function MypageProfileHeaderDoc() { {/* 예시 코드 */} Click me`} /> - + {}} /> ); } diff --git a/packages/design-system/src/pages/OngoingExperienceCardDoc.tsx b/packages/design-system/src/pages/OngoingExperienceCardDoc.tsx index af9eb939..d4415456 100644 --- a/packages/design-system/src/pages/OngoingExperienceCardDoc.tsx +++ b/packages/design-system/src/pages/OngoingExperienceCardDoc.tsx @@ -26,9 +26,9 @@ export default function OngoingExperienceCardDoc() { Click me`} />
    - - - + {}} onClickActivity={() => {}} /> + {}} onClickActivity={() => {}} /> + {}} onClickActivity={() => {}} />
    ); diff --git a/packages/design-system/src/pages/UpcomingScheduleDoc.tsx b/packages/design-system/src/pages/UpcomingScheduleDoc.tsx index 7e42a5a1..b205cf0b 100644 --- a/packages/design-system/src/pages/UpcomingScheduleDoc.tsx +++ b/packages/design-system/src/pages/UpcomingScheduleDoc.tsx @@ -26,7 +26,7 @@ export default function UpcomingScheduleDoc() { {/* 예시 코드 */} Click me`} /> - + {}} /> ); }