Skip to content
168 changes: 148 additions & 20 deletions apps/what-today/src/pages/mypage/main/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { useQueries, useQuery } from '@tanstack/react-query';
import {
Button,
EmptyLogo,
MypageProfileHeader,
MypageSummaryCard,
OngoingExperienceCard,
OngoingExperienceCardSkeleton,
UpcomingSchedule,
UpcomingScheduleSkeleton,
useToast,
} from '@what-today/design-system';
import dayjs from 'dayjs';
Expand All @@ -18,6 +22,36 @@ import type { monthlyScheduleResponse } from '@/schemas/myActivities';
import type { MyReservationsResponse } from '@/schemas/myReservations';
import { useWhatTodayStore } from '@/stores';

function NoResultUpcoming() {
const navigate = useNavigate();

return (
<div className='flex w-full flex-col items-center justify-center gap-20 pt-32'>
<EmptyLogo size={80} />
<Button className='text-md w-auto font-semibold' variant='outline' onClick={() => navigate('/')}>
Copy link
Member

Choose a reason for hiding this comment

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

텍스트 정한걸로 변경해주세요!

체험 예약하러 가기
</Button>
</div>
);
}

function NoResultOngoing() {
const navigate = useNavigate();

return (
<div className='flex w-full flex-col items-center justify-center gap-20 pt-32'>
<EmptyLogo size={80} />
<Button
className='text-md w-auto font-semibold'
Copy link
Member

Choose a reason for hiding this comment

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

여기도..!

variant='outline'
onClick={() => navigate('/experiences/create')}
>
체험 등록하러 가기
</Button>
</div>
);
}

