Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
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
26 changes: 25 additions & 1 deletion apps/what-today/src/apis/activityDetail.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Schedules> => {
const response = await axiosInstance.get(`/activities/${activityId}/available-schedule`, { params });
return schedulesSchema.parse(response.data);
};
2 changes: 1 addition & 1 deletion apps/what-today/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function Header() {

<div className='mx-12 h-16 w-px bg-gray-100' />

<Link className='flex items-center gap-8 hover:opacity-60' to='/mypage/edit-profile'>
<Link className='flex items-center gap-8 hover:opacity-60' to='/mypage'>
{user?.profileImageUrl ? (
<img
alt='프로필 이미지'
Expand Down
97 changes: 97 additions & 0 deletions apps/what-today/src/components/MypageMainSideBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { CalendarIcon, ListIcon, SettingIcon, UserIcon } from '@what-today/design-system';
import { Link, useLocation } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';

// interface MypageSidebarProps {
// /**
// * 사용자 프로필 이미지 URL
// * 전달되지 않을 경우 기본 아이콘(ProfileLogo)이 표시됩니다.
// */
// profileImgUrl?: string;
// /**
// * 로그아웃 버튼 클릭 시 실행되는 콜백 함수(아마 모달을 띄우지 않을까 싶습니다.)
// */
// onLogoutClick: () => void;
// /**
// * 클릭했을 때 sidebar 열림 여부 변경
// */
// onClick: () => void;
// /**
// * MypageSidebar의 열림 여부
// */
// isOpen: boolean;
// }

/**
* MypageSidebar 컴포넌트
*
* 사용자의 프로필 이미지와 마이페이지 관련 메뉴를 표시합니다.
* 현재 URL 경로에 따라 해당 메뉴 항목을 하이라이트 처리합니다.
* 로그아웃 버튼을 포함하며, 클릭 시 지정된 콜백을 실행합니다.
*
* @component
* @example
* <MypageSidebar
* profileImgUrl="https://example.com/avatar.jpg"
* isOpen={isSidebarOpen}
* onClick={() => 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 (
<nav
className={twMerge(
// 공통 스타일
'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',
// 'md:translate-x-0',
// 'md:bg-white',
)}
>
{/* 콘텐츠: PC는 항상, 모바일은 isOpen일 때만 */}
<div
className={twMerge(
'flex h-full flex-col items-center gap-24 px-14 py-12',
// isOpen ? 'flex' : 'hidden',
// 'md:flex',
)}
>
<ul className='flex w-full flex-col justify-center gap-4'>
{items.map(({ label, icon: Icon, to }) => {
const isSelected = location.pathname === to;
const itemClass = twMerge(
'flex w-full cursor-pointer items-center gap-8 rounded-2xl px-20 py-14',
isSelected ? 'bg-primary-100 text-gray-950' : 'text-gray-600 hover:bg-gray-25',
);
const iconColor = isSelected ? '#3d9ef2' : '#707177';
return (
<li key={label}>
<Link className={itemClass} to={to}>
<div className='flex size-24 items-center justify-center'>
<Icon color={`${iconColor}`} />
</div>
<div className='body-text font-medium'>{label}</div>
</Link>
</li>
);
})}
</ul>
</div>
</nav>
);
}
20 changes: 6 additions & 14 deletions apps/what-today/src/components/MypageSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -15,7 +7,7 @@ interface MypageSidebarProps {
* 사용자 프로필 이미지 URL
* 전달되지 않을 경우 기본 아이콘(ProfileLogo)이 표시됩니다.
*/
profileImgUrl?: string;
// profileImgUrl?: string;
/**
* 로그아웃 버튼 클릭 시 실행되는 콜백 함수(아마 모달을 띄우지 않을까 싶습니다.)
*/
Expand Down Expand Up @@ -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();

/**
Expand All @@ -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',
)}
Expand All @@ -79,15 +71,15 @@ export default function MypageSidebar({ profileImgUrl, onLogoutClick, onClick, i
'md:flex',
)}
>
{profileImgUrl ? (
{/* {profileImgUrl ? (
<img
alt='프로필 이미지'
className='bg-white-100 size-120 rounded-full border border-gray-50 object-cover'
src={profileImgUrl}
/>
) : (
<ProfileLogo className='rounded-full' size={120} />
)}
)} */}
<ul className='flex w-full flex-col justify-center gap-4'>
{items.map(({ label, icon: Icon, to }) => {
const isSelected = location.pathname === to;
Expand Down
2 changes: 1 addition & 1 deletion apps/what-today/src/layouts/Mypage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default function MyPageLayout() {
)}
<MypageSidebar
isOpen={isSidebarOpen}
profileImgUrl={user?.profileImageUrl ?? ''}
// profileImgUrl={user?.profileImageUrl ?? ''}
onClick={() => setSidebarOpen((prev) => !prev)}
onLogoutClick={handleLogout}
/>
Expand Down
157 changes: 157 additions & 0 deletions apps/what-today/src/pages/mypage/main/index.tsx
Original file line number Diff line number Diff line change
@@ -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 year = dayjs().format('YYYY');
const month = dayjs().format('MM');

// 등록한 체험 갯수
const { data: activityData } = useInfiniteMyActivitiesQuery(1000);
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);

// 완료한 체험 갯수
const { data: completedData } = useQuery<MyReservationsResponse>({
queryKey: ['reservations', 'completed'],
queryFn: () =>
fetchMyReservations({
cursorId: null, // 첫 페이지부터 가져옴
size: 1000, // 충분히 큰 숫자로 설정 (전체 데이터 한 번에)
status: 'completed', // 완료된 체험만 받아오기
}),
staleTime: 1000 * 30,
});

// 완료한 체험 중 리뷰 미작성 갯수
const reviewRequired = completedData?.reservations.filter((res) => res.reviewSubmitted === false).length ?? 0;

// 다가오는 체험 데이터
const { data: confirmedData } = useQuery<MyReservationsResponse>({
queryKey: ['reservations', 'confirmed'],
queryFn: () =>
fetchMyReservations({
cursorId: null, // 첫 페이지부터 가져옴
size: 1000, // 충분히 큰 숫자로 설정 (전체 데이터 한 번에)
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 (
<div className='flex gap-30'>
{/* <MypageMainSidebar /> */}
<div className='flex w-full flex-col gap-24'>
<MypageProfileHeader
name={user?.nickname}
profileImageUrl={user?.profileImageUrl ?? undefined}
onLogoutClick={handleLogout}
/>
<div className='flex gap-24'>
<MypageSummaryCard.Root>
<MypageSummaryCard.Item count={totalActivity || 0} label='등록한 체험' />
<MypageSummaryCard.Item count={totalPending} label={`${dayjs().format('M')}월 승인 대기`} />
</MypageSummaryCard.Root>
<MypageSummaryCard.Root className='bg-[#4D6071]'>
<MypageSummaryCard.Item
count={completedData?.totalCount || 0}
countClassName='text-white'
label='완료한 체험'
labelClassName='text-gray-200'
/>
<MypageSummaryCard.Item
count={reviewRequired}
countClassName='text-white'
label='리뷰 대기'
labelClassName='text-gray-200'
/>
</MypageSummaryCard.Root>
</div>
<div className='flex max-h-540 min-h-300 flex-col gap-16 rounded-3xl border border-gray-50 px-32 pt-24'>
<p className='body-text font-bold'>다가오는 일정</p>
<UpcomingSchedule
className='w-full overflow-y-scroll'
reservation={confirmedData?.reservations || []}
onClick={() => navigate('/')}
onClickReservation={(id) => navigate(`/activities/${id}`)}
/>
</div>
<div className='flex h-300 w-full flex-col gap-16 overflow-hidden rounded-3xl border border-gray-50 px-40 py-24'>
<p className='body-text font-bold'>{`${dayjs().format('M')}월 모집 중인 체험`}</p>
<OngoingExperienceCard
activities={availableActivities}
onClick={() => navigate('/experiences/create')}
onClickActivity={(id) => navigate(`/activities/${id}`)}
/>
</div>
</div>
</div>
);
}
4 changes: 3 additions & 1 deletion apps/what-today/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -39,12 +40,13 @@ export const router = createBrowserRouter([
{ path: 'activities/:id', element: <ActivityDetailPage /> },
{ path: 'experiences/create', element: <CreateExperience /> },
{ path: 'experiences/create/:id', element: <CreateExperience /> },
// { path: 'mypage', element: <MyPage /> }, // ✅ 여기만 별도로(다시 변경)
{
path: 'mypage',
loader: authGuardLoader,
element: <MyPageLayout />,
children: [
{ index: true, element: <EditProfilePage /> },
{ index: true, element: <MyPage /> },
{ path: 'edit-profile', element: <EditProfilePage /> },
{ path: 'reservations-list', element: <ReservationsListPage /> },
{ path: 'manage-activities', element: <ManageActivitiesPage /> },
Expand Down
Loading