diff --git a/src/app/interesting-stock/layout.tsx b/src/app/interesting-stock/layout.tsx new file mode 100644 index 0000000..1cd4532 --- /dev/null +++ b/src/app/interesting-stock/layout.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Header } from "@/components/common/Header"; +import colors from "@/styles/colors"; +import React, { FC } from "react"; +import styled from "styled-components"; + +interface LayoutProps { + children: React.ReactNode; +} + +const InterestingStockLayout: FC = ({ children }) => { + return ( + +
+
{children}
+ + ); +}; + +export default InterestingStockLayout; + +const MainWrapper = styled.div` + background-color: ${colors.WHITE}; + height: 100vh; +`; diff --git a/src/app/interesting-stock/page.tsx b/src/app/interesting-stock/page.tsx new file mode 100644 index 0000000..d998936 --- /dev/null +++ b/src/app/interesting-stock/page.tsx @@ -0,0 +1,66 @@ +"use client"; + +import TapMenu from "@/components/common/TapMenu"; +import { getFonts } from "@/styles/fonts"; +import HeartIcon from "@/svg/HeartIcon"; +import { FC, useState } from "react"; +import styled from "styled-components"; + +type status = "ALL" | "READY" | "IN_PROGRESS" | "DONE"; + +type Subscription = "disable" | "able" | "limit"; + +interface DummyInterestingStockType { + id: string; + title: string; + love: boolean; + category: string; + account: string; + minPrice: number; + maxPrice: number; + subscription?: Subscription; + subscriptionDueDate: string; + accountDueDate: string; +} + +const MyPage: FC = () => { + const [menuState, setMenuState] = useState("ALL"); + + const dummyInterestingStocks: DummyInterestingStockType[] = []; + + return ( + <> + + {/* TODO : 데이터 있으면 UpComingStock 카드 재활용 */} + {dummyInterestingStocks.length === 0 ? ( + + +

아직 관심 공모주가 없어요.

+
+ ) : null} + + ); +}; + +export default MyPage; + +const EmptyInterestingStockList = styled.div` + display: flex; + flex-direction: column; + align-items: center; + padding-top: 120px; + + h3 { + padding-top: 8px; + ${getFonts("H3_SEMIBOLD")} + } +`; diff --git a/src/components/common/Modal.tsx b/src/components/common/Modal.tsx new file mode 100644 index 0000000..25296ff --- /dev/null +++ b/src/components/common/Modal.tsx @@ -0,0 +1,101 @@ +import colors from "@/styles/colors"; +import { getFonts } from "@/styles/fonts"; +import { FC } from "react"; +import styled from "styled-components"; +import { initalModalData, ModalData } from "../mypage/MenuSection"; +import { Overlay } from "./Overlay"; + +interface ModalProps { + /** + * 모달 박스 제목 + */ + title: string; + /** + * 모달 박스 내용 + */ + content?: string; + /** + * 모달 버튼 내용 + */ + buttonText: [string, string] | string; + /** + * Primary 버튼 클릭 핸들러 + */ + handlePrimaryButtonClick: () => void; + /** + * 모달 on/off 핸들러 + */ + setIsModalShowing: (v: ModalData) => void; +} + +const Modal: FC = (props) => { + const { title, content, buttonText, handlePrimaryButtonClick, setIsModalShowing } = props; + return ( + setIsModalShowing(initalModalData)}> + e.stopPropagation()}> +

{title}

