Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 1 addition & 3 deletions apps/what-today/src/layouts/Mypage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -37,7 +35,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 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<MyReservationsResponse>({
queryKey: ['reservations', 'completed'],
queryFn: () =>
fetchMyReservations({
cursorId: null, // 첫 페이지부터 가져옴
size: MAX_PAGE_SIZE, // 충분히 큰 숫자로 설정 (전체 데이터 한 번에)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

페이지네이션이 걸려있는 목록에서 전체 데이터를 한 번에 받아올 때는,
요청 데이터의 size를 1로 하여 반환받은 totalPages를 사용하는 방법도 있습니다!

혹시 cursorId만 제공되는 엔드포인트였을까요..?!
그게 아니라면 API 요청이 한 번 더 가지만 size=1로 먼저 요청하고 totalPages로 재요청하는 것이,
나중에 데이터가 많아졌을때 누락없이 모든 데이터를 받아올 수 있을 것 같습니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안그래도 저렇게 1000을 넣는 것이 맞나 의문이 들긴 했어요...
cursorId, size를 보낼 수 있는데 그럼 수정 가능한 걸까요?? 가능하다면 말씀해주신 방향으로 수정해보겠습니다!

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: 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 (
<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>
);
}
3 changes: 2 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 @@ -49,7 +50,7 @@ export const router = createBrowserRouter([
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