-
Notifications
You must be signed in to change notification settings - Fork 1
마이페이지 메인 UI 리팩토링 #248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
마이페이지 메인 UI 리팩토링 #248
Changes from 5 commits
d0428a5
778a78b
e402bde
89178e8
6886959
c0a08b4
39d3052
d940766
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
|
|
@@ -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('/')}> | ||
| 체험 예약하러 가기 | ||
| </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' | ||
|
||
| variant='outline' | ||
| onClick={() => navigate('/experiences/create')} | ||
| > | ||
| 체험 등록하러 가기 | ||
| </Button> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default function MyPage() { | ||
| const navigate = useNavigate(); | ||
|
|
||
|
|
@@ -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; | ||
|
|
||
| // 이번달 예약 승인 대기 갯수 | ||
|
|
@@ -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({ | ||
|
|
@@ -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) => ({ | ||
|
|
@@ -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(); | ||
|
|
@@ -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')}월 승인 대기`} /> | ||
|
|
@@ -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> | ||
|
|
||
| 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)}> | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| <img className='h-full w-full rounded-t-2xl rounded-b-3xl object-cover' src={bannerImageUrl} /> | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| <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> | ||
|
||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
텍스트 정한걸로 변경해주세요!