+ {content ?

{content}

: null} +
+ {typeof buttonText === "string" ? ( + + {buttonText} + + ) : ( + buttonText.map((text, idx) => ( + setIsModalShowing(initalModalData)} + key={text} + > + {text} + + )) + )} +
+
+
+ ); +}; + +export default Modal; + +const ModalBox = styled.div` + position: absolute; + top: 50%; + left: 50%; + translate: -50% -50%; + width: 295px; + padding: 24px 16px 16px 16px; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + background-color: white; + + h3 { + ${getFonts("H3_SEMIBOLD")} + } + + p { + ${getFonts("BODY1_REGULAR")} + padding-top: 11px; + white-space: pre-line; + } + + div { + display: flex; + gap: 8px; + padding-top: 24px; + width: 100%; + } +`; + +const ModalButton = styled.button<{ primary: boolean }>` + flex-grow: 1; + padding: 16px 20px 16px 20px; + border-radius: 6px; + background-color: ${(props) => (props.primary ? colors.ON.PRIMARY : colors.BLUE[1])}; + color: ${(props) => (props.primary ? colors.WHITE : colors.ON.PRIMARY)}; + ${getFonts("BUTTON1_SEMIBOLD")} +`; diff --git a/src/components/common/Overlay.tsx b/src/components/common/Overlay.tsx new file mode 100644 index 0000000..14b4fe1 --- /dev/null +++ b/src/components/common/Overlay.tsx @@ -0,0 +1,28 @@ +import colors from "@/styles/colors"; +import { FC } from "react"; +import styled from "styled-components"; + +interface OverlayProps { + /** + * 오버레이 클릭 핸들러 + */ + onClick: () => void; + /** + * 오버레이 내부 컨텐츠 + */ + children: React.ReactNode; +} + +export const Overlay: FC = (props) => { + const { onClick, children } = props; + return {children}; +}; + +const OverlayBox = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: ${colors.BLACK_TRANSPARENT_SCALE[5]}; +`; diff --git a/src/components/common/TapMenu.tsx b/src/components/common/TapMenu.tsx index 6a090dc..67c75a7 100644 --- a/src/components/common/TapMenu.tsx +++ b/src/components/common/TapMenu.tsx @@ -16,7 +16,7 @@ interface TapMenuProps { /** * 버튼목록에 들어갈 값 배열 */ - options: [TapMenuOption, TapMenuOption]; + options: TapMenuOption[]; /** * 값 변경 콜백 */ @@ -26,12 +26,13 @@ interface TapMenuProps { const TapMenu: FC = (props) => { const { value, onChange, options } = props; return ( - + {options.map((option) => { return ( { onChange(option.value); }} @@ -46,18 +47,18 @@ const TapMenu: FC = (props) => { export default TapMenu; -const TabBox = styled.div` - padding-inline: 16px; +const TabBox = styled.div<{ count: number }>` + padding-inline: ${(props) => (props.count === 2 ? "16px" : "auto")}; border-bottom: 1px solid ${colors.GRAY[2]}; `; -const TabButton = styled.button<{ $active: string }>` +const TabButton = styled.button<{ $active: string; count: number }>` cursor: pointer; background-color: transparent; border: none; - width: 50%; padding-block: 15px; ${(props) => ` + width: ${`${100 / props.count}%`}; ${props.$active === "true" ? getFonts("H5_SEMIBOLD") : getFonts("H5_REGULAR")} color: ${props.$active === "true" ? colors.FONT_LIGHT.PRIMARY : colors.FONT_LIGHT.SECONDARY}; border-bottom: solid 4px ${props.$active === "true" ? colors.ON.BASIC_LIGHT : colors.ON.BASIC_DARK}; diff --git a/src/components/common/bottomSheet/BottomSheet.tsx b/src/components/common/bottomSheet/BottomSheet.tsx new file mode 100644 index 0000000..bd7f7b0 --- /dev/null +++ b/src/components/common/bottomSheet/BottomSheet.tsx @@ -0,0 +1,40 @@ +import { FC } from "react"; +import { Overlay } from "../Overlay"; +import styled from "styled-components"; +import colors from "@/styles/colors"; + +interface BottomSheetProps { + /** + * 오버레이 클릭 핸들러 + */ + handleOverlayClick: () => void; + /** + * 바텀시트 컨텐츠 + */ + children: React.ReactNode; +} + +export const BottomSheet: FC = (props) => { + const { handleOverlayClick, children } = props; + return ( + + e.stopPropagation()}>{children} + + ); +}; + +const BottomSheetBox = styled.div` + box-sizing: border-box; + position: fixed; + bottom: 0; + left: 50%; + translate: -50%; + background-color: ${colors.WHITE}; + border-radius: 16px 16px 0 0; + padding: 20px 16px 20px 16px; + width: 375px; + + @media (max-width: 375px) { + width: 100vw; + } +`; diff --git a/src/components/common/bottomSheet/BottomSheetGuide.tsx b/src/components/common/bottomSheet/BottomSheetGuide.tsx index 61547f5..bd07537 100644 --- a/src/components/common/bottomSheet/BottomSheetGuide.tsx +++ b/src/components/common/bottomSheet/BottomSheetGuide.tsx @@ -14,17 +14,21 @@ interface BottomSheetGuideProps { * 가이드 컨텐츠(p, span으로 구성) */ children: ReactNode; + /** + * 닫기 버튼 클릭 핸들러 + */ + handleClose: () => void; } export const BottomSheetGuide: FC = (props) => { - const { title, children } = props; + const { title, children, handleClose } = props; return ( <>

{title}

{children} - diff --git a/src/components/detail/IPOInfo.tsx b/src/components/detail/IPOInfo.tsx index 86bd53f..901e3bf 100644 --- a/src/components/detail/IPOInfo.tsx +++ b/src/components/detail/IPOInfo.tsx @@ -4,9 +4,64 @@ import colors from "@/styles/colors"; import { getFonts } from "@/styles/fonts"; import CaretIcon from "@/svg/CaretIcon"; import GuideIcon from "@/svg/GuideIcon"; +import { useState } from "react"; import styled from "styled-components"; +import { BottomSheet } from "../common/bottomSheet/BottomSheet"; +import { BottomSheetGuide } from "../common/bottomSheet/BottomSheetGuide"; + +export type BottomSheetStatus = "NONE" | "DEPOSIT" | "COMPETITION" | "RETENTION_COMMITMENT"; const IPOInfo = () => { + const [isModalShowing, setIsModalShowing] = useState("NONE"); + + const renderBottomSheet = () => { + if (isModalShowing === "NONE") return; + return ( + setIsModalShowing("NONE")}> + {isModalShowing === "DEPOSIT" && ( + setIsModalShowing("NONE")}> +

+ 공모주를 배정받기 위해 미리 내는 보증금의 비율로, 일반적으로 50% 또는 100%이에요. +
+
+ 최소 청약증거금 계산 : +
+ 공모가 x 최소 신청 물량(보통 10주) x 증거금율 +
+ 이 금액을 청약 전 증권 계좌에 입금해야 해요. +
+
+ 상장 후 배정받지 못한 주수만큼 환불 받아요. +

+
+ )} + {isModalShowing === "COMPETITION" && ( + setIsModalShowing("NONE")}> +

+ 공모가가 확정되기 전, 기관들이 상장 예정인 회사의 주식을 받고자 청약한 경쟁률이에요. +
+
+ 높을수록 기관들이 사고자 하는 경쟁이 치열하다는 의미이고, 일반적으로 1000:1 이상의 경우 안정적이라고 + 평가해요. +

+
+ )} + {isModalShowing === "RETENTION_COMMITMENT" && ( + setIsModalShowing("NONE")}> +

+ 의무보유확약은 기관이 많은 공모주를 배정받는 대신, 상장 후 일정 기간 공모주를 팔지 않고, 의무적으로 + 보유하겠다는 약속이에요. +
+
+ 확약률은 이 약속을 내건 기관들의 비율이고요. 더 오랜 기간 보유를 약속한 기관이 많을수록 매력적인 + 공모주라고 판단할 수 있어요. +

+
+ )} +
+ ); + }; + return ( @@ -27,14 +82,14 @@ const IPOInfo = () => { 청약증거금율 - + setIsModalShowing("DEPOSIT")} /> 50% 기관투자자 경쟁률 - + setIsModalShowing("COMPETITION")} /> 516 : 1 console.log("hihihi")} width={16} height={16} /> @@ -43,7 +98,7 @@ const IPOInfo = () => { 의무보유 확약률 - + setIsModalShowing("RETENTION_COMMITMENT")} /> 의무보유 확약률 @@ -51,6 +106,7 @@ const IPOInfo = () => { + {renderBottomSheet()} ); }; diff --git a/src/components/home/UpcomingStock.tsx b/src/components/home/UpcomingStock.tsx index 013eaa9..b91a9e5 100644 --- a/src/components/home/UpcomingStock.tsx +++ b/src/components/home/UpcomingStock.tsx @@ -8,6 +8,7 @@ import BankIcon from "@/svg/BankIcon"; import { getFonts } from "@/styles/fonts"; import DangerIcon from "@/svg/DangerIcon"; import Button from "../common/Button"; +import { useRouter } from "next/navigation"; interface UpcomingStockProps { children: ReactNode; @@ -52,6 +53,7 @@ const UpcomingStockStatus: FC = ({ status, children }) }; const UpcomingStockCard: FC = (props) => { const { category, title, price, love, account, subscription, onClick, date = "" } = props; + const router = useRouter(); const renderSubscription = () => { switch (subscription) { @@ -93,7 +95,8 @@ const UpcomingStockCard: FC = (props) => { // TODO 보유한 계좌의 데이터가 어떤 형식으로 오는지 에상이 안되서 아직 개발 x return ( - + // TODO onClick은 임시 + router.push("/detail/1")}>
{category} diff --git a/src/components/mypage/ButtonSection.tsx b/src/components/mypage/ButtonSection.tsx index 3124b4a..24c849b 100644 --- a/src/components/mypage/ButtonSection.tsx +++ b/src/components/mypage/ButtonSection.tsx @@ -6,16 +6,18 @@ import styled from "styled-components"; import MyPageButton from "./MyPageButton"; import HeartIcon from "@/svg/HeartIcon"; import BankIcon from "@/svg/BankIcon"; +import { useRouter } from "next/navigation"; const ButtonSection = () => { + const router = useRouter(); return ( - + router.push("/home")}> 내 계좌 - + router.push("/interesting-stock")}> 관심공모주 diff --git a/src/components/mypage/MenuSection.tsx b/src/components/mypage/MenuSection.tsx index 1c7490f..107ef7f 100644 --- a/src/components/mypage/MenuSection.tsx +++ b/src/components/mypage/MenuSection.tsx @@ -1,19 +1,65 @@ +"use client"; import styled from "styled-components"; import MenuList from "./MenuList"; import colors from "@/styles/colors"; +import { useState } from "react"; +import Modal from "../common/Modal"; + +export interface ModalData { + title: string; + content?: string; + buttonText: [string, string] | string; + handlePrimaryButtonClick: () => void; +} + +export const initalModalData = { + title: "", + buttonText: "", + handlePrimaryButtonClick: () => {}, +}; const MenuSection = () => { + const [isModalShowing, setIsModalShowing] = useState(initalModalData); + + const logoutModalData: ModalData = { + title: "로그아웃하시겠습니까?", + buttonText: ["취소", "확인"], + handlePrimaryButtonClick: () => setIsModalShowing(initalModalData), + }; + + const withdrawalDoneModalData: ModalData = { + title: "모두모주 탈퇴가 완료되었습니다.", + buttonText: "다음에 또 올게요.", + handlePrimaryButtonClick: () => setIsModalShowing(initalModalData), + }; + + const withdrawalAskModalData: ModalData = { + title: "탈퇴하시겠습니까?", + content: "탈퇴 시 모든 정보는\n삭제되며 복구는 불가능합니다.\n정말 탈퇴하시겠습니까?", + buttonText: ["더써볼래요", "탈퇴할게요"], + handlePrimaryButtonClick: () => setIsModalShowing(withdrawalDoneModalData), + }; + return ( -
+ {}} /> - {}} /> - {}} /> -
-
+ setIsModalShowing(logoutModalData)} /> + setIsModalShowing(withdrawalAskModalData)} /> + + {}} /> {}} /> -
+ + {isModalShowing.title.length > 0 && ( + + )}
); }; @@ -21,10 +67,10 @@ const MenuSection = () => { const SectionWrapper = styled.section` height: 100%; background-color: ${colors.GRAY[1]}; +`; - div { - padding-top: 12px; - } +const MenuGroup = styled.div` + padding-top: 12px; `; export default MenuSection; diff --git a/src/components/mypage/MyPageButton.tsx b/src/components/mypage/MyPageButton.tsx index 92b0850..115bbb5 100644 --- a/src/components/mypage/MyPageButton.tsx +++ b/src/components/mypage/MyPageButton.tsx @@ -6,11 +6,12 @@ import styled from "styled-components"; interface MyPageButtonProps { children: React.ReactNode; + handleClick: () => void; } -const MyPageButton = ({ children }: MyPageButtonProps) => { +const MyPageButton = ({ children, handleClick }: MyPageButtonProps) => { return ( - ); diff --git a/src/stories/Modal.stories.tsx b/src/stories/Modal.stories.tsx new file mode 100644 index 0000000..ede7617 --- /dev/null +++ b/src/stories/Modal.stories.tsx @@ -0,0 +1,28 @@ +import Modal from "@/components/common/Modal"; +import { Meta, StoryObj } from "@storybook/react"; + +const meta = { + title: "Common/Modal", + component: Modal, + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const OneButton: Story = { + args: { + title: "모두모주 탈퇴가 완료되었습니다.", + buttonText: "다음에 또 올게요", + }, +}; + +export const TwoButton: Story = { + args: { + title: "탈퇴하시겠습니까?", + content: "탈퇴 시 모든 정보는\n삭제되며 복구는 불가능합니다.\n정말 탈퇴하시겠습니까?", + buttonText: ["더 써볼래요.", "탈퇴할게요"], + }, +}; diff --git a/src/svg/HeartIcon.tsx b/src/svg/HeartIcon.tsx index b9871cf..235faf1 100644 --- a/src/svg/HeartIcon.tsx +++ b/src/svg/HeartIcon.tsx @@ -2,14 +2,25 @@ import { FC, SVGProps } from "react"; interface FillProps extends SVGProps { color?: string; + width?: number; + height?: number; } interface BorderProps extends SVGProps { color?: string; + width?: number; + height?: number; } -const Fill: FC = ({ color = "#C9CDD2", ...rest }) => { +const Fill: FC = ({ width = 24, height = 24, color = "#C9CDD2", ...rest }) => { return ( - + = ({ color = "#C9CDD2", ...rest }) => { ); }; -const Border: FC = ({ color = "#1B1D1F", ...rest }) => ( - +const Border: FC = ({ width = 24, height = 24, color = "#1B1D1F", ...rest }) => ( +