export default function MyPage() {
const navigate = useNavigate();

Expand All @@ -30,7 +64,7 @@ export default function MyPage() {
const month = dayjs().format('MM');

// 등록한 체험 갯수
const { data: activityData } = useInfiniteMyActivitiesQuery(MAX_PAGE_SIZE);
const { data: activityData, isLoading: isLoadingActivities } = useInfiniteMyActivitiesQuery(MAX_PAGE_SIZE);
const totalActivity = activityData?.pages[0]?.totalCount;

// 이번달 예약 승인 대기 갯수
Expand Down Expand Up @@ -68,7 +102,7 @@ export default function MyPage() {
const reviewRequired = completedData?.reservations.filter((res) => res.reviewSubmitted === false).length ?? 0;

// 다가오는 체험 데이터
const { data: confirmedData } = useQuery<MyReservationsResponse>({
const { data: confirmedData, isLoading: isLoadingConfirmed } = useQuery<MyReservationsResponse>({
queryKey: ['reservations', 'confirmed'],
queryFn: () =>
fetchMyReservations({
Expand All @@ -80,6 +114,54 @@ export default function MyPage() {
enabled: Boolean(user),
});

const sortedReservations = [...(confirmedData?.reservations ?? [])].sort((a, b) =>
dayjs(a.date).isAfter(b.date) ? 1 : -1,
);

let upcomingScheduleContent = null;

if (isLoadingConfirmed) {
// 1. 로딩 중: 스켈레톤 3개
upcomingScheduleContent = (
<>
<UpcomingScheduleSkeleton />
<UpcomingScheduleSkeleton />
<UpcomingScheduleSkeleton />
</>
);
} else if (sortedReservations.length > 0) {
// 2. 데이터 있음: 날짜별 그룹으로 렌더링
upcomingScheduleContent = (() => {
let prevDate: string | null = null;

return sortedReservations.map((res, idx, arr) => {
const showDateLabel = res.date !== prevDate;
const isLast = idx === arr.length - 1;
prevDate = res.date;

return (
<div
key={res.id}
className={`flex flex-col gap-8 ${isLast ? 'pb-32' : ''}`}
onClick={() => navigate(`/activities/${res.activity.id}`)}
>
{showDateLabel && <p className='caption-text text-gray-400'>{res.date}</p>}
<UpcomingSchedule
headCount={res.headCount}
price={res.totalPrice}
src={res.activity.bannerImageUrl}
time={`${res.startTime}~${res.endTime}`}
title={res.activity.title}
/>
</div>
);
});
})();
} else {
// 3. 데이터 없음
upcomingScheduleContent = <NoResultUpcoming />;
}

// 이번 달 모집 중인 체험
const reservationAvailableResults = useQueries({
queries: activityIds.map((id) => ({
Expand All @@ -90,14 +172,52 @@ export default function MyPage() {
enabled: !!id,
})),
});

// useQueries 로딩 상태
const isLoadingAvailableQueries =
reservationAvailableResults.length === 0 || // 아직 activityIds 준비 전
reservationAvailableResults.some((q) => q.isLoading || q.isFetching);

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));
// 3. 최종 스켈레톤 노출 여부
const isLoadingAvailable = isLoadingActivities || isLoadingAvailableQueries;
// 4. 모집 중인 체험에 띄울 콘텐츠 결정 (스켈레톤 UI or 데이터 없음 or 실제 데이터)
let ongoingExperienceContent = null;
if (isLoadingAvailable) {
ongoingExperienceContent = (
<>
<OngoingExperienceCardSkeleton />
<OngoingExperienceCardSkeleton />
<OngoingExperienceCardSkeleton />
<OngoingExperienceCardSkeleton />
</>
);
} else if (availableActivities.length > 0) {
ongoingExperienceContent = (
<>
{availableActivities.map((act) => (
<OngoingExperienceCard
key={act.id}
bannerImageUrl={act.bannerImageUrl}
id={act.id}
price={act.price}
title={act.title}
onClickActivity={(id) => navigate(`/activities/${id}`)}
/>
))}
</>
);
} else {
ongoingExperienceContent = <NoResultOngoing />;
}

const handleLogout = () => {
logoutUser();
Expand All @@ -110,15 +230,14 @@ export default function MyPage() {
};
return (
<div className='flex gap-30'>
{/* <MypageMainSidebar /> */}
<div className='flex w-full flex-col gap-24'>
<div className='flex w-full flex-col gap-36'>
<MypageProfileHeader
email={user?.email}
name={user?.nickname}
profileImageUrl={user?.profileImageUrl ?? undefined}
onLogoutClick={handleLogout}
/>
<div className='flex flex-col gap-24 md:flex-row'>
<div className='flex flex-col gap-12 md:flex-row md:gap-24'>
<MypageSummaryCard.Root>
<MypageSummaryCard.Item count={totalActivity || 0} label='등록한 체험' />
<MypageSummaryCard.Item count={totalPending} label={`${dayjs().format('M')}월 승인 대기`} />
Expand All @@ -138,22 +257,31 @@ export default function MyPage() {
/>
</MypageSummaryCard.Root>
</div>
<div className='flex h-248 w-full flex-col gap-8 rounded-3xl border-gray-50 md:h-300 md:gap-16 md:border md:px-40 md:py-24'>
<p className='text-lg font-bold'>{`${dayjs().format('M')}월 모집 중인 체험`}</p>
<OngoingExperienceCard
activities={availableActivities}
onClick={() => navigate('/experiences/create')}
onClickActivity={(id) => navigate(`/activities/${id}`)}
/>
<div className='relative flex h-fit w-full flex-col gap-8 rounded-3xl border-gray-50 bg-white pr-0 md:gap-16 md:border md:px-40 md:py-24'>
<p className='section-text font-bold'>{`${dayjs().format('M')}월 모집 중인 체험`}</p>
<div className='grid h-210 w-full grid-cols-1'>
<div className='flex gap-12 overflow-x-auto'>
{/* flex로 한 줄로 나열해두고 overflow-x-auto를 부모 너비가 같이 늘어났음 */}
{ongoingExperienceContent}
</div>
</div>
</div>
<div className='flex min-h-300 flex-col gap-8 rounded-3xl border-gray-50 md:max-h-540 md:gap-16 md:border md:px-32 md:pt-24'>
<p className='text-lg font-bold'>다가오는 일정</p>
<UpcomingSchedule
className='w-full md:overflow-y-auto'
reservation={confirmedData?.reservations || []}
onClick={() => navigate('/')}
onClickReservation={(id) => navigate(`/activities/${id}`)}
/>

<div className='grid min-h-300 grid-rows-[auto_1fr] gap-8 rounded-3xl border-gray-50 bg-white md:max-h-540 md:gap-16 md:border md:px-32 md:pt-24'>
<p className='section-text font-bold'>다가오는 일정</p>

<div className='ml-12 w-full overflow-y-auto'>
<div className='flex w-full items-stretch gap-12'>
{/* 왼쪽 타임라인 */}
{sortedReservations.length > 0 && (
<div className='flex flex-col items-center'>
<div className='bg-primary-500 size-12 shrink-0 rounded-full' />
<div className='from-primary-500 w-3 flex-1 bg-gradient-to-b to-transparent' />
</div>
)}
<div className='mt-12 flex w-full flex-col gap-24'>{upcomingScheduleContent}</div>
</div>
</div>
</div>
</div>
</div>
Expand Down
18 changes: 9 additions & 9 deletions packages/design-system/src/components/MypageProfileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,31 @@ interface MypageProfileHeaderProps {

export default function MypageProfileHeader({ name, email, profileImageUrl, onLogoutClick }: MypageProfileHeaderProps) {
return (
<div className='flex items-center gap-24 rounded-3xl border border-gray-50 bg-white px-36 py-24'>
<div className='flex size-100 items-center justify-center rounded-full border border-gray-50 bg-white'>
<div className='flex items-center gap-36 rounded-3xl border border-gray-50 bg-white px-36 py-36'>
<div className='flex size-120 items-center justify-center rounded-full border border-gray-50 bg-white'>
{profileImageUrl ? (
<img
alt='프로필 이미지'
className='bg-white-100 size-90 rounded-full border border-gray-50 object-cover'
className='size-110 rounded-full border border-gray-50 bg-white object-cover'
src={profileImageUrl}
/>
) : (
<ProfileLogo className='rounded-full' size={90} />
<ProfileLogo className='rounded-full' size={110} />
)}
</div>

<div className='flex h-80 flex-col justify-center gap-8 text-gray-950'>
<div className='flex h-80 flex-col justify-center gap-16 text-gray-950'>
<div className='flex flex-col'>
<p className='text-2xl font-bold'>{name}</p>
<p className='text-lg text-gray-400'>{email}</p>
<p className='title-text font-bold'>{name}</p>
<p className='body-text text-gray-400'>{email}</p>
</div>
<Button
className='h-auto w-auto justify-start p-0 text-gray-400'
className='caption-text h-auto w-auto justify-start p-0 text-gray-400'
size='lg'
variant='none'
onClick={onLogoutClick}
>
<ExitIcon className='size-16' /> 로그아웃
<ExitIcon className='size-12' /> 로그아웃
</Button>
</div>
</div>
Expand Down
8 changes: 4 additions & 4 deletions packages/design-system/src/components/MypageSummaryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ function SummaryRoot({ children, className }: { children: React.ReactNode; class
return (
<div
className={twMerge(
'flex w-full items-center justify-around rounded-3xl border border-gray-50 bg-white px-36 py-12',
'flex w-full items-center justify-around rounded-3xl border border-gray-50 bg-white px-36 py-16',
className,
)}
>
Expand All @@ -23,9 +23,9 @@ function SummaryRoot({ children, className }: { children: React.ReactNode; class

function SummaryItem({ count, label, onClick, countClassName, labelClassName }: SummaryItemProps) {
return (
<div className='flex cursor-pointer flex-col items-center gap-12' onClick={onClick}>
<p className={twMerge('text-2xl font-bold', countClassName)}>{count}</p>
<p className={twMerge('text-gray-500', labelClassName)}>{label}</p>
<div className='flex cursor-pointer flex-col items-center gap-4' onClick={onClick}>
<p className={twMerge('subtitle-text font-bold', countClassName)}>{count}</p>
<p className={twMerge('caption-text text-gray-400', labelClassName)}>{label}</p>
</div>
);
}
Expand Down
45 changes: 11 additions & 34 deletions packages/design-system/src/components/OngoingExperienceCard.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,28 @@
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[];
interface OngoingExperienceCardProps extends Activity {
onClickActivity: (id: number) => void;
onClick: () => void;
}

export default function OngoingExperienceCard({
className,
activities,
id,
bannerImageUrl,
title,
price,
onClickActivity,
onClick,
}: OngoingExperienceCardProps) {
const flex = activities.length === 0 ? 'justify-center' : '';
return (
<div className={twMerge('flex gap-12', flex, className)}>
{activities.length === 0 ? (
<div className='flex flex-col items-center justify-center gap-20 pt-32'>
<EmptyLogo size={80} />
<Button className='text-md w-auto font-semibold' variant='outline' onClick={onClick}>
체험 등록하러 가기
</Button>
</div>
) : (
activities.map((act) => {
return (
<div key={act.id} className='relative h-170 w-150 cursor-pointer' onClick={() => onClickActivity(act.id)}>
<img className='h-full w-full rounded-t-2xl rounded-b-3xl object-cover' src={act.bannerImageUrl} />
<div className='absolute bottom-0 w-full translate-y-[40px] cursor-pointer rounded-2xl border border-gray-50 bg-white px-12 py-12'>
<p className='text-md line-clamp-1 font-semibold'>{act.title}</p>
<p className='text-md text-gray-500'>{act.price.toLocaleString()} / 인</p>
</div>
</div>
);
})
)}
<div key={id} className='relative h-170 w-150 shrink-0 cursor-pointer' onClick={() => onClickActivity(id)}>
<img className='h-full w-full rounded-t-2xl rounded-b-3xl object-cover' src={bannerImageUrl} />
<div className='absolute bottom-0 w-full translate-y-[40px] cursor-pointer rounded-2xl border border-gray-50 bg-white px-12 py-12'>
<p className='caption-text line-clamp-2 font-semibold'>{title}</p>
<p className='caption-text text-gray-500'>{price.toLocaleString()} / 인</p>
Copy link
Member

Choose a reason for hiding this comment

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

요기 gray-400으로 통일하는걸까요?!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

엇 500으로 되어있던건, 컨벤션에 맞게 gray-400으로 적용했고, 금액까지 회색으로 처리한건 의도한게 맞습니다!

</div>
</div>
);
}
Loading