From a99cb3fc7caa4c48fcff51ba645efb00651d2a64 Mon Sep 17 00:00:00 2001 From: nxnaxx Date: Thu, 5 Dec 2024 11:55:23 +0900 Subject: [PATCH] feat: implement bus rental details layout --- src/components/inputField/InputField.tsx | 1 - src/components/subHeader/SubHeader.tsx | 1 + src/constants/FilterTypes.ts | 28 +++ src/hooks/useRentalDetails.ts | 35 +++ src/mocks/handlers.ts | 46 +++- src/pages/busRentalDetail/BusRentalDetail.tsx | 199 ++++++++++++++++-- src/pages/busRentalDetail/components/.gitkeep | 0 .../busRentalDetail/components/BusTime.tsx | 51 +++++ .../components/DepositAccount.tsx | 67 ++++++ .../components/DrivingInfo.tsx | 78 +++++++ .../components/ParticipantsStatus.tsx | 81 +++++++ src/styles/GlobalStyle.tsx | 2 +- src/types/api.ts | 6 + src/types/busRental.ts | 59 ++++++ src/types/index.ts | 2 + src/utils/dateUtils.ts | 19 ++ 16 files changed, 654 insertions(+), 21 deletions(-) create mode 100644 src/constants/FilterTypes.ts create mode 100644 src/hooks/useRentalDetails.ts delete mode 100644 src/pages/busRentalDetail/components/.gitkeep create mode 100644 src/pages/busRentalDetail/components/BusTime.tsx create mode 100644 src/pages/busRentalDetail/components/DepositAccount.tsx create mode 100644 src/pages/busRentalDetail/components/DrivingInfo.tsx create mode 100644 src/pages/busRentalDetail/components/ParticipantsStatus.tsx create mode 100644 src/types/api.ts create mode 100644 src/types/busRental.ts create mode 100644 src/utils/dateUtils.ts diff --git a/src/components/inputField/InputField.tsx b/src/components/inputField/InputField.tsx index 4f97d73..023279f 100644 --- a/src/components/inputField/InputField.tsx +++ b/src/components/inputField/InputField.tsx @@ -30,7 +30,6 @@ const InputFieldWrapper = styled.div<{ isError: boolean }>` align-items: center; gap: 0.8rem; width: 100%; - min-width: 24rem; height: 4rem; margin-bottom: 0.8rem; padding: 0 1.6rem; diff --git a/src/components/subHeader/SubHeader.tsx b/src/components/subHeader/SubHeader.tsx index 1ca7b0d..d723949 100644 --- a/src/components/subHeader/SubHeader.tsx +++ b/src/components/subHeader/SubHeader.tsx @@ -26,6 +26,7 @@ const SubHeaderContainer = styled.div<{ isTransparent: boolean }>` grid-template-columns: 24px 1fr 24px; align-items: center; position: fixed; + z-index: 900; max-width: ${({ theme }) => theme.maxWidth}; width: 100%; height: 5.2rem; diff --git a/src/constants/FilterTypes.ts b/src/constants/FilterTypes.ts new file mode 100644 index 0000000..9564de5 --- /dev/null +++ b/src/constants/FilterTypes.ts @@ -0,0 +1,28 @@ +export const REGIONS = [ + '서울', + '경기', + '인천', + '강원', + '세종', + '천안', + '청주', + '대전', + '대구', + '경북', + '부산', + '울산', + '마산', + '창원', + '경남', + '광주', + '전북', + '전주', + '전남', +] as const; + +export const CONCERT_SORT = ['최근 공연순', '인기순'] as const; +export const DATE_SORT = ['최신순', '오래된순', '마감순'] as const; + +export type Region = (typeof REGIONS)[number]; +export type ConcertSort = (typeof CONCERT_SORT)[number]; +export type DateSort = (typeof DATE_SORT)[number]; diff --git a/src/hooks/useRentalDetails.ts b/src/hooks/useRentalDetails.ts new file mode 100644 index 0000000..75336b5 --- /dev/null +++ b/src/hooks/useRentalDetails.ts @@ -0,0 +1,35 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +import type { AllRentalDetail, RentalAccountResponse, RentalDetailResponse } from 'types'; + +// 추후 api 로직 분리 +const fetchRentalDetails = (id: string) => { + return axios.get(`/api/v1/rents/${id}`); +}; + +const fetchDepositAccount = (id: string) => { + return axios.get(`/api/v1/rents/${id}/deposit-account`); +}; + +export const useRentalDetails = (id: string) => { + // 로그인 여부 (추후 수정) + const isLoggedIn = true; + + const fetchDetails = async (): Promise => { + const detailPromise = await fetchRentalDetails(id); + const accountPromise = isLoggedIn ? fetchDepositAccount(id) : Promise.resolve(null); + + const [detailResponse, accountResponse] = await Promise.all([detailPromise, accountPromise]); + const rentalDetails = detailResponse.data.result; + const depositAccount = accountResponse?.data.result.depositAccount ?? null; + + return { ...rentalDetails, depositAccount }; + }; + + return useQuery({ + queryKey: ['rentalDetail', id], + queryFn: fetchDetails, + enabled: !!id, + }); +}; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 351b258..d56e49f 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,3 +1,47 @@ import { http, HttpResponse } from 'msw'; -export const handlers = []; +export const handlers = [ + http.get('/api/v1/rents/1', () => { + return HttpResponse.json({ + timeStamp: '2024-12-03T00:13:56.217Z', + code: '200', + message: 'Success', + result: { + concertName: 'DAY6 3RD WORLD TOUR, FOREVER YOUNG [인천]', + imageUrl: 'https://img1.newsis.com/2024/03/18/NISI20240318_0001504432_web.jpg', + title: '데이식스(DAY6) FOREVER YOUNG 콘서트 청주 차대절 🎸', + artistName: 'DAY6', + region: '청주', + boardingArea: '스타벅스 청주터미널점', + dropOffArea: '인스파이어리조트 (아레나)', + upTime: '09:00', + downTime: '23:00', + rentBoardingDates: ['2024-09-20', '2024-09-21', '2024-09-22'], + busSize: 'LARGE', + busType: 'DELUXE', + maxPassenger: 28, + roundPrice: 45000, + upTimePrice: 45000, + downTimePrice: 45000, + recruitmentCount: 25, + participants: [25, 12, 18], + endDate: '2024-12-26', + chatUrl: 'https://open.kakao.com/o/abcDeF', + refundType: 'ADDITIONAL_DEPOSIT', + information: `❗입금 후 폼 작성 부탁드립니다.❗\n\n 왕복, 편도 가격 동일합니다. + 양도나 분할 탑승 신청자분들은 직접 짝 구해주시고 신청해주시길 바랍니다. (입금은 한분께서 일괄 입금 부탁드리며 오픈카톡을 통해 확인 내용 알려주시길 바랍니다.)\n + 📌 개인이 진행하는 차대절이기 때문에 인원 미달, 13번 이후 입금자를 제외한 환불은 절대 불가하오며 이 부분을 숙지하지 못한 사항에 대해서 생기는 불이익은 책임지지 않습니다.이점 유의하시고 신청 바랍니다.`, + }, + }); + }), + http.get('/api/v1/rents/1/deposit-account', () => { + return HttpResponse.json({ + timeStamp: '2024-12-03T02:38:22.994Z', + code: '200', + message: 'success', + result: { + depositAccount: '우리은행 1242264211943 김데식', + }, + }); + }), +]; diff --git a/src/pages/busRentalDetail/BusRentalDetail.tsx b/src/pages/busRentalDetail/BusRentalDetail.tsx index 182f682..83c929b 100644 --- a/src/pages/busRentalDetail/BusRentalDetail.tsx +++ b/src/pages/busRentalDetail/BusRentalDetail.tsx @@ -1,23 +1,186 @@ +import styled from '@emotion/styled'; +import { every } from 'lodash-es'; +import { useParams } from 'react-router-dom'; + +import BusTime from './components/BusTime'; +import DepositAccount from './components/DepositAccount'; +import DrivingInfo from './components/DrivingInfo'; +import ParticipantsStatus from './components/ParticipantsStatus'; + +import Badge from 'components/badge/Badge'; +import BaseButton from 'components/buttons/BaseButton'; +import SimpleChip from 'components/chips/SimpleChip'; +import { useRentalDetails } from 'hooks/useRentalDetails'; +import { BodyRegularText, TitleText1, TitleText2 } from 'styles/Typography'; +import { formatDateWithDay, getDday } from 'utils/dateUtils'; + +const DetailContainer = styled.div` + position: relative; +`; + +const ThumbnailContainer = styled.div` + position: relative; + width: 100%; + height: 0; + padding-top: 100%; + overflow: hidden; +`; + +const ThumbnailImg = styled.img` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +`; + +const ContentContainer = styled.div` + padding: 2.4rem; +`; + +const Title = styled(TitleText1)` + margin: 1.6rem 0 0.8rem 0; +`; + +const ConcertName = styled(BodyRegularText)` + margin-bottom: 1.6rem; + color: ${({ theme }) => theme.colors.dark[200]}; +`; + +const ChipWrapper = styled.div` + display: flex; + gap: 0.8rem; + margin-bottom: 1.6rem; +`; + +const SectionWrapper = styled.div` + &:not(:last-of-type) { + margin-bottom: 3.2rem; + } +`; + +const SectionTitle = styled(TitleText2)` + margin-bottom: 1.6rem; + color: ${({ theme }) => theme.colors.dark[50]}; +`; + +const Information = styled(BodyRegularText)` + color: ${({ theme }) => theme.colors.dark[200]}; + white-space: pre-line; +`; + +const BottomButtonWrapper = styled.div` + position: sticky; + bottom: 0; + left: 0; + width: 100%; + padding: 2.4rem; + background-color: ${({ theme }) => theme.colors.black}; +`; + +const InfoSection = ({ title, children }: { title: string; children: React.ReactNode }) => ( + + {title} + {children} + +); + const BusRentalDetail = () => { + const { id } = useParams(); + const { data: details, error, isLoading } = useRentalDetails(id as string); + + if (isLoading) return
로딩중
; + if (error) return
Error 발생: {error.message}
; + if (!details) return
세부 정보가 존재하지 않습니다.
; + + const { + imageUrl, + title, + concertName, + region, + artistName, + endDate, + rentBoardingDates, + recruitmentCount, + participants, + boardingArea, + dropOffArea, + busSize, + busType, + maxPassenger, + roundPrice, + upTimePrice, + downTimePrice, + depositAccount, + upTime, + downTime, + information, + } = details; + + const dDay = getDday(endDate); + const rentDates = rentBoardingDates.map((date) => formatDateWithDay(date)); + const busPrices = [roundPrice, upTimePrice, downTimePrice]; + return ( - <> -
BusRentalDetail
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- + + + + + + + 3 ? 'gray' : 'red'} size="medium" variant="square"> + {dDay === 0 ? `D-Day` : `D-${dDay}`} + + {title} + {concertName} + + {region} + {artistName} + + + + + + + + + + + + + + {information} + + + + participant === recruitmentCount)} + onClick={() => {}} + size="medium" + variant="fill" + > + 폼 작성하기 + + + ); }; diff --git a/src/pages/busRentalDetail/components/.gitkeep b/src/pages/busRentalDetail/components/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/pages/busRentalDetail/components/BusTime.tsx b/src/pages/busRentalDetail/components/BusTime.tsx new file mode 100644 index 0000000..92261ec --- /dev/null +++ b/src/pages/busRentalDetail/components/BusTime.tsx @@ -0,0 +1,51 @@ +import styled from '@emotion/styled'; + +import { BodyRegularText } from 'styles/Typography'; + +interface BusTimeProps { + upTime: string; + downTime: string; + boardingArea: string; + dropOffArea: string; +} + +const TimeContainer = styled.div``; + +const TimeItem = styled.div` + display: flex; + + &:not(:last-of-type) { + margin-bottom: 1.6rem; + } +`; + +const Label = styled(BodyRegularText)` + margin-right: 1.6rem; + color: ${({ theme }) => theme.colors.dark[200]}; +`; + +const Time = styled(BodyRegularText)` + margin-right: 0.8rem; + color: ${({ theme }) => theme.colors.dark[200]}; +`; + +const BusTime = ({ upTime, downTime, boardingArea, dropOffArea }: BusTimeProps) => { + const timeData = [ + { label: '상행', time: upTime, area: boardingArea }, + { label: '하행', time: downTime, area: dropOffArea }, + ]; + + return ( + + {timeData.map(({ label, time, area }) => ( + + + + {area} + + ))} + + ); +}; + +export default BusTime; diff --git a/src/pages/busRentalDetail/components/DepositAccount.tsx b/src/pages/busRentalDetail/components/DepositAccount.tsx new file mode 100644 index 0000000..419eaee --- /dev/null +++ b/src/pages/busRentalDetail/components/DepositAccount.tsx @@ -0,0 +1,67 @@ +import styled from '@emotion/styled'; +import { TbCopy } from 'react-icons/tb'; + +import { BodyRegularText } from 'styles/Typography'; + +interface DepositAccountProps { + depositAccount: string | null; +} + +const AccountWrapper = styled.div` + display: flex; + align-items: center; + gap: 0.8rem; + position: relative; + padding: 1.2rem 1.6rem; + border-radius: 8px; + background-color: ${({ theme }) => theme.colors.dark[700]}; + color: ${({ theme }) => theme.colors.dark[200]}; +`; + +const AccountCopyButton = styled.button` + display: flex; + justify-content: center; + align-items: center; + color: ${({ theme }) => theme.colors.dark[200]}; + + &:hover, + &:active { + color: ${({ theme }) => theme.colors.primary}; + } +`; + +const Blur = styled.div` + display: flex; + justify-content: center; + align-items: center; + position: absolute; + inset: 0; + width: 100%; + border-radius: 8px; + backdrop-filter: blur(6px); + color: ${({ theme }) => theme.backdrop}; +`; + +const DepositAccount = ({ depositAccount }: DepositAccountProps) => { + return ( + + {depositAccount ? ( + <> + {depositAccount} + {}}> + + + + ) : ( + <> + + 로그인 후 확인하실 수 있습니다. + + 올리은행 012345678910112 + + )} + + ); +}; + +export default DepositAccount; diff --git a/src/pages/busRentalDetail/components/DrivingInfo.tsx b/src/pages/busRentalDetail/components/DrivingInfo.tsx new file mode 100644 index 0000000..9a3dde9 --- /dev/null +++ b/src/pages/busRentalDetail/components/DrivingInfo.tsx @@ -0,0 +1,78 @@ +import styled from '@emotion/styled'; +import { join, sortBy, uniq } from 'lodash-es'; + +import { BodyRegularText } from 'styles/Typography'; +import type { BusSize, BusType } from 'types'; +import { BUS_SIZE, BUS_TYPE } from 'types'; + +interface DrivingInfoProps { + rentDates: string[]; + boardingArea: string; + maxPassenger: number; + busSize: BusSize; + busType: BusType; + busPrices: number[]; +} + +const DrivingInfoContainer = styled.div` + display: flex; + flex-direction: column; + gap: 1.2rem; + margin-bottom: 0.8rem; + padding: 1.6rem; + border-radius: 8px; + background-color: ${({ theme }) => theme.colors.dark[700]}; +`; + +const DrivingInfoItem = styled.div` + display: flex; + align-items: center; + gap: 1.2rem; +`; + +const DrivingInfoLabel = styled(BodyRegularText)` + color: ${({ theme }) => theme.colors.dark[200]}; +`; + +const DrivingInfo = ({ + rentDates, + boardingArea, + maxPassenger, + busSize, + busType, + busPrices, +}: DrivingInfoProps) => { + const sortedPrices = sortBy(uniq(busPrices)); + + return ( + + + 운행 기간 + + {rentDates.length > 0 + ? `${rentDates[0]} - ${rentDates[rentDates.length - 1]}` + : rentDates[0]} + + + + 탑승 장소 + {boardingArea} + + + 차량 정보 + {`${maxPassenger}인승 ${BUS_SIZE[busSize]} ${BUS_TYPE[busType]}버스`} + + + 이용 요금 + + {join( + sortedPrices.map((price) => `${new Intl.NumberFormat().format(price)}원`), + ' | ' + )} + + + + ); +}; + +export default DrivingInfo; diff --git a/src/pages/busRentalDetail/components/ParticipantsStatus.tsx b/src/pages/busRentalDetail/components/ParticipantsStatus.tsx new file mode 100644 index 0000000..b1325bf --- /dev/null +++ b/src/pages/busRentalDetail/components/ParticipantsStatus.tsx @@ -0,0 +1,81 @@ +import styled from '@emotion/styled'; +import { FaUserGroup } from 'react-icons/fa6'; + +import Badge from 'components/badge/Badge'; +import { CaptionText } from 'styles/Typography'; + +interface ParticipantsStatusProps { + rentDates: string[]; + participants: number[]; + recruitmentCount: number; +} + +const StatusContainer = styled.div``; + +const StatusWrapper = styled.div` + margin-bottom: 0.8rem; + padding: 1.6rem; + border-radius: 8px; + background-color: ${({ theme }) => theme.colors.dark[700]}; +`; + +const StatusItem = styled.div` + display: flex; + align-items: center; + gap: 1.2rem; + + &:not(:last-of-type) { + margin-bottom: 1.2rem; + } +`; + +const RentDate = styled(CaptionText)<{ isClosed: boolean }>` + color: ${({ isClosed, theme }) => (isClosed ? theme.colors.dark[300] : theme.colors.dark[100])}; + text-decoration: ${({ isClosed }) => (isClosed ? 'line-through' : 'none')}; +`; + +const ParticipantNums = styled(CaptionText)<{ isClosed: boolean }>` + margin-left: auto; + color: ${({ isClosed, theme }) => (isClosed ? theme.colors.red : theme.colors.dark[100])}; +`; + +const StatusDescription = styled(CaptionText)` + display: flex; + justify-content: end; + align-items: center; + gap: 0.8rem; + color: ${({ theme }) => theme.colors.dark[200]}; +`; + +const ParticipantsStatus = ({ + rentDates, + participants, + recruitmentCount, +}: ParticipantsStatusProps) => { + return ( + + + {rentDates.map((date, idx) => { + const isClosed = participants[idx] === recruitmentCount; + return ( + + + {isClosed ? '마감완료' : '신청가능'} + + {date} + + {participants[idx]} / {recruitmentCount} + + + ); + })} + + + + 지금 참여 중인 인원 + + + ); +}; + +export default ParticipantsStatus; diff --git a/src/styles/GlobalStyle.tsx b/src/styles/GlobalStyle.tsx index c723397..d163d6d 100644 --- a/src/styles/GlobalStyle.tsx +++ b/src/styles/GlobalStyle.tsx @@ -27,7 +27,7 @@ const globalStyle = (theme: Theme) => css` align-items: center; background-color: #f9f9f9; font-size: 62.5%; - color: ${theme.colors.white}; + color: ${theme.colors.dark[100]}; } button { diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..dde7293 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,6 @@ +export interface ApiResponse { + timeStamp: string; + status: number; + message: string; + result: T; +} diff --git a/src/types/busRental.ts b/src/types/busRental.ts new file mode 100644 index 0000000..66f6345 --- /dev/null +++ b/src/types/busRental.ts @@ -0,0 +1,59 @@ +import type { ApiResponse } from './api'; + +import type { Region } from 'constants/FilterTypes'; + +export const BUS_SIZE = { + LARGE: '대형', + MEDIUM: '중형', + MINI: '미니', +} as const; + +export const BUS_TYPE = { + STANDARD: '일반', + DELUXE: '우등', + PREMIUM: '프리미엄', +} as const; + +export const REFUND_TYPE = { + ADDITIONAL_DEPOSIT: '추가 입금', + REFUND: '환불', + BOTH: '둘 다', +} as const; + +export type BusSize = keyof typeof BUS_SIZE; +export type BusType = keyof typeof BUS_TYPE; +export type RefundType = keyof typeof REFUND_TYPE; + +export interface RentalDetail { + concertName: string; + imageUrl: string; + title: string; + artistName: string; + region: Region; + boardingArea: string; + dropOffArea: string; + upTime: string; + downTime: string; + rentBoardingDates: string[]; + busSize: BusSize; + busType: BusType; + maxPassenger: number; + roundPrice: number; + upTimePrice: number; + downTimePrice: number; + recruitmentCount: number; + participants: number[]; + endDate: string; + chatUrl: string; + refundType: RefundType; + information: string; +} + +export interface RentalAccount { + depositAccount: string | null; +} + +export interface AllRentalDetail extends RentalDetail, RentalAccount {} + +export type RentalDetailResponse = ApiResponse; +export type RentalAccountResponse = ApiResponse; diff --git a/src/types/index.ts b/src/types/index.ts index 133aa74..2692c02 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1 +1,3 @@ export * from './modal'; +export * from './api'; +export * from './busRental'; diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts new file mode 100644 index 0000000..ce951da --- /dev/null +++ b/src/utils/dateUtils.ts @@ -0,0 +1,19 @@ +import dayjs from 'dayjs'; +import 'dayjs/locale/ko'; + +dayjs.locale('ko'); + +// D-Day 계산 +export const getDday = (endDate: string) => { + const deadline = dayjs(endDate, 'YYYY-MM-DD', true); + if (!deadline.isValid()) return 0; + + return deadline.diff(dayjs(), 'day'); +}; + +// ISO Date -> YY.MM.DD(요일) 형식으로 변환 +export const formatDateWithDay = (dateString: string) => { + const date = dayjs(dateString); + const dayOfWeek = date.format('dd'); + return `${date.format('YY.MM.DD')}(${dayOfWeek})`; +};