diff --git a/components/common/button/index.tsx b/components/common/button/index.tsx index 1b26158..74ce154 100644 --- a/components/common/button/index.tsx +++ b/components/common/button/index.tsx @@ -54,8 +54,7 @@ const ButtonText = styled(Text)<{ sort: ButtonSort }>` const { colors } = theme; return css` - font-weight: 700; - line-height: 24px; + ${theme.typoToken.subhead02} letter-spacing: 0.5px; color: ${sort === "primary" ? colors.white : colors.primary_01}; `; diff --git a/components/common/modal/index.ts b/components/common/modal/index.ts index 7c4b24b..4377c00 100644 --- a/components/common/modal/index.ts +++ b/components/common/modal/index.ts @@ -1,2 +1,4 @@ export * from "./modal.default"; export * from "./modal.portal"; +export * from "./modal.confirm"; +export * from "./modal.reservation"; diff --git a/components/common/modal/modal.confirm.tsx b/components/common/modal/modal.confirm.tsx new file mode 100644 index 0000000..c60a37d --- /dev/null +++ b/components/common/modal/modal.confirm.tsx @@ -0,0 +1,102 @@ +import { useEffect, type ReactNode } from "react"; +import Image from "next/image"; +import styled, { css } from "styled-components"; +import Button from "@/components/common/button"; +import { SpacerSkleton } from "@/components/common/spacer"; +import Typography from "@/components/common/text/Typography"; + +interface ConfirmModalProps { + isShow: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + children: ReactNode; +} + +export function ConfirmModal({ + isShow, + onClose, + onConfirm, + title, + children, +}: ConfirmModalProps) { + useEffect(() => { + if (isShow) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + + return () => { + document.body.style.overflow = ""; + }; + }, [isShow]); + + if (!isShow) { + return null; + } + + return ( + + e.stopPropagation()}> + + + + {title} + {children} + 확인 + + + ); +} + +const ModalTitle = styled(Typography)` + margin-top: 0.5rem; + margin-bottom: 1.4rem; +`; + +const Backdrop = styled.div` + ${({ theme }) => { + const { colors } = theme; + + return css` + z-index: 9999; + position: fixed; + top: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + background-color: ${colors.secondary["black-50"]}; + `; + }} +`; + +const ConfirmButton = styled(Button)` + margin-top: 2rem; +`; + +const Content = styled.div` + ${({ theme }) => { + const { colors } = theme; + + return css` + display: flex; + flex-direction: column; + padding: 1.5rem 2rem; + border-radius: 12px; + background-color: ${colors.secondary.white}; + box-shadow: 0 5px 15px ${colors.secondary["black-30"]}; + text-align: center; + `; + }} +`; diff --git a/components/domains/detail/RowInfo.tsx b/components/domains/detail/RowInfo.tsx index ead557f..ca669bc 100644 --- a/components/domains/detail/RowInfo.tsx +++ b/components/domains/detail/RowInfo.tsx @@ -1,3 +1,4 @@ +import dayjs from "dayjs"; import styled from "styled-components"; import Typography from "@/components/common/text/Typography"; @@ -12,6 +13,8 @@ export interface RowInfoData { } export default function RowInfo({ infoData }: { infoData: RowInfoData }) { + console.log(dayjs(infoData?.startDate).format("YYYY-MM-DD")); + return ( @@ -24,7 +27,9 @@ export default function RowInfo({ infoData }: { infoData: RowInfoData }) { {infoData.place} - {`${infoData.startDate}~${infoData.endDate}`} + {`${dayjs(infoData?.startDate).format( + "YYYY-MM-DD" + )}~${dayjs(infoData?.endDate).format("YYYY-MM-DD")}`} {`만 ${infoData.ageLimit}세 이상`} {`총 ${infoData.runningTime}분`} {`${infoData.interMission}분`} diff --git a/components/domains/detail/calendar/DaySchedules.tsx b/components/domains/detail/calendar/DaySchedules.tsx index 44a08a2..5724c8e 100644 --- a/components/domains/detail/calendar/DaySchedules.tsx +++ b/components/domains/detail/calendar/DaySchedules.tsx @@ -1,5 +1,6 @@ import type { Dispatch, SetStateAction } from "react"; import format from "date-fns/format"; +import dayjs from "dayjs"; import styled from "styled-components"; import Typography from "@/components/common/text/Typography"; import type { Schedule } from "./type"; @@ -7,28 +8,37 @@ import type { Schedule } from "./type"; type DaySchedulesProps = { schedules: Schedule[]; onClickSchedule: Dispatch>; + selectedDate: Date | null; clickedId: number | null; }; export default function DaySchedules({ schedules, + selectedDate, onClickSchedule, clickedId, }: DaySchedulesProps) { return ( - {schedules.map((schedule, index) => { - return ( - - - {`${index + 1}회 | ${format( - new Date(schedule.startAt), - "HH:mm" - )}`} - - - ); - })} + {schedules + .filter((schedule) => { + return ( + dayjs(schedule.startAt).startOf("day").valueOf() === + selectedDate?.valueOf() + ); + }) + .map((schedule, index) => { + return ( + + + {`${index + 1}회 | ${format( + new Date(schedule.startAt), + "HH:mm" + )}`} + + + ); + })} ); } diff --git a/components/domains/detail/calendar/ReservationCalendar.tsx b/components/domains/detail/calendar/ReservationCalendar.tsx index 6b339ab..3620031 100644 --- a/components/domains/detail/calendar/ReservationCalendar.tsx +++ b/components/domains/detail/calendar/ReservationCalendar.tsx @@ -1,18 +1,30 @@ import { useState } from "react"; -import styled, { css } from "styled-components"; +import styled from "styled-components"; import Button from "@/components/common/button"; import { CustomCalendar } from "@/components/common/calendar"; +import { ConfirmModal } from "@/components/common/modal"; import Typography from "@/components/common/text/Typography"; import CustomHeader from "@/components/domains/detail/calendar/CustomHeader"; import DaySchedules from "@/components/domains/detail/calendar/DaySchedules"; import ReservedSeat from "@/components/domains/detail/calendar/ResearvedSeat"; -import type { ReservationCalendarProps } from "@/components/domains/detail/calendar/type"; +import type { + ReservationCalendarProps, + Schedule, +} from "@/components/domains/detail/calendar/type"; +import { ReservationModal } from "@/components/domains/detail/reservation/ReservationModal"; +import ReservationNotice from "@/components/domains/detail/reservation/ReservationNotice"; + +type ReserveModalCase = "NOTICE" | "COMPLETION" | "RESERVATION" | null; + +const convertScheduleData = (schedules: Schedule[]) => { + return schedules.map((schedule) => { + return new Date(schedule.startAt); + }); +}; export default function ReservationCalendar({ - schedules, + schedules = [], }: ReservationCalendarProps) { - // TODO: Link Calendar Function - const [date, setDate] = useState(null); const [clickedScheduleId, setClickedScheduleId] = useState( null @@ -20,16 +32,23 @@ export default function ReservationCalendar({ const [availableSeatCount, setAvailableSeatCount] = useState( null ); + const [modalStatus, setModalStatus] = + useState("RESERVATION"); const onChangeDate = (date: Date | null) => { setDate(date); }; + const closeModal = () => { + setModalStatus(null); + }; + return (
날짜/시간 선택
( )} @@ -37,13 +56,46 @@ export default function ReservationCalendar({ onChangeDate={onChangeDate} />
- 예매하기 + { + setModalStatus("NOTICE"); + }} + > + 예매하기 + + { + setModalStatus("RESERVATION"); + }} + > + + + + 티켓 구매가 완료되었습니다. +
구매하신 티켓은 MY PAGE에서 확인 가능합니다. +
+ { + setModalStatus("COMPLETION"); + }} + onClose={closeModal} + />
); } diff --git a/components/domains/detail/reservation/ReservationModal.tsx b/components/domains/detail/reservation/ReservationModal.tsx new file mode 100644 index 0000000..18a5f47 --- /dev/null +++ b/components/domains/detail/reservation/ReservationModal.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from "react"; +import Image from "next/image"; +import styled, { css } from "styled-components"; +import { SpacerSkleton } from "@/components/common/spacer"; +import ReservationSummaryPanel from "@/components/domains/detail/reservation/ReservationSummaryPanel"; +import TicketQuantitySelect from "@/components/domains/detail/reservation/TicketQuantitySelect"; +import ReservationStep from "./ReservationStep"; + +interface Props { + isShow: boolean; + onClose: () => void; + onReserve: () => void; +} + +export function ReservationModal({ isShow, onClose, onReserve }: Props) { + const [ticketCount, setTicketCount] = useState(1); + + useEffect(() => { + if (isShow) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + + return () => { + document.body.style.overflow = ""; + }; + }, [isShow]); + + if (!isShow) { + return null; + } + + return ( + + e.stopPropagation()}> + + + + + + + + + + + + + ); +} + +const Wrapper = styled.div` + width: 45rem; +`; +const ContentSection = styled.div` + display: flex; +`; + +const TopSection = styled(SpacerSkleton)` + padding: 8px 16px; + border-bottom: 1px solid #e8e8e8; +`; + +const Backdrop = styled.div` + ${({ theme }) => { + const { colors } = theme; + + return css` + z-index: 9999; + position: fixed; + top: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + background-color: ${colors.secondary["black-50"]}; + `; + }} +`; + +const Content = styled.div` + ${({ theme }) => { + const { colors } = theme; + + return css` + border-radius: 12px; + background-color: ${colors.secondary.white}; + box-shadow: 0 5px 15px ${colors.secondary["black-30"]}; + `; + }} +`; diff --git a/components/domains/detail/reservation/ReservationNotice.tsx b/components/domains/detail/reservation/ReservationNotice.tsx new file mode 100644 index 0000000..59f30fb --- /dev/null +++ b/components/domains/detail/reservation/ReservationNotice.tsx @@ -0,0 +1,21 @@ +import styled from 'styled-components'; + +export default function ReservationNotice() { + return 예매자는 본 안내페이지의 모든 내용을 숙지 및 동의한 것으로 간주합니다.{" "} +
+
- 관람 연령/티켓수령/공연관람 안내 미숙지로 인한 책임은 관람자 + 본인에게 있으며, +
관련 사유로 예매 티켓의 취소/변경/환불은 일체 불가하오니 각별히 + 유의하시기 바랍니다. +
+
- 모든 할인은 실 관람자 기준으로 적용되며, 관람회차의 티켓 수령 + 시 반드시 할인 대상 본인이
매표소에 방문하여 증빙자료를 제시해 주셔야 + 합니다. +
+
*Play NEWniverse는 작품 유통 외의 작품 제작과 관련에는 책임이 + 없음을 알립니다.
+} + +const Notice = styled.p` + text-align: left; +` diff --git a/components/domains/detail/reservation/ReservationStep.tsx b/components/domains/detail/reservation/ReservationStep.tsx new file mode 100644 index 0000000..dc01c71 --- /dev/null +++ b/components/domains/detail/reservation/ReservationStep.tsx @@ -0,0 +1,73 @@ +import Image from "next/image"; +import styled, { css } from "styled-components"; +import Typography from "@/components/common/text/Typography"; + +export type ReservationStepProps = { + step: "PRICE_SELECT" | "SEAT_SELECT"; + purchaseLimitCount?: number; + isAlert?: boolean; +}; + +export default function ReservationStep({ + step, + purchaseLimitCount = 2, + isAlert, +}: ReservationStepProps) { + return ( + + + 좌석 선택 + + 가격 선택 + + + {`1인당 ${purchaseLimitCount}매까지 구매 가능합니다.`} + + + ); +} + +const Container = styled.div` + padding: 12px 24px; + border-bottom: 1px solid ${({ theme }) => theme.colors.secondary[150]}; +`; + +const Wrapper = styled.div` + display: flex; + align-items: center; +`; + +const ArrowIcon = styled(Image)` + margin: 0 12px; +`; + +const AlertMessage = styled(Typography)<{ isAlert?: boolean }>` + ${({ theme }) => { + const { colors, isAlert, typoToken } = theme; + + return css` + color: ${isAlert ? colors.system.red : colors.secondary[500]}; + ${typoToken.subhead04} + `; + }} +`; + +const Step = styled(Typography)<{ isActive: boolean }>` + ${({ theme, isActive }) => { + const { colors, typoToken } = theme; + + return css` + ${typoToken.subhead02} + color: ${isActive ? colors.secondary[400] : colors.secondary.black} + `; + }} +`; diff --git a/components/domains/detail/reservation/ReservationSummaryPanel.tsx b/components/domains/detail/reservation/ReservationSummaryPanel.tsx new file mode 100644 index 0000000..0af9f5d --- /dev/null +++ b/components/domains/detail/reservation/ReservationSummaryPanel.tsx @@ -0,0 +1,106 @@ +import styled from "styled-components"; +import Button from "@/components/common/button"; +import Typography from "@/components/common/text/Typography"; + +type ReservationSummaryPanelProps = { + ticketPrice: number; + discount: number; + reservationFee: number; + ticketCount: number; + onClickReserveButton: () => void; +}; + +export default function ReservationSummaryPanel({ + ticketPrice = 0, + discount = 0, + reservationFee = 0, + ticketCount = 0, + onClickReserveButton, +}: ReservationSummaryPanelProps) { + return ( + + + + 결제 금액 + + + + 기본가 + {ticketPrice * ticketCount}원 + + + 가격 할인 + {discount}원 + + + 예매수수료 + {reservationFee}원 + + + 총금액 + + {ticketPrice * ticketCount - discount - reservationFee}원 + + + + 결제 + + ); +} + +const Container = styled.div` + width: 240px; + height: 560px; + border-left: 1px solid ${({ theme }) => theme.colors.secondary[150]}; + padding: 8px 16px 20px 16px; + display: flex; + flex-direction: column; +`; + +const LogoIcon = styled.div` + height: 50px; + width: 90px; + object-fit: contain; + margin: 8px auto 16px auto; + background-repeat: no-repeat; + background-image: url("/icon/plavnewniverse_black_logo.svg"); +`; + +const DataWrapper = styled.div` + padding: 0 12px; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +`; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + background-color: ${({ theme }) => theme.colors.secondary[100]}; + border-radius: 4px; + padding: 16px 0; +`; + +const TotalCount = styled.div` + padding: 16px 12px 0 12px; + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid ${({ theme }) => theme.colors.secondary[150]}; +`; + +const ReservationButton = styled(Button)` + margin-top: auto; + width: 100%; +`; diff --git a/components/domains/detail/reservation/TicketQuantitySelect.tsx b/components/domains/detail/reservation/TicketQuantitySelect.tsx new file mode 100644 index 0000000..f4c6a50 --- /dev/null +++ b/components/domains/detail/reservation/TicketQuantitySelect.tsx @@ -0,0 +1,71 @@ +import { useMemo } from "react"; +import styled from "styled-components"; +import { Field } from "@/components/common/field"; +import Typography from "@/components/common/text/Typography"; + +type TicketQuantitySelectProps = { + purchaseLimitCount?: number; + ticketPrice?: number; + ticketCount: number; + setTicketCount: (count: number) => void; +}; + +export default function TicketQuantitySelect({ + purchaseLimitCount = 2, + ticketPrice = 0, + ticketCount = 0, + setTicketCount, +}: TicketQuantitySelectProps) { + const selectOptions = useMemo(() => { + return Array.from({ length: purchaseLimitCount }, (_, index) => ({ + label: `${index + 1}매`, + value: index + 1, + })); + }, [purchaseLimitCount]); + + return ( + + + 티켓 선택 + + {ticketCount} + 매 선택 + + + + 기본가 + + {`${ticketPrice}원`} + + { + setTicketCount(Number(count)); + }} + placeholder={`${ticketCount}`} + /> + + + + + ); +} + +const Container = styled.div``; + +const Wrapper = styled.div` + padding: 16px 24px; + border-bottom: 1px solid ${({ theme }) => theme.colors.secondary[150]}; + display: flex; + justify-content: space-between; +`; + +const SelectedTicketCount = styled.div` + display: flex; + align-items: center; +